From 5e7e6320b276e4af29c0561f87860a3e7a0d702b Mon Sep 17 00:00:00 2001 From: NichtNil5 Date: Fri, 6 Mar 2026 10:08:25 +0100 Subject: [PATCH 01/13] feat(tictactoe): add tictactoe draft --- gradle.properties | 4 +- plugin2099/build.gradle.kts | 11 +++ .../src/main/kotlin/sc/plugin2099/Board.kt | 54 ++++++++++++ .../main/kotlin/sc/plugin2099/FieldState.kt | 40 +++++++++ .../main/kotlin/sc/plugin2099/GameState.kt | 84 +++++++++++++++++++ .../src/main/kotlin/sc/plugin2099/Move.kt | 22 +++++ .../kotlin/sc/plugin2099/util/Constants.kt | 8 ++ .../kotlin/sc/plugin2099/util/GamePlugin.kt | 44 ++++++++++ .../sc/plugin2099/util/GameRuleLogic.kt | 80 ++++++++++++++++++ .../sc/plugin2099/util/XStreamClasses.kt | 19 +++++ settings.gradle.kts | 2 +- 11 files changed, 365 insertions(+), 3 deletions(-) create mode 100644 plugin2099/build.gradle.kts create mode 100644 plugin2099/src/main/kotlin/sc/plugin2099/Board.kt create mode 100644 plugin2099/src/main/kotlin/sc/plugin2099/FieldState.kt create mode 100644 plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt create mode 100644 plugin2099/src/main/kotlin/sc/plugin2099/Move.kt create mode 100644 plugin2099/src/main/kotlin/sc/plugin2099/util/Constants.kt create mode 100644 plugin2099/src/main/kotlin/sc/plugin2099/util/GamePlugin.kt create mode 100644 plugin2099/src/main/kotlin/sc/plugin2099/util/GameRuleLogic.kt create mode 100644 plugin2099/src/main/kotlin/sc/plugin2099/util/XStreamClasses.kt diff --git a/gradle.properties b/gradle.properties index 90411e188..9adce4d00 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ -socha.gameName=piranhas -socha.version.year=26 +socha.gameName=tictactoe +socha.version.year=99 socha.version.minor=00 socha.version.patch=07 socha.version.suffix= \ No newline at end of file diff --git a/plugin2099/build.gradle.kts b/plugin2099/build.gradle.kts new file mode 100644 index 000000000..a76ff84d4 --- /dev/null +++ b/plugin2099/build.gradle.kts @@ -0,0 +1,11 @@ +val game: String by project + +dependencies { + api(project(":sdk")) +} + +tasks { + jar { + archiveBaseName.set(game) + } +} diff --git a/plugin2099/src/main/kotlin/sc/plugin2099/Board.kt b/plugin2099/src/main/kotlin/sc/plugin2099/Board.kt new file mode 100644 index 000000000..788b0bd41 --- /dev/null +++ b/plugin2099/src/main/kotlin/sc/plugin2099/Board.kt @@ -0,0 +1,54 @@ +package sc.plugin2099 + +import com.thoughtworks.xstream.annotations.XStreamAlias +import com.thoughtworks.xstream.annotations.XStreamImplicit +import sc.api.plugins.* +import sc.framework.deepCopy +import sc.plugin2099.util.TicTacToeConstants + +val line = "-".repeat(TicTacToeConstants.BOARD_LENGTH * 2 + 2) + +/** Spielbrett für Piranhas mit [TicTacToeConstants.BOARD_LENGTH]² Feldern. */ +@XStreamAlias(value = "board") +class Board( + @XStreamImplicit(itemFieldName = "row") + override val gameField: MutableTwoDBoard = emptyFields() +): RectangularBoard(), IBoard { + + override fun toString() = + "Board " + gameField.withIndex().joinToString(" ", "[", "]") { row -> + row.value.withIndex().joinToString(", ", prefix = "[", postfix = "]") { + "(${it.index}, ${row.index}) " + it.value.toString() + } + } + + fun prettyString(): String { + val map = StringBuilder(line) + gameField.forEach { row -> + map.append("\n|") + row.forEach { field -> + map.append(field.asLetters()) + } + } + map.append("\n").append(line) + return map.toString() + } + + override fun clone(): Board { + //println("Cloning with ${gameField::class.java}: $this") + return Board(gameField.deepCopy()) + } + + fun getTeam(pos: Coordinates): Team? = + this[pos].team + + + companion object { + /** Erstellt ein leeres Spielbrett. */ + fun emptyFields(): MutableTwoDBoard { + return Array(TicTacToeConstants.BOARD_LENGTH) { + Array(TicTacToeConstants.BOARD_LENGTH) { FieldState.EMPTY } + } + } + } +} diff --git a/plugin2099/src/main/kotlin/sc/plugin2099/FieldState.kt b/plugin2099/src/main/kotlin/sc/plugin2099/FieldState.kt new file mode 100644 index 000000000..345630538 --- /dev/null +++ b/plugin2099/src/main/kotlin/sc/plugin2099/FieldState.kt @@ -0,0 +1,40 @@ +package sc.plugin2099 + +import com.thoughtworks.xstream.annotations.XStreamAlias +import sc.api.plugins.IField +import sc.api.plugins.Team +import sc.framework.DeepCloneable + +@XStreamAlias("field") +enum class FieldState(): IField, DeepCloneable { + CIRCLE, + CROSS, + EMPTY; + + override fun deepCopy(): FieldState = this + + override val isEmpty: Boolean + get() = this == EMPTY + + val team: Team? + get() = when(this) { + FieldState.CIRCLE -> Team.ONE + FieldState.CROSS -> Team.TWO + EMPTY -> null + } + + override fun toString() = + when(this) { + CIRCLE -> "Kreis" + CROSS -> "Kreuz" + EMPTY -> " " + } + + fun asLetters() = + when(this) { + CIRCLE -> "O " + CROSS -> "X" + EMPTY -> " " + } + +} diff --git a/plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt b/plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt new file mode 100644 index 000000000..f26c9e79e --- /dev/null +++ b/plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt @@ -0,0 +1,84 @@ +package sc.plugin2099 + +import com.thoughtworks.xstream.annotations.XStreamAlias +import com.thoughtworks.xstream.annotations.XStreamAsAttribute +import sc.api.plugins.ITeam +import sc.api.plugins.Stat +import sc.api.plugins.Team +import sc.api.plugins.TwoPlayerGameState +import sc.plugin2099.util.GameRuleLogic +import sc.plugin2099.util.TicTacToeConstants +import sc.plugin2099.util.TicTacToeWinReason +import sc.shared.InvalidMoveException +import sc.shared.MoveMistake +import sc.shared.WinCondition +import sc.shared.WinReasonTie + +/** + * The GameState class represents the current state of the game. + * + * It holds all the information about the current round, + * to provide all information needed to make the next move. + * + * @property board The current game board. + * @property turn The number of turns already made in the game. + * @property lastMove The last move made in the game. + */ +@XStreamAlias(value = "state") +data class GameState @JvmOverloads constructor( + /** Die Anzahl an bereits getätigten Zügen. */ + @XStreamAsAttribute override var turn: Int = 0, + /** Der zuletzt gespielte Zug. */ + override var lastMove: Move? = null, + /** Das aktuelle Spielfeld. */ + override val board: Board = Board(), +): TwoPlayerGameState(Team.ONE) { + + // Bin mir nicht sicher wie man TicTacToe bewerten soll. + override fun getPointsForTeam(team: ITeam): IntArray = + intArrayOf(0) + + override val isOver: Boolean + get() = (GameRuleLogic.checkWinner(board) != null) || + turn >= TicTacToeConstants.TURN_LIMIT + + override val winCondition: WinCondition? + get() = + if(GameRuleLogic.checkWinner(board) != null || turn >= TicTacToeConstants.TURN_LIMIT) { + GameRuleLogic.checkWinner(board) + ?.let { WinCondition(it, TicTacToeWinReason.FIRST_THREE_IN_A_LINE) } + ?: WinCondition(null, WinReasonTie) + } else { + null + } + + override fun performMoveDirectly(move: Move) { + GameRuleLogic.checkMove(board, move)?.let { throw InvalidMoveException(it, move) } + board[move.field] = if (turn % 2 == 0) { + FieldState.CIRCLE + } else { + FieldState.CROSS + } + turn++ + lastMove = move + } + + override fun getSensibleMoves(): List { + val piranhas = board.filterValues { field -> field.team == currentTeam } + val moves = ArrayList(piranhas.size * 2) + moves.addAll(GameRuleLogic.possibleMoves(board)) + return moves + } + + override fun moveIterator(): Iterator = + getSensibleMoves().iterator() + + override fun clone(): GameState = + copy(board = board.clone()) + + // Keine wirklichen Stats vorhanden bei TicTacToe. + override fun teamStats(team: ITeam): List = + listOf( + ) + +} \ No newline at end of file diff --git a/plugin2099/src/main/kotlin/sc/plugin2099/Move.kt b/plugin2099/src/main/kotlin/sc/plugin2099/Move.kt new file mode 100644 index 000000000..b4918627c --- /dev/null +++ b/plugin2099/src/main/kotlin/sc/plugin2099/Move.kt @@ -0,0 +1,22 @@ +package sc.plugin2099 + +import com.thoughtworks.xstream.annotations.XStreamAlias +import sc.api.plugins.Coordinates +import sc.api.plugins.IMove +import sc.plugin2099.util.GameRuleLogic + +@XStreamAlias("move") +/** + * Spielzug: Eine Bewegung eines Fisches. + * + * Für weitere Funktionen siehe [GameRuleLogic]. + */ +data class Move( + /** Position des zu bewegenden Fisches. */ + val field: Coordinates, +): IMove { + + override fun toString(): String = + "Beanspruche $field" + +} diff --git a/plugin2099/src/main/kotlin/sc/plugin2099/util/Constants.kt b/plugin2099/src/main/kotlin/sc/plugin2099/util/Constants.kt new file mode 100644 index 000000000..b90df268b --- /dev/null +++ b/plugin2099/src/main/kotlin/sc/plugin2099/util/Constants.kt @@ -0,0 +1,8 @@ +package sc.plugin2099.util + +/** Eine Sammlung an verschiedenen Konstanten, die im Spiel verwendet werden. */ +object TicTacToeConstants { + const val BOARD_LENGTH: Int = 3 + + const val TURN_LIMIT: Int = 9 +} \ No newline at end of file diff --git a/plugin2099/src/main/kotlin/sc/plugin2099/util/GamePlugin.kt b/plugin2099/src/main/kotlin/sc/plugin2099/util/GamePlugin.kt new file mode 100644 index 000000000..740a91dd4 --- /dev/null +++ b/plugin2099/src/main/kotlin/sc/plugin2099/util/GamePlugin.kt @@ -0,0 +1,44 @@ +package sc.plugin2099.util + +import com.thoughtworks.xstream.annotations.XStreamAlias +import sc.api.plugins.IGameInstance +import sc.api.plugins.IGamePlugin +import sc.api.plugins.IGameState +import sc.framework.plugins.TwoPlayerGame +import sc.plugin2099.GameState +import sc.plugin2099.Move +import sc.shared.* + +@XStreamAlias(value = "winreason") +enum class TicTacToeWinReason(override val message: String, override val isRegular: Boolean = true): IWinReason { + FIRST_THREE_IN_A_LINE("%s hat zuerst drei felder mit seiner Markierung markiert."), +} + +class GamePlugin: IGamePlugin { + companion object { + const val PLUGIN_ID = "swc_2026_tictactoe" + val scoreDefinition: ScoreDefinition = + ScoreDefinition(arrayOf( + ScoreFragment("Siegpunkte", WinReason("%s hat gewonnen."), ScoreAggregation.SUM), + )) + } + + override val id = PLUGIN_ID + + override val name = "TicTacToe" + + override val scoreDefinition = + Companion.scoreDefinition + + override val turnLimit: Int = + TicTacToeConstants.TURN_LIMIT + + override val moveClass = Move::class.java + + override fun createGame(): IGameInstance = + TwoPlayerGame(this, GameState()) + + override fun createGameFromState(state: IGameState): IGameInstance = + TwoPlayerGame(this, state as GameState) + +} diff --git a/plugin2099/src/main/kotlin/sc/plugin2099/util/GameRuleLogic.kt b/plugin2099/src/main/kotlin/sc/plugin2099/util/GameRuleLogic.kt new file mode 100644 index 000000000..b512cb7aa --- /dev/null +++ b/plugin2099/src/main/kotlin/sc/plugin2099/util/GameRuleLogic.kt @@ -0,0 +1,80 @@ +package sc.plugin2099.util + +import sc.api.plugins.Coordinates +import sc.api.plugins.Team +import sc.plugin2099.Board +import sc.plugin2099.FieldState +import sc.plugin2099.Move +import sc.shared.IMoveMistake +import sc.shared.MoveMistake + +object GameRuleLogic { + + /** Prüft, ob ein Zug gültig ist. + * @team null wenn der Zug valide ist, sonst ein entsprechender [IMoveMistake]. */ + @JvmStatic + fun checkMove(board: Board, move: Move): IMoveMistake? { + val destination = board.getOrNull(move.field) ?: return MoveMistake.DESTINATION_OUT_OF_BOUNDS + if (destination != FieldState.EMPTY) {return MoveMistake.DESTINATION_BLOCKED } + return null + } + + /** Valide Züge. */ + @JvmStatic + fun possibleMoves(board: Board): Collection { + val moves: MutableList = ArrayList() + for (field in board.entries) { + if (field.value == FieldState.EMPTY) {moves.add(Move(field.key))} + } + return moves + } + + /** @return the [Coordinates] from [parentSet] which are neighbors of [pos] */ + private fun selectNeighbors(pos: Coordinates, parentSet: Collection): Collection { + val returnSet = ArrayList(8) + for(i in -1..1) { + for(j in -1..1) { + val x = pos.x + i + val y = pos.y + j + if(x < 0 || x >= TicTacToeConstants.BOARD_LENGTH || + y < 0 || y >= TicTacToeConstants.BOARD_LENGTH || + (i == 0 && j == 0)) continue + + val coord = Coordinates(x, y) + if(parentSet.contains(coord)) { + returnSet.add(coord) + } + } + } + return returnSet + } + + @JvmStatic + fun checkWinner(board: Board,): Team? { + // Check rows and columns for a win + for (i in 0 until 3) { + if (board[Coordinates(i, 0)] != FieldState.EMPTY && + board[Coordinates(i, 0)] == board[Coordinates(i, 1)] && + board[Coordinates(i, 1)] == board[Coordinates(i, 2)]) { + return board[Coordinates(i, 0)].team // Return the winning team + } + + if (board[Coordinates(0, i)] != FieldState.EMPTY && + board[Coordinates(0, i)] == board[Coordinates(1, i)] && + board[Coordinates(1, i)] == board[Coordinates(2, i)]) { + return board[Coordinates(0, i)].team // Return the winning team + } + } + + if (board[Coordinates(1, 1)] != FieldState.EMPTY) { + if (board[Coordinates(0, 0)] == board[Coordinates(1, 1)] && board[Coordinates(1, 1)] == board[Coordinates(2, 2)]) { + return board[Coordinates(1, 1)].team // Return the winning team + } + if (board[Coordinates(0, 2)] == board[Coordinates(1, 1)] && board[Coordinates(1, 1)] == board[Coordinates(2, 0)]) { + return board[Coordinates(1, 1)].team // Return the winning team + } + } + + return null + } +} \ No newline at end of file diff --git a/plugin2099/src/main/kotlin/sc/plugin2099/util/XStreamClasses.kt b/plugin2099/src/main/kotlin/sc/plugin2099/util/XStreamClasses.kt new file mode 100644 index 000000000..5150bb531 --- /dev/null +++ b/plugin2099/src/main/kotlin/sc/plugin2099/util/XStreamClasses.kt @@ -0,0 +1,19 @@ +package sc.plugin2099.util + +import sc.networking.XStreamProvider +import sc.plugin2099.Board +import sc.plugin2099.FieldState +import sc.plugin2099.GameState +import sc.plugin2099.Move + +class XStreamClasses: XStreamProvider { + + override val classesToRegister: List> = + listOf( + GameState::class.java, + Board::class.java, + FieldState::class.java, + Move::class.java, + ) + +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index aa57692c9..fed4b8309 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,5 +3,5 @@ rootProject.buildFileName = "gradle/build.gradle.kts" includeBuild("gradle/custom-tasks") -include("sdk", "server", "plugin", "plugin2025", "plugin2026", "player", "test-client") +include("sdk", "server", "plugin", "plugin2025", "plugin2026", "plugin2099", "player", "test-client") project(":test-client").projectDir = file("helpers/test-client") From 036d7a69b62d252badd938f99a4288be1f0c4a8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Domr=C3=B6s?= Date: Tue, 31 Mar 2026 14:13:45 +0200 Subject: [PATCH 02/13] fix(piranhas26): end game when one player cannot move (#438) * Adds game over on no moves with test. * Adds new win condition for blocked opponent. * Removes blocked game over todo comment. --- .../main/kotlin/sc/plugin2026/GameState.kt | 5 ++- .../kotlin/sc/plugin2026/util/GamePlugin.kt | 1 + .../kotlin/sc/plugin2026/GameRuleLogicTest.kt | 33 ++++++++++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/plugin2026/src/main/kotlin/sc/plugin2026/GameState.kt b/plugin2026/src/main/kotlin/sc/plugin2026/GameState.kt index a14a05509..8f5550266 100644 --- a/plugin2026/src/main/kotlin/sc/plugin2026/GameState.kt +++ b/plugin2026/src/main/kotlin/sc/plugin2026/GameState.kt @@ -37,9 +37,9 @@ data class GameState @JvmOverloads constructor( override fun getPointsForTeam(team: ITeam): IntArray = intArrayOf(GameRuleLogic.greatestSwarmSize(board, team)) - // TODO test if one player is unable to move he loses e.g. in corner override val isOver: Boolean get() = (Team.values().any { GameRuleLogic.isSwarmConnected(board, it) } && turn.mod(2) == 0) || + this.getSensibleMoves().isEmpty() || turn / 2 >= PiranhaConstants.ROUND_LIMIT override val winCondition: WinCondition? @@ -48,6 +48,9 @@ data class GameState @JvmOverloads constructor( Team.values().toList().maxByNoEqual { team -> GameRuleLogic.greatestSwarmSize(board, team) } ?.let { WinCondition(it, PiranhasWinReason.BIGGER_SWARM) } ?: WinCondition(null, WinReasonTie) + } else if (this.getSensibleMoves().isEmpty()) { + val team = this.currentTeam.opponent() + WinCondition(team, PiranhasWinReason.BLOCKED) } else { null } diff --git a/plugin2026/src/main/kotlin/sc/plugin2026/util/GamePlugin.kt b/plugin2026/src/main/kotlin/sc/plugin2026/util/GamePlugin.kt index 2b9e315d4..fec96b0e7 100644 --- a/plugin2026/src/main/kotlin/sc/plugin2026/util/GamePlugin.kt +++ b/plugin2026/src/main/kotlin/sc/plugin2026/util/GamePlugin.kt @@ -13,6 +13,7 @@ import sc.shared.* enum class PiranhasWinReason(override val message: String, override val isRegular: Boolean = true): IWinReason { BIGGER_SWARM("%s hat den größeren zusammenhängenden Schwarm"), FIRST_UNION("%s hat zuerst alle Fische einer Farbe vereinigt"), + BLOCKED("%s hat den Gegner blockiert, sodass er keinen Zug mehr machen kann"), } class GamePlugin: IGamePlugin { diff --git a/plugin2026/src/test/kotlin/sc/plugin2026/GameRuleLogicTest.kt b/plugin2026/src/test/kotlin/sc/plugin2026/GameRuleLogicTest.kt index fe561673f..f477bd9e0 100644 --- a/plugin2026/src/test/kotlin/sc/plugin2026/GameRuleLogicTest.kt +++ b/plugin2026/src/test/kotlin/sc/plugin2026/GameRuleLogicTest.kt @@ -9,8 +9,9 @@ import io.kotest.matchers.maps.* import sc.api.plugins.Coordinates import sc.api.plugins.Direction import sc.api.plugins.Team -import sc.plugin2026.util.GameRuleLogic +import sc.plugin2026.util.* import sc.shared.MoveMistake +import sc.shared.WinCondition class GameRuleLogicTest: FunSpec({ context("swarm size") { @@ -53,4 +54,34 @@ class GameRuleLogicTest: FunSpec({ GameRuleLogic.possibleMovesFor(board, fish) shouldHaveSize 3 } } + /** + * Check if a player loses when no move is possible. + */ + context("losing by no moves") { + test("cornered") { + val board = Board() + // Remove all piranhas from the board + for(x in 0 until PiranhaConstants.BOARD_LENGTH) { + for(y in 0 until PiranhaConstants.BOARD_LENGTH) { + board[x, y] = FieldState.EMPTY + } + } + + // Readd piranhas for the test + // Piranha at (0, 0) is blocked + board[0, 0] = FieldState.ONE_S + board[1, 0] = FieldState.TWO_S + board[1, 1] = FieldState.TWO_S + board[0, 1] = FieldState.TWO_S + + // Piranha at (9, 9) is also blocked + board[9, 9] = FieldState.ONE_S + board[8, 9] = FieldState.TWO_S + board[8, 8] = FieldState.TWO_S + board[9, 8] = FieldState.TWO_S + val gameState = GameState(board = board, turn = 0) + gameState.isOver shouldBe true + gameState.winCondition shouldBe WinCondition(Team.TWO, PiranhasWinReason.BLOCKED) + } + } }) From e7ea26945eaf501ca7c1ac692b32d29ff9046de5 Mon Sep 17 00:00:00 2001 From: xeruf Date: Tue, 31 Mar 2026 15:14:57 +0200 Subject: [PATCH 03/13] fix(plugin26): implement FIRST_UNION tie-breaker - Add firstUnion to GameState and set on first full connection - Use FIRST_UNION as tie-breaker when greatest swarms tie --- .../src/main/kotlin/sc/plugin2026/Board.kt | 4 ++ .../main/kotlin/sc/plugin2026/GameState.kt | 19 +++++- .../kotlin/sc/plugin2026/GameRuleLogicTest.kt | 12 +--- .../kotlin/sc/plugin2026/GameStateTest.kt | 66 +++++++++++++++++-- 4 files changed, 82 insertions(+), 19 deletions(-) diff --git a/plugin2026/src/main/kotlin/sc/plugin2026/Board.kt b/plugin2026/src/main/kotlin/sc/plugin2026/Board.kt index 4a4ac162d..6e3698518 100644 --- a/plugin2026/src/main/kotlin/sc/plugin2026/Board.kt +++ b/plugin2026/src/main/kotlin/sc/plugin2026/Board.kt @@ -48,6 +48,10 @@ class Board( .mapValues { (_, field) -> field.size } companion object { + /** Test helper to generate an empty board. */ + val EMPTY + get() = Board(Array(PiranhaConstants.BOARD_LENGTH) { Array(PiranhaConstants.BOARD_LENGTH) { FieldState.EMPTY } }) + /** Erstellt ein zufälliges Spielbrett. */ fun randomFields( obstacleCount: Int = PiranhaConstants.NUM_OBSTACLES, diff --git a/plugin2026/src/main/kotlin/sc/plugin2026/GameState.kt b/plugin2026/src/main/kotlin/sc/plugin2026/GameState.kt index 8f5550266..62eb2ac1d 100644 --- a/plugin2026/src/main/kotlin/sc/plugin2026/GameState.kt +++ b/plugin2026/src/main/kotlin/sc/plugin2026/GameState.kt @@ -32,6 +32,8 @@ data class GameState @JvmOverloads constructor( override var lastMove: Move? = null, /** Das aktuelle Spielfeld. */ override val board: Board = Board(), + /** Team, das als erstes seinen vollständigen Schwarm gebildet hat (alle eigenen Fische zusammenhängend). */ + @XStreamAsAttribute var firstUnion: Team? = null, ): TwoPlayerGameState(Team.ONE) { override fun getPointsForTeam(team: ITeam): IntArray = @@ -45,9 +47,11 @@ data class GameState @JvmOverloads constructor( override val winCondition: WinCondition? get() = if(Team.values().any { team -> GameRuleLogic.isSwarmConnected(board, team) }) { - Team.values().toList().maxByNoEqual { team -> GameRuleLogic.greatestSwarmSize(board, team) } - ?.let { WinCondition(it, PiranhasWinReason.BIGGER_SWARM) } - ?: WinCondition(null, WinReasonTie) + // Bestimme Team mit eindeutig größtem Schwarm oder nutze Tie‑Breaker FIRST_UNION, sonst Unentschieden + val best = Team.values().toList().maxByNoEqual { team -> GameRuleLogic.greatestSwarmSize(board, team) } + best?.let { WinCondition(it, PiranhasWinReason.BIGGER_SWARM) } + ?: firstUnion?.let { WinCondition(it, PiranhasWinReason.FIRST_UNION) } + ?: WinCondition(null, WinReasonTie) } else if (this.getSensibleMoves().isEmpty()) { val team = this.currentTeam.opponent() WinCondition(team, PiranhasWinReason.BLOCKED) @@ -65,6 +69,15 @@ data class GameState @JvmOverloads constructor( board[move.from] = FieldState.EMPTY turn++ lastMove = move + // Nach dem Zug prüfen, ob ein Team erstmals vollständig verbunden ist + if(firstUnion == null) { + for(team in Team.values()) { + if(GameRuleLogic.isSwarmConnected(board, team)) { + firstUnion = team + break + } + } + } } override fun getSensibleMoves(): List { diff --git a/plugin2026/src/test/kotlin/sc/plugin2026/GameRuleLogicTest.kt b/plugin2026/src/test/kotlin/sc/plugin2026/GameRuleLogicTest.kt index f477bd9e0..97c359830 100644 --- a/plugin2026/src/test/kotlin/sc/plugin2026/GameRuleLogicTest.kt +++ b/plugin2026/src/test/kotlin/sc/plugin2026/GameRuleLogicTest.kt @@ -54,18 +54,10 @@ class GameRuleLogicTest: FunSpec({ GameRuleLogic.possibleMovesFor(board, fish) shouldHaveSize 3 } } - /** - * Check if a player loses when no move is possible. - */ + /** Check if a player loses when no move is possible. */ context("losing by no moves") { test("cornered") { - val board = Board() - // Remove all piranhas from the board - for(x in 0 until PiranhaConstants.BOARD_LENGTH) { - for(y in 0 until PiranhaConstants.BOARD_LENGTH) { - board[x, y] = FieldState.EMPTY - } - } + val board = Board.EMPTY // Readd piranhas for the test // Piranha at (0, 0) is blocked diff --git a/plugin2026/src/test/kotlin/sc/plugin2026/GameStateTest.kt b/plugin2026/src/test/kotlin/sc/plugin2026/GameStateTest.kt index 978e7173e..8f079b3b5 100644 --- a/plugin2026/src/test/kotlin/sc/plugin2026/GameStateTest.kt +++ b/plugin2026/src/test/kotlin/sc/plugin2026/GameStateTest.kt @@ -9,6 +9,8 @@ import sc.api.plugins.Direction import sc.api.plugins.Team import sc.helpers.testXStream import sc.shared.InvalidMoveException +import sc.shared.WinReasonTie +import sc.plugin2026.util.PiranhaConstants class GameStateTest: FunSpec({ test("cloning") { @@ -17,7 +19,7 @@ class GameStateTest: FunSpec({ } context("XML Serialization") { test("deserialization") { - val state = GameState() + val state = GameState(firstUnion = Team.TWO) val xml = testXStream.toXML(state) xml shouldHaveLineCount 124 val restate = testXStream.fromXML(xml) as GameState @@ -25,20 +27,72 @@ class GameStateTest: FunSpec({ restate.currentTeam shouldBe Team.ONE restate shouldBe state restate.board.toString() shouldBe state.board.toString() + restate.firstUnion shouldBe Team.TWO val startMove = Move(Coordinates(0, 1), Direction.RIGHT) state.performMoveDirectly(startMove) - restate shouldNotBe state - // FIXME restate.board.clone() - //val clone = restate.clone() - //clone shouldBe state + restate shouldNotBe state + val clone = restate.clone() + clone shouldNotBe state restate.performMoveDirectly(startMove) restate shouldBe state - //clone shouldNotBe state + clone shouldNotBe state restate.performMoveDirectly(Move(Coordinates(1, 0), Direction.RIGHT)) shouldThrow { restate.performMoveDirectly(startMove) }.mistake shouldBe PiranhaMoveMistake.WRONG_START } } + + context("FIRST_UNION Tie-Breaker") { + context("manuell") { + val board = Board.EMPTY + val state = GameState(board = board) + // Team ONE: zwei benachbarte 1er-Fische (vollständig verbunden) + board[Coordinates(1, 1)] = FieldState.from(Team.ONE, 1) + board[Coordinates(2, 1)] = FieldState.from(Team.ONE, 1) + // Team TWO: zwei benachbarte 1er-Fische (auch verbunden) + board[Coordinates(7, 7)] = FieldState.from(Team.TWO, 1) + board[Coordinates(7, 8)] = FieldState.from(Team.TWO, 1) + + test("bei Gleichstand entscheidet FIRST_UNION") { + state.firstUnion = Team.ONE + + val win = state.winCondition + win?.winner shouldBe Team.ONE + win?.reason shouldBe sc.plugin2026.util.PiranhasWinReason.FIRST_UNION + } + + test("ohne FIRST_UNION bleibt es Unentschieden") { + // firstUnion bleibt null + val win = state.winCondition + win?.winner shouldBe null + win?.reason shouldBe WinReasonTie + } + } + + test("FIRST_UNION wird beim ersten verbundenen Team gesetzt und bleibt auch nach zweiter Verbindung bestehen") { + val board = Board.EMPTY + val state = GameState(board = board) + + // Team ONE: zwei 1er-Fische, die NICHT verbunden sind, aber durch einen 1er-Schritt verbunden werden können + board[Coordinates(1, 1)] = FieldState.from(Team.ONE, 1) + board[Coordinates(3, 2)] = FieldState.from(Team.ONE, 1) + + // Team TWO: ebenfalls zwei 1er-Fische, die durch einen 1er-Schritt verbunden werden können + board[Coordinates(7, 7)] = FieldState.from(Team.TWO, 1) + board[Coordinates(9, 8)] = FieldState.from(Team.TWO, 1) + + // Vor den Zügen ist noch kein Team vollständig verbunden + state.firstUnion shouldBe null + + // Zug 1 (Team.ONE zuerst am Zug): (1,1) -> RIGHT (2,1) verbindet die roten Fische vollständig + state.performMoveDirectly(Move(Coordinates(1, 1), Direction.RIGHT)) + state.firstUnion shouldBe Team.ONE + + // Zug 2 (Team.TWO): (7,7) -> RIGHT (8,7) verbindet die blauen Fische, firstUnion bleibt jedoch Team.ONE + state.performMoveDirectly(Move(Coordinates(7, 7), Direction.RIGHT)) + state.firstUnion shouldBe Team.ONE + } + } }) \ No newline at end of file From dbdb37b2d3ba8099c41b54c90e8c25776e7f48f7 Mon Sep 17 00:00:00 2001 From: xeruf Date: Thu, 2 Apr 2026 11:38:21 +0200 Subject: [PATCH 04/13] release: v26.1.0 --- gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 90411e188..726463fed 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ socha.gameName=piranhas socha.version.year=26 -socha.version.minor=00 -socha.version.patch=07 +socha.version.minor=01 +socha.version.patch=00 socha.version.suffix= \ No newline at end of file From fa9c94d94d684373788c1d6702f921fdecc05664 Mon Sep 17 00:00:00 2001 From: NichtNil5 Date: Wed, 8 Apr 2026 05:05:44 +0200 Subject: [PATCH 05/13] fix(tictactoe): Fixed player crashing when starting a game. Added missing files: sc.api.plugins.IGamePlugin and sc.networking.XStreamProvider. --- .../main/resources/META-INF/services/sc.api.plugins.IGamePlugin | 1 + .../resources/META-INF/services/sc.networking.XStreamProvider | 1 + 2 files changed, 2 insertions(+) create mode 100644 plugin2099/src/main/resources/META-INF/services/sc.api.plugins.IGamePlugin create mode 100644 plugin2099/src/main/resources/META-INF/services/sc.networking.XStreamProvider diff --git a/plugin2099/src/main/resources/META-INF/services/sc.api.plugins.IGamePlugin b/plugin2099/src/main/resources/META-INF/services/sc.api.plugins.IGamePlugin new file mode 100644 index 000000000..09d84b68d --- /dev/null +++ b/plugin2099/src/main/resources/META-INF/services/sc.api.plugins.IGamePlugin @@ -0,0 +1 @@ +sc.plugin2099.util.GamePlugin diff --git a/plugin2099/src/main/resources/META-INF/services/sc.networking.XStreamProvider b/plugin2099/src/main/resources/META-INF/services/sc.networking.XStreamProvider new file mode 100644 index 000000000..a8d4ed6e9 --- /dev/null +++ b/plugin2099/src/main/resources/META-INF/services/sc.networking.XStreamProvider @@ -0,0 +1 @@ +sc.plugin2099.util.XStreamClasses From 9ec67518c3d41c7b26ae925723e6a203054ec07e Mon Sep 17 00:00:00 2001 From: NichtNil5 Date: Fri, 10 Apr 2026 04:54:28 +0200 Subject: [PATCH 06/13] fix(tictactoe): Fixed game crashing when game over. --- plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt b/plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt index f26c9e79e..cc28f67f5 100644 --- a/plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt +++ b/plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt @@ -10,7 +10,6 @@ import sc.plugin2099.util.GameRuleLogic import sc.plugin2099.util.TicTacToeConstants import sc.plugin2099.util.TicTacToeWinReason import sc.shared.InvalidMoveException -import sc.shared.MoveMistake import sc.shared.WinCondition import sc.shared.WinReasonTie @@ -34,9 +33,8 @@ data class GameState @JvmOverloads constructor( override val board: Board = Board(), ): TwoPlayerGameState(Team.ONE) { - // Bin mir nicht sicher wie man TicTacToe bewerten soll. override fun getPointsForTeam(team: ITeam): IntArray = - intArrayOf(0) + intArrayOf() override val isOver: Boolean get() = (GameRuleLogic.checkWinner(board) != null) || @@ -76,7 +74,6 @@ data class GameState @JvmOverloads constructor( override fun clone(): GameState = copy(board = board.clone()) - // Keine wirklichen Stats vorhanden bei TicTacToe. override fun teamStats(team: ITeam): List = listOf( ) From e3c0b0c07eaeee05fc924fe8cb799675e91e4565 Mon Sep 17 00:00:00 2001 From: NichtNil5 Date: Mon, 20 Apr 2026 15:30:02 +0200 Subject: [PATCH 07/13] test(tictactoe): Added GamePlayTest Added season independent plugin-test. --- plugin2099/src/test/kotlin/sc/GamePlayTest.kt | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 plugin2099/src/test/kotlin/sc/GamePlayTest.kt diff --git a/plugin2099/src/test/kotlin/sc/GamePlayTest.kt b/plugin2099/src/test/kotlin/sc/GamePlayTest.kt new file mode 100644 index 000000000..b78a0d4d1 --- /dev/null +++ b/plugin2099/src/test/kotlin/sc/GamePlayTest.kt @@ -0,0 +1,118 @@ +package sc + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.assertions.withClue +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.WordSpec +import io.kotest.matchers.* +import io.kotest.matchers.booleans.* +import io.kotest.matchers.iterator.* +import io.kotest.matchers.nulls.* +import org.slf4j.LoggerFactory +import sc.api.plugins.IGamePlugin +import sc.api.plugins.IGameState +import sc.api.plugins.Team +import sc.api.plugins.TwoPlayerGameState +import sc.api.plugins.exceptions.TooManyPlayersException +import sc.api.plugins.host.IGameListener +import sc.framework.plugins.AbstractGame +import sc.framework.plugins.Constants +import sc.shared.GameResult +import kotlin.time.Duration.Companion.milliseconds + +/** This test verifies that the Game implementation can be used to play a game. + * It is the only plugin-test independent of the season. */ +class GamePlayTest: WordSpec({ + val logger = LoggerFactory.getLogger(GamePlayTest::class.java) + isolationMode = IsolationMode.SingleInstance + val plugin = IGamePlugin.loadPlugin() + fun createGame() = plugin.createGame() as AbstractGame<*> + "A Game" should { + val game = createGame() + "let players join" { + game.onPlayerJoined() + game.onPlayerJoined() + } + "throw on third player join" { + shouldThrow { + game.onPlayerJoined() + } + } + "set activePlayer on start" { + game.start() + game.activePlayer shouldNotBe null + } + "stay paused after move" { + game.isPaused = true + game.onRoundBasedAction(game.currentState.moveIterator().next()) + game.isPaused shouldBe true + } + } + "A Game started with two players" When { + "played normally" should { + val game = createGame() + game.onPlayerJoined().team shouldBe Team.ONE + game.onPlayerJoined().team shouldBe Team.TWO + game.start() + + var finalState: Int? = null + game.addGameListener(object: IGameListener { + override fun onGameOver(result: GameResult) { + logger.info("Game over: $result") + } + + override fun onStateChanged(data: IGameState, observersOnly: Boolean) { + val state = data as? TwoPlayerGameState<*> + state?.lastMove.shouldNotBeNull() + data.hashCode() shouldNotBe finalState + // hashing it to avoid cloning, since we get the original object which might be mutable + finalState = data.hashCode() + logger.debug("Updating state hash to $finalState") + } + }) + + "finish without issues".config(invocationTimeout = plugin.gameTimeout.milliseconds) { + while(true) { + try { + val condition = game.checkWinCondition() + if(condition != null) { + logger.info("Game ended with $condition") + break + } + + val state = game.currentState + if(finalState != null) + finalState shouldBe state.hashCode() + + val moves = state.moveIterator() + withClue(state) { + moves.shouldHaveNext() + game.onAction(game.players[state.currentTeam.index], moves.next()) + } + } catch(e: Exception) { + logger.warn(e.message) + break + } + } + withClue(game.currentState) { + // Note that this fails if the game ends irregularly + game.currentState.isOver.shouldBeTrue() + } + } + "send the final state to listeners" { + finalState shouldBe game.currentState.hashCode() + } + "return regular scores" { + val result = game.getResult() + result.isRegular shouldBe true + val scores = result.scores.values + scores.first().parts.first().intValueExact() shouldBe when(scores.last().parts.first().intValueExact()) { + Constants.LOSE_SCORE -> Constants.WIN_SCORE + Constants.WIN_SCORE -> Constants.LOSE_SCORE + Constants.DRAW_SCORE -> Constants.DRAW_SCORE + else -> throw NoWhenBranchMatchedException() + } + } + } + } +}) From 9a355652423ee44779241370e39c58b3fb9f8f9a Mon Sep 17 00:00:00 2001 From: Unbekannt28 Date: Mon, 20 Apr 2026 21:11:46 +0200 Subject: [PATCH 08/13] Added board test based on plugin2026 board test --- .../test/kotlin/sc/plugin2099/BoardTest.kt | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 plugin2099/src/test/kotlin/sc/plugin2099/BoardTest.kt diff --git a/plugin2099/src/test/kotlin/sc/plugin2099/BoardTest.kt b/plugin2099/src/test/kotlin/sc/plugin2099/BoardTest.kt new file mode 100644 index 000000000..e8aebe6f7 --- /dev/null +++ b/plugin2099/src/test/kotlin/sc/plugin2099/BoardTest.kt @@ -0,0 +1,46 @@ +package sc.plugin2099 + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldHaveLineCount +import io.kotest.matchers.types.shouldBeSameInstanceAs +import sc.helpers.shouldSerializeTo +import sc.networking.XStreamProvider +import sc.plugin2099.util.XStreamClasses +import sc.protocol.LobbyProtocol + +class BoardTest: FunSpec( { + val board = Board() + context("generation") { + test("obstacles position") { + board.fieldsEmpty() shouldBe true + } + } + + val xstream = XStreamProvider.basic() + LobbyProtocol.registerAdditionalMessages(xstream, XStreamClasses().classesToRegister) + context("serialization") { + test("field") { + FieldState.CIRCLE shouldSerializeTo "CIRCLE" + xstream.fromXML(xstream.toXML(FieldState.CIRCLE)) shouldBeSameInstanceAs FieldState.CIRCLE + } + + test("board") { + xstream.toXML(board) shouldHaveLineCount 17 + xstream.toXML(board.clone()) shouldHaveLineCount 17 + + Board(arrayOf(arrayOf(FieldState.CIRCLE, FieldState.CROSS))) shouldSerializeTo """ + + + CIRCLE + CROSS + + """.trimIndent() + + val xml = xstream.toXML(board) + val reboard = xstream.fromXML(xml) + + (reboard as Board).clone() shouldBe board + } + } +}) \ No newline at end of file From 39296f25e0082f3bccc34077515c8ccc7ee8a79d Mon Sep 17 00:00:00 2001 From: Unbekannt28 Date: Wed, 22 Apr 2026 01:01:39 +0200 Subject: [PATCH 09/13] Added more tests --- .../kotlin/sc/plugin2099/GameRuleLogicTest.kt | 42 +++++++++++++++++++ .../kotlin/sc/plugin2099/GameStateTest.kt | 41 ++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 plugin2099/src/test/kotlin/sc/plugin2099/GameRuleLogicTest.kt create mode 100644 plugin2099/src/test/kotlin/sc/plugin2099/GameStateTest.kt diff --git a/plugin2099/src/test/kotlin/sc/plugin2099/GameRuleLogicTest.kt b/plugin2099/src/test/kotlin/sc/plugin2099/GameRuleLogicTest.kt new file mode 100644 index 000000000..d5c0a18d5 --- /dev/null +++ b/plugin2099/src/test/kotlin/sc/plugin2099/GameRuleLogicTest.kt @@ -0,0 +1,42 @@ +package sc.plugin2099 + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import sc.api.plugins.Coordinates +import sc.api.plugins.Team +import sc.plugin2099.util.GameRuleLogic +import sc.shared.MoveMistake + +class GameRuleLogicTest: FunSpec({ + test("possible moves") { + val board = Board() + board[1, 2] = FieldState.CROSS + board[2, 2] = FieldState.CIRCLE + GameRuleLogic.possibleMoves(board) shouldHaveSize 7 + + GameRuleLogic.checkMove(board, Move(Coordinates(1, 2))) shouldBe MoveMistake.DESTINATION_BLOCKED + GameRuleLogic.checkMove(board, Move(Coordinates(1, 1))) shouldBe null + GameRuleLogic.checkWinner(board) shouldBe null + + board[1, 1] = FieldState.CROSS + board[1, 0] = FieldState.CROSS + + GameRuleLogic.checkWinner(board) shouldBe Team.TWO + } + + test("apply moves") { + val board = Board() + board[1, 2] = FieldState.CROSS + board[2, 2] = FieldState.CIRCLE + + GameRuleLogic.checkMove(board, Move(Coordinates(1, 2))) shouldBe MoveMistake.DESTINATION_BLOCKED + GameRuleLogic.checkMove(board, Move(Coordinates(1, 1))) shouldBe null + GameRuleLogic.checkWinner(board) shouldBe null + + board[1, 1] = FieldState.CROSS + board[1, 0] = FieldState.CROSS + + GameRuleLogic.checkWinner(board) shouldBe Team.TWO + } +}) \ No newline at end of file diff --git a/plugin2099/src/test/kotlin/sc/plugin2099/GameStateTest.kt b/plugin2099/src/test/kotlin/sc/plugin2099/GameStateTest.kt new file mode 100644 index 000000000..e600e2a29 --- /dev/null +++ b/plugin2099/src/test/kotlin/sc/plugin2099/GameStateTest.kt @@ -0,0 +1,41 @@ +package sc.plugin2099 + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldHaveLineCount +import sc.api.plugins.Coordinates +import sc.api.plugins.Team +import sc.helpers.testXStream +import sc.shared.InvalidMoveException +import sc.shared.MoveMistake + +class GameStateTest: FunSpec({ + test("cloning") { + val state = GameState() + state.clone() shouldBe state + } + context("XML Serialization") { + test("deserialization") { + val state = GameState() + val xml = testXStream.toXML(state) + xml shouldHaveLineCount 19 + val restate = testXStream.fromXML(xml) as GameState + restate.startTeam shouldBe Team.ONE + restate.currentTeam shouldBe Team.ONE + restate shouldBe state + restate.board.toString() shouldBe state.board.toString() + + val startMove = Move(Coordinates(0, 1)) + state.performMoveDirectly(startMove) + restate shouldNotBe state + restate.performMoveDirectly(startMove) + restate shouldBe state + restate.performMoveDirectly(Move(Coordinates(1, 0))) + shouldThrow { + restate.performMoveDirectly(startMove) + }.mistake shouldBe MoveMistake.DESTINATION_BLOCKED + } + } +}) \ No newline at end of file From 12e012f101cb5eb7edd6d5e2936d0d8e7e2dbba8 Mon Sep 17 00:00:00 2001 From: NichtNil5 Date: Wed, 22 Apr 2026 01:22:45 +0200 Subject: [PATCH 10/13] fix(tictactoe): Changed starting team to CROSS Set starting team to CROSS. --- plugin2099/src/main/kotlin/sc/plugin2099/FieldState.kt | 6 +++--- .../src/test/kotlin/sc/plugin2099/GameRuleLogicTest.kt | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plugin2099/src/main/kotlin/sc/plugin2099/FieldState.kt b/plugin2099/src/main/kotlin/sc/plugin2099/FieldState.kt index 345630538..fb894c1d0 100644 --- a/plugin2099/src/main/kotlin/sc/plugin2099/FieldState.kt +++ b/plugin2099/src/main/kotlin/sc/plugin2099/FieldState.kt @@ -18,8 +18,8 @@ enum class FieldState(): IField, DeepCloneable { val team: Team? get() = when(this) { - FieldState.CIRCLE -> Team.ONE - FieldState.CROSS -> Team.TWO + CROSS -> Team.ONE + CIRCLE -> Team.TWO EMPTY -> null } @@ -33,7 +33,7 @@ enum class FieldState(): IField, DeepCloneable { fun asLetters() = when(this) { CIRCLE -> "O " - CROSS -> "X" + CROSS -> "X " EMPTY -> " " } diff --git a/plugin2099/src/test/kotlin/sc/plugin2099/GameRuleLogicTest.kt b/plugin2099/src/test/kotlin/sc/plugin2099/GameRuleLogicTest.kt index d5c0a18d5..5fe19fffc 100644 --- a/plugin2099/src/test/kotlin/sc/plugin2099/GameRuleLogicTest.kt +++ b/plugin2099/src/test/kotlin/sc/plugin2099/GameRuleLogicTest.kt @@ -22,20 +22,20 @@ class GameRuleLogicTest: FunSpec({ board[1, 1] = FieldState.CROSS board[1, 0] = FieldState.CROSS - GameRuleLogic.checkWinner(board) shouldBe Team.TWO + GameRuleLogic.checkWinner(board) shouldBe Team.ONE } test("apply moves") { val board = Board() - board[1, 2] = FieldState.CROSS - board[2, 2] = FieldState.CIRCLE + board[1, 2] = FieldState.CIRCLE + board[2, 2] = FieldState.CROSS GameRuleLogic.checkMove(board, Move(Coordinates(1, 2))) shouldBe MoveMistake.DESTINATION_BLOCKED GameRuleLogic.checkMove(board, Move(Coordinates(1, 1))) shouldBe null GameRuleLogic.checkWinner(board) shouldBe null - board[1, 1] = FieldState.CROSS - board[1, 0] = FieldState.CROSS + board[1, 1] = FieldState.CIRCLE + board[1, 0] = FieldState.CIRCLE GameRuleLogic.checkWinner(board) shouldBe Team.TWO } From 2eda7336e5832ab1bb16b6305b0e91c78220cdc4 Mon Sep 17 00:00:00 2001 From: NichtNil5 Date: Wed, 22 Apr 2026 11:42:40 +0200 Subject: [PATCH 11/13] fix(tictactoe): add dummy points and ScoreDefinition Add a dummy ScoreDefinition and dummy point entries because the GUI expects more points than just "Sigespunkte". --- plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt | 2 +- plugin2099/src/main/kotlin/sc/plugin2099/util/GamePlugin.kt | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt b/plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt index cc28f67f5..d3712e938 100644 --- a/plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt +++ b/plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt @@ -34,7 +34,7 @@ data class GameState @JvmOverloads constructor( ): TwoPlayerGameState(Team.ONE) { override fun getPointsForTeam(team: ITeam): IntArray = - intArrayOf() + intArrayOf(0) override val isOver: Boolean get() = (GameRuleLogic.checkWinner(board) != null) || diff --git a/plugin2099/src/main/kotlin/sc/plugin2099/util/GamePlugin.kt b/plugin2099/src/main/kotlin/sc/plugin2099/util/GamePlugin.kt index 740a91dd4..517906bdf 100644 --- a/plugin2099/src/main/kotlin/sc/plugin2099/util/GamePlugin.kt +++ b/plugin2099/src/main/kotlin/sc/plugin2099/util/GamePlugin.kt @@ -16,10 +16,11 @@ enum class TicTacToeWinReason(override val message: String, override val isRegul class GamePlugin: IGamePlugin { companion object { - const val PLUGIN_ID = "swc_2026_tictactoe" + const val PLUGIN_ID = "swc_2099_tictactoe" val scoreDefinition: ScoreDefinition = ScoreDefinition(arrayOf( ScoreFragment("Siegpunkte", WinReason("%s hat gewonnen."), ScoreAggregation.SUM), + ScoreFragment("Dummy score definition", WinReason("%s hat gewonnen, aber dieser Text sollte niemals angezeigt werden"), ScoreAggregation.AVERAGE), )) } From ee4eba9fbae622e0670078457ba050b9189a04f6 Mon Sep 17 00:00:00 2001 From: NichtNil5 Date: Thu, 23 Apr 2026 00:52:12 +0200 Subject: [PATCH 12/13] fix(tictactoe): performMoveDirectly no longer assumes that Team One goes first. --- .../src/main/kotlin/sc/plugin2099/FieldState.kt | 15 +++++++++++---- .../src/main/kotlin/sc/plugin2099/GameState.kt | 6 +----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/plugin2099/src/main/kotlin/sc/plugin2099/FieldState.kt b/plugin2099/src/main/kotlin/sc/plugin2099/FieldState.kt index fb894c1d0..7f0c3e7d6 100644 --- a/plugin2099/src/main/kotlin/sc/plugin2099/FieldState.kt +++ b/plugin2099/src/main/kotlin/sc/plugin2099/FieldState.kt @@ -6,9 +6,9 @@ import sc.api.plugins.Team import sc.framework.DeepCloneable @XStreamAlias("field") -enum class FieldState(): IField, DeepCloneable { - CIRCLE, +enum class FieldState: IField, DeepCloneable { CROSS, + CIRCLE, EMPTY; override fun deepCopy(): FieldState = this @@ -25,16 +25,23 @@ enum class FieldState(): IField, DeepCloneable { override fun toString() = when(this) { - CIRCLE -> "Kreis" CROSS -> "Kreuz" + CIRCLE -> "Kreis" EMPTY -> " " } fun asLetters() = when(this) { - CIRCLE -> "O " CROSS -> "X " + CIRCLE -> "O " EMPTY -> " " } + + companion object { + fun fromTeam(team: Team): FieldState = when (team) { + Team.ONE -> CROSS + Team.TWO -> CIRCLE + } + } } diff --git a/plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt b/plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt index d3712e938..c4773ecec 100644 --- a/plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt +++ b/plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt @@ -52,11 +52,7 @@ data class GameState @JvmOverloads constructor( override fun performMoveDirectly(move: Move) { GameRuleLogic.checkMove(board, move)?.let { throw InvalidMoveException(it, move) } - board[move.field] = if (turn % 2 == 0) { - FieldState.CIRCLE - } else { - FieldState.CROSS - } + board[move.field] = FieldState.fromTeam(currentTeam) turn++ lastMove = move } From 50962638ad105bea6774e974802c227bc4c8f22d Mon Sep 17 00:00:00 2001 From: NichtNil5 Date: Thu, 23 Apr 2026 00:55:38 +0200 Subject: [PATCH 13/13] refactor(tictactoe): removed unused function selectNeighbors --- .../sc/plugin2099/util/GameRuleLogic.kt | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/plugin2099/src/main/kotlin/sc/plugin2099/util/GameRuleLogic.kt b/plugin2099/src/main/kotlin/sc/plugin2099/util/GameRuleLogic.kt index b512cb7aa..8345cee4d 100644 --- a/plugin2099/src/main/kotlin/sc/plugin2099/util/GameRuleLogic.kt +++ b/plugin2099/src/main/kotlin/sc/plugin2099/util/GameRuleLogic.kt @@ -29,28 +29,8 @@ object GameRuleLogic { return moves } - /** @return the [Coordinates] from [parentSet] which are neighbors of [pos] */ - private fun selectNeighbors(pos: Coordinates, parentSet: Collection): Collection { - val returnSet = ArrayList(8) - for(i in -1..1) { - for(j in -1..1) { - val x = pos.x + i - val y = pos.y + j - if(x < 0 || x >= TicTacToeConstants.BOARD_LENGTH || - y < 0 || y >= TicTacToeConstants.BOARD_LENGTH || - (i == 0 && j == 0)) continue - - val coord = Coordinates(x, y) - if(parentSet.contains(coord)) { - returnSet.add(coord) - } - } - } - return returnSet - } - @JvmStatic - fun checkWinner(board: Board,): Team? { + fun checkWinner(board: Board): Team? { // Check rows and columns for a win for (i in 0 until 3) { if (board[Coordinates(i, 0)] != FieldState.EMPTY &&