diff --git a/gradle.properties b/gradle.properties index 90411e188..f4828e396 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.gameName=tictactoe +socha.version.year=99 +socha.version.minor=01 +socha.version.patch=00 socha.version.suffix= \ No newline at end of file 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 a14a05509..62eb2ac1d 100644 --- a/plugin2026/src/main/kotlin/sc/plugin2026/GameState.kt +++ b/plugin2026/src/main/kotlin/sc/plugin2026/GameState.kt @@ -32,22 +32,29 @@ 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 = 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? 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) } else { null } @@ -62,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/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..97c359830 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,26 @@ 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.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) + } + } }) 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 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..7f0c3e7d6 --- /dev/null +++ b/plugin2099/src/main/kotlin/sc/plugin2099/FieldState.kt @@ -0,0 +1,47 @@ +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 { + CROSS, + CIRCLE, + EMPTY; + + override fun deepCopy(): FieldState = this + + override val isEmpty: Boolean + get() = this == EMPTY + + val team: Team? + get() = when(this) { + CROSS -> Team.ONE + CIRCLE -> Team.TWO + EMPTY -> null + } + + override fun toString() = + when(this) { + CROSS -> "Kreuz" + CIRCLE -> "Kreis" + EMPTY -> " " + } + + fun asLetters() = + when(this) { + 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 new file mode 100644 index 000000000..c4773ecec --- /dev/null +++ b/plugin2099/src/main/kotlin/sc/plugin2099/GameState.kt @@ -0,0 +1,77 @@ +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.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) { + + 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] = FieldState.fromTeam(currentTeam) + 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()) + + 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..517906bdf --- /dev/null +++ b/plugin2099/src/main/kotlin/sc/plugin2099/util/GamePlugin.kt @@ -0,0 +1,45 @@ +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_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), + )) + } + + 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..8345cee4d --- /dev/null +++ b/plugin2099/src/main/kotlin/sc/plugin2099/util/GameRuleLogic.kt @@ -0,0 +1,60 @@ +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 + } + + @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/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 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() + } + } + } + } +}) 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 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..5fe19fffc --- /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.ONE + } + + test("apply moves") { + val board = Board() + 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.CIRCLE + board[1, 0] = FieldState.CIRCLE + + 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 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")