Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions gradle.properties
Original file line number Diff line number Diff line change
@@ -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=
4 changes: 4 additions & 0 deletions plugin2026/src/main/kotlin/sc/plugin2026/Board.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 20 additions & 4 deletions plugin2026/src/main/kotlin/sc/plugin2026/GameState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Move>(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
}
Expand All @@ -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<Move> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Move> {
Expand Down
25 changes: 24 additions & 1 deletion plugin2026/src/test/kotlin/sc/plugin2026/GameRuleLogicTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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)
}
}
})
66 changes: 60 additions & 6 deletions plugin2026/src/test/kotlin/sc/plugin2026/GameStateTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -17,28 +19,80 @@ 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
restate.startTeam shouldBe Team.ONE
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<InvalidMoveException> {
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
}
}
})
11 changes: 11 additions & 0 deletions plugin2099/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
val game: String by project

dependencies {
api(project(":sdk"))
}

tasks {
jar {
archiveBaseName.set(game)
}
}
54 changes: 54 additions & 0 deletions plugin2099/src/main/kotlin/sc/plugin2099/Board.kt
Original file line number Diff line number Diff line change
@@ -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<FieldState> = emptyFields()
): RectangularBoard<FieldState>(), 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<FieldState> {
return Array(TicTacToeConstants.BOARD_LENGTH) {
Array(TicTacToeConstants.BOARD_LENGTH) { FieldState.EMPTY }
}
}
}
}
47 changes: 47 additions & 0 deletions plugin2099/src/main/kotlin/sc/plugin2099/FieldState.kt
Original file line number Diff line number Diff line change
@@ -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<FieldState> {
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
}
}

}
Loading
Loading