Migrate FightSystem modes and countdowns to GameCore

- Move shared arena-mode abstractions into GameCore
- Adapt fight countdowns and APIs to the shared model
- Add SteamWar issue audit workflow and SQL language settings
This commit is contained in:
2026-07-24 12:18:49 +02:00
parent badff1c70f
commit 867ca6f5dc
133 changed files with 2155 additions and 1076 deletions
+35
View File
@@ -0,0 +1,35 @@
# GameCore
GameCore is the Kotlin runtime library shared by SteamWar game-mode plugins.
Its implementation is based on FightSystem, the most exhaustive game mode, and
contains the mechanics that do not depend on a specific arena:
- restartable Bukkit countdown scheduling, FightSystem milestone behavior,
message/sound/level presentation policy, and shared skip behavior;
- generic team contracts, team lookup, readiness, and opposite-team handling;
- reusable winner/draw results and score/last-remaining evaluation;
- the complete state-dependent lifecycle (one-shots, tasks, listeners, commands,
and countdowns);
- the FightSystem arena modes and typed JVM runtime properties;
- observable, generic game-state transitions.
GameCore intentionally does not define a game-state enum. Each consumer supplies
its own enum to `GameStateMachine<S>` and the generic state-dependent classes, so
each game mode controls its valid states while sharing the lifecycle behavior.
```kotlin
enum class State { WAITING, RUNNING, END }
val lifecycle = GameStateMachine(State.WAITING)
StateDependentListener(
lifecycle,
true,
setOf(State.RUNNING),
plugin,
ingameListener,
)
```
FightSystem, MissileWars, TNTLeague, and TowerRun retain their own messages,
inventories, listeners, schematics, and concrete win rules. They depend on the
GameCore plugin at runtime; GameCore itself depends on KotlinCore.
+22
View File
@@ -0,0 +1,22 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2026 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
plugins {
steamwar.kotlin
}
dependencies {
compileOnly(libs.paperapi)
compileOnly(project(":KotlinCore", "default"))
testImplementation(libs.junit)
testImplementation(libs.hamcrest)
}
@@ -0,0 +1,69 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2026 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package de.steamwar.gamecore
import de.steamwar.gamecore.config.GameRuntimeProperties
import java.util.Collections
import java.util.EnumSet
/**
* Canonical arena mode model, ported from FightSystem.
*/
enum class ArenaMode {
NORMAL,
EVENT,
TEST,
CHECK,
PREPARE,
REPLAY;
companion object {
@JvmField val All: Set<ArenaMode> = immutable(EnumSet.allOf(ArenaMode::class.java))
@JvmField val Normal: Set<ArenaMode> = immutable(EnumSet.of(NORMAL))
@JvmField val Check: Set<ArenaMode> = immutable(EnumSet.of(CHECK))
@JvmField val Event: Set<ArenaMode> = immutable(EnumSet.of(EVENT))
@JvmField val Test: Set<ArenaMode> = immutable(EnumSet.of(TEST, CHECK))
@JvmField val Prepare: Set<ArenaMode> = immutable(EnumSet.of(PREPARE))
@JvmField val Replay: Set<ArenaMode> = immutable(EnumSet.of(REPLAY, TEST))
@JvmField val AntiReplay: Set<ArenaMode> = complement(REPLAY)
@JvmField val AntiTest: Set<ArenaMode> = complement(TEST, CHECK)
@JvmField val AntiEvent: Set<ArenaMode> = complement(EVENT)
@JvmField val AntiTestCheckPrepare: Set<ArenaMode> = complement(TEST, CHECK, PREPARE)
@JvmField val AntiPrepare: Set<ArenaMode> = complement(PREPARE)
@JvmField val VariableTeams: Set<ArenaMode> = complement(EVENT, REPLAY, CHECK)
@JvmField val ManualTeams: Set<ArenaMode> = complement(EVENT, REPLAY)
@JvmField val RankedEvent: Set<ArenaMode> = immutable(EnumSet.of(EVENT, REPLAY))
@JvmField val Restartable: Set<ArenaMode> = immutable(EnumSet.of(NORMAL, TEST))
@JvmField val NotRestartable: Set<ArenaMode> = immutable(EnumSet.of(EVENT, REPLAY))
@JvmField val SoloLeader: Set<ArenaMode> = immutable(EnumSet.of(TEST, CHECK, PREPARE))
@JvmField val NotOnBau: Set<ArenaMode> = complement(TEST, CHECK, PREPARE, REPLAY)
@JvmField val SeriousFight: Set<ArenaMode> = complement(TEST, CHECK, REPLAY)
@JvmStatic
fun from(properties: GameRuntimeProperties): ArenaMode = when {
properties.checkSchematicId != 0 -> CHECK
properties.prepareSchematicId != 0 -> PREPARE
properties.fightId >= 1 -> EVENT
properties.fightId == -1 -> TEST
properties.replayId != 0 -> REPLAY
else -> NORMAL
}
private fun complement(vararg modes: ArenaMode): Set<ArenaMode> =
immutable(EnumSet.complementOf(EnumSet.copyOf(modes.toSet())))
private fun immutable(modes: EnumSet<ArenaMode>): Set<ArenaMode> =
Collections.unmodifiableSet(modes)
}
}
@@ -0,0 +1,22 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2026 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package de.steamwar.gamecore
import org.bukkit.plugin.java.JavaPlugin
/**
* Runtime boundary for code shared by the SteamWar game modes.
*
* GameCore intentionally has no game lifecycle of its own. Depending plugins
* provide the concrete teams, states, messages and win effects.
*/
class GameCore : JavaPlugin()
@@ -0,0 +1,63 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2026 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package de.steamwar.gamecore.config
import java.util.Properties
import java.util.UUID
/**
* Typed view of every JVM system property historically consumed by FightSystem.
*
* Other game modes use the same names and parser, so launch behavior is
* consistent across the complete GameCore family.
*/
data class GameRuntimeProperties(
val checkSchematicId: Int,
val prepareSchematicId: Int,
val replayId: Int,
val configFile: String,
val fightId: Int,
val blueLeader: UUID?,
val redLeader: UUID?,
) {
val isReplayServer: Boolean
get() = replayId < 0
companion object {
const val CHECK_SCHEMATIC_ID = "checkSchemID"
const val PREPARE_SCHEMATIC_ID = "prepareSchemID"
const val REPLAY_ID = "replay"
const val CONFIG_FILE = "config"
const val FIGHT_ID = "fightID"
const val BLUE_LEADER = "blueLeader"
const val RED_LEADER = "redLeader"
@JvmStatic
@JvmOverloads
fun load(properties: Properties = System.getProperties()): GameRuntimeProperties =
GameRuntimeProperties(
checkSchematicId = properties.int(CHECK_SCHEMATIC_ID),
prepareSchematicId = properties.int(PREPARE_SCHEMATIC_ID),
replayId = properties.int(REPLAY_ID),
configFile = properties.getProperty(CONFIG_FILE, "config.yml"),
fightId = properties.int(FIGHT_ID),
blueLeader = properties.uuid(BLUE_LEADER),
redLeader = properties.uuid(RED_LEADER),
)
private fun Properties.int(name: String): Int =
getProperty(name, "0").toInt()
private fun Properties.uuid(name: String): UUID? =
getProperty(name)?.let(UUID::fromString)
}
}
@@ -0,0 +1,242 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2026 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package de.steamwar.gamecore.countdown
import org.bukkit.plugin.Plugin
import org.bukkit.scheduler.BukkitTask
import java.util.concurrent.CopyOnWriteArraySet
import java.util.function.IntConsumer
/**
* Common, restartable countdown implementation.
*
* The engine owns scheduling and cancellation. Game modes only implement
* presentation in [onTick] and their transition in [onFinished].
*/
abstract class Countdown @JvmOverloads constructor(
private val plugin: Plugin,
val totalTime: Int,
private val initialDelayTicks: Long = 20,
private val periodTicks: Long = 20,
private val showInitialValue: Boolean = true,
) {
init {
require(totalTime >= 0) { "totalTime must not be negative" }
require(initialDelayTicks >= 0) { "initialDelayTicks must not be negative" }
require(periodTicks > 0) { "periodTicks must be positive" }
}
@Volatile
var timeLeft: Int = totalTime
private set
@Volatile
private var enabled = false
private var task: BukkitTask? = null
val isRunning: Boolean
get() = enabled
fun enable() {
disable()
timeLeft = totalTime
enabled = true
activeCountdowns.add(this)
onStarted()
if (!enabled) return
if (showInitialValue) {
onTick(timeLeft)
}
if (!enabled) return
if (totalTime == 0) {
finish()
return
}
task = plugin.server.scheduler.runTaskTimer(
plugin,
Runnable { advance() },
initialDelayTicks,
periodTicks,
)
}
fun disable() {
val wasEnabled = enabled
enabled = false
val currentTask = task
task = null
currentTask?.cancel()
activeCountdowns.remove(this)
if (wasEnabled) {
onStopped()
}
}
fun reset() {
timeLeft = totalTime
}
fun setRemainingTime(time: Int) {
timeLeft = time.coerceIn(0, totalTime)
}
fun decreaseBy(time: Int) {
require(time >= 0) { "time must not be negative" }
if (!enabled) return
timeLeft = (timeLeft - time).coerceAtLeast(0)
onTick(timeLeft)
if (timeLeft == 0 && isRunning) {
finish()
}
}
private fun advance() {
if (!enabled) return
timeLeft = (timeLeft - 1).coerceAtLeast(0)
onTick(timeLeft)
if (timeLeft == 0) {
finish()
}
}
private fun finish() {
if (!enabled) return
disable()
onFinished()
}
protected open fun onStarted() = Unit
protected open fun onStopped() = Unit
protected open fun onTick(timeLeft: Int) = Unit
protected abstract fun onFinished()
companion object {
private val activeCountdowns = CopyOnWriteArraySet<Countdown>()
/**
* Advances every running countdown to the next countdown that finishes.
*/
@JvmStatic
fun skipActive(): Boolean {
val advanceBy = activeCountdowns.minOfOrNull { it.timeLeft } ?: return false
activeCountdowns.toList().forEach { it.decreaseBy(advanceBy) }
return true
}
}
}
enum class CountdownMessage(val key: String) {
MINUTES("COUNTDOWN_MINUTES"),
SECONDS("COUNTDOWN_SECONDS"),
SECOND("COUNTDOWN_SECOND"),
}
data class CountdownMilestone(
val message: CountdownMessage,
val displayTime: Int,
)
object CountdownMilestones {
@JvmStatic
fun at(timeLeft: Int): CountdownMilestone? = when (timeLeft) {
900, 600, 300, 180, 120 ->
CountdownMilestone(CountdownMessage.MINUTES, timeLeft / 60)
60, 30, 20, 15, 10, 5, 4, 3, 2 ->
CountdownMilestone(CountdownMessage.SECONDS, timeLeft)
1 ->
CountdownMilestone(CountdownMessage.SECOND, timeLeft)
else -> null
}
}
/**
* FightSystem's complete countdown behavior. Consumers bind their game-specific
* transports while GameCore owns lifecycle, milestones, and presentation.
*/
abstract class StandardCountdown @JvmOverloads constructor(
plugin: Plugin,
totalTime: Int,
private val presentation: CountdownPresentation,
initialDelayTicks: Long = 20,
periodTicks: Long = 20,
showInitialValue: Boolean = true,
) : Countdown(
plugin,
totalTime,
initialDelayTicks,
periodTicks,
showInitialValue,
) {
final override fun onTick(timeLeft: Int) {
presentation.update(timeLeft)
}
final override fun onFinished() {
presentation.finished()
countdownFinished()
}
protected abstract fun countdownFinished()
}
fun interface CountdownMessageSink {
fun display(message: CountdownMessage, displayTime: Int)
}
fun interface CountdownSoundSink {
fun play(pitch: Float)
}
/**
* Complete standard presentation policy. Game modes only bind their actual
* message, sound, and level transports.
*/
class CountdownPresentation constructor(
private val messageSink: CountdownMessageSink,
private val tickSoundSink: CountdownSoundSink?,
private val finishedSoundSink: CountdownSoundSink?,
private val levelSink: IntConsumer?,
private val tickSoundOnMinutes: Boolean,
) {
@JvmOverloads
constructor(
messageSink: CountdownMessageSink,
soundSink: CountdownSoundSink? = null,
levelSink: IntConsumer? = null,
) : this(messageSink, soundSink, soundSink, levelSink, false)
fun update(timeLeft: Int) {
CountdownMilestones.at(timeLeft)?.let {
if (tickSoundOnMinutes || it.message != CountdownMessage.MINUTES) {
tickSoundSink?.play(1.0f)
}
messageSink.display(it.message, it.displayTime)
}
levelSink?.accept(timeLeft)
}
fun finished() {
finishedSoundSink?.play(2.0f)
}
}
@@ -0,0 +1,74 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2026 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package de.steamwar.gamecore.state
import java.util.Collections
import java.util.LinkedHashMap
import java.util.concurrent.CopyOnWriteArrayList
fun interface GameStateListener<S : Enum<S>> {
fun onStateChange(oldState: S, newState: S)
}
/**
* Small state holder used by game modes instead of duplicating listener lists
* and transition notification code.
*/
class GameStateMachine<S : Enum<S>>(initialState: S) {
private val listeners = CopyOnWriteArrayList<GameStateListener<S>>()
private val stateDependents = LinkedHashMap<IStateDependent<S>, Boolean>()
@Volatile
var currentState: S = initialState
private set
val stateDependentFeatures: Map<IStateDependent<S>, Boolean>
get() = Collections.unmodifiableMap(LinkedHashMap(stateDependents))
fun registerStateDependent(stateDependent: IStateDependent<S>) {
if (stateDependent.enabled().isEmpty()) return
val enabled = currentState in stateDependent.enabled()
stateDependents[stateDependent] = enabled
if (enabled) stateDependent.enable()
}
fun transitionTo(newState: S) {
val oldState = currentState
if (oldState == newState) return
currentState = newState
stateDependents.entries.toList().forEach { (feature, wasEnabled) ->
val shouldBeEnabled = newState in feature.enabled()
if (shouldBeEnabled && !wasEnabled) {
feature.enable()
stateDependents[feature] = true
} else if (!shouldBeEnabled && wasEnabled) {
feature.disable()
stateDependents[feature] = false
}
}
listeners.forEach { it.onStateChange(oldState, newState) }
}
fun reset(initialState: S) {
transitionTo(initialState)
}
fun addListener(listener: GameStateListener<S>) {
listeners.add(listener)
}
fun removeListener(listener: GameStateListener<S>) {
listeners.remove(listener)
}
}
@@ -0,0 +1,194 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2026 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package de.steamwar.gamecore.state
import de.steamwar.gamecore.ArenaMode
import de.steamwar.gamecore.countdown.Countdown
import org.bukkit.command.CommandExecutor
import org.bukkit.event.HandlerList
import org.bukkit.event.Listener
import org.bukkit.plugin.Plugin
import org.bukkit.plugin.java.JavaPlugin
import org.bukkit.scheduler.BukkitTask
import java.util.concurrent.ConcurrentHashMap
interface IStateDependent<S : Enum<S>> {
fun enabled(): Set<S>
fun enable()
fun disable()
}
abstract class StateDependent<S : Enum<S>> protected constructor(
protected val lifecycle: GameStateMachine<S>,
private val active: Boolean,
private val enabledStates: Set<S>,
) : IStateDependent<S> {
protected constructor(
lifecycle: GameStateMachine<S>,
modes: Set<ArenaMode>,
arenaMode: ArenaMode,
enabledStates: Set<S>,
) : this(lifecycle, arenaMode in modes, enabledStates)
final override fun enabled(): Set<S> = enabledStates
fun register() {
if (active) lifecycle.registerStateDependent(this)
}
}
open class OneShotStateDependent<S : Enum<S>>(
lifecycle: GameStateMachine<S>,
active: Boolean,
enabledStates: Set<S>,
private val runnable: Runnable,
) : StateDependent<S>(lifecycle, active, enabledStates) {
constructor(
lifecycle: GameStateMachine<S>,
modes: Set<ArenaMode>,
arenaMode: ArenaMode,
enabledStates: Set<S>,
runnable: Runnable,
) : this(lifecycle, arenaMode in modes, enabledStates, runnable)
init {
register()
}
override fun enable() = runnable.run()
override fun disable() = Unit
}
open class StateDependentTask<S : Enum<S>>(
lifecycle: GameStateMachine<S>,
active: Boolean,
enabledStates: Set<S>,
private val plugin: Plugin,
private val runnable: Runnable,
private val delay: Long,
private val period: Long,
) : StateDependent<S>(lifecycle, active, enabledStates) {
constructor(
lifecycle: GameStateMachine<S>,
modes: Set<ArenaMode>,
arenaMode: ArenaMode,
enabledStates: Set<S>,
plugin: Plugin,
runnable: Runnable,
delay: Long,
period: Long,
) : this(lifecycle, arenaMode in modes, enabledStates, plugin, runnable, delay, period)
private var task: BukkitTask? = null
init {
register()
}
override fun enable() {
task?.cancel()
task = plugin.server.scheduler.runTaskTimer(plugin, runnable, delay, period)
}
override fun disable() {
task?.cancel()
task = null
}
}
open class StateDependentListener<S : Enum<S>>(
lifecycle: GameStateMachine<S>,
active: Boolean,
enabledStates: Set<S>,
private val plugin: Plugin,
private val listener: Listener,
) : StateDependent<S>(lifecycle, active, enabledStates) {
constructor(
lifecycle: GameStateMachine<S>,
modes: Set<ArenaMode>,
arenaMode: ArenaMode,
enabledStates: Set<S>,
plugin: Plugin,
listener: Listener,
) : this(lifecycle, arenaMode in modes, enabledStates, plugin, listener)
init {
register()
}
override fun enable() {
plugin.server.pluginManager.registerEvents(listener, plugin)
}
override fun disable() {
HandlerList.unregisterAll(listener)
}
}
open class StateDependentCommand<S : Enum<S>>(
lifecycle: GameStateMachine<S>,
active: Boolean,
enabledStates: Set<S>,
plugin: JavaPlugin,
name: String,
private val executor: CommandExecutor,
private val unavailable: CommandExecutor,
) : StateDependent<S>(lifecycle, active, enabledStates) {
private val command = requireNotNull(plugin.getCommand(name)) {
"Command '$name' is not declared by ${plugin.name}"
}
init {
disable()
register()
}
override fun enable() {
command.setExecutor(executor)
}
override fun disable() {
command.setExecutor(unavailable)
}
}
open class StateDependentCountdown<S : Enum<S>>(
lifecycle: GameStateMachine<S>,
active: Boolean,
enabledStates: Set<S>,
open val countdown: Countdown,
) : StateDependent<S>(lifecycle, active, enabledStates) {
init {
register()
}
override fun enable() {
mainCountdowns[lifecycle] = countdown
countdown.enable()
}
override fun disable() {
countdown.disable()
mainCountdowns.remove(lifecycle, countdown)
}
companion object {
private val mainCountdowns = ConcurrentHashMap<GameStateMachine<*>, Countdown>()
@JvmStatic
fun getMainCountdown(lifecycle: GameStateMachine<*>): Countdown? =
mainCountdowns[lifecycle]
}
}
@@ -0,0 +1,60 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2026 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package de.steamwar.gamecore.team
import java.util.Locale
/**
* Minimal team contract shared by all game modes.
*
* Membership side effects (teleports, inventories, scoreboards) deliberately
* stay in the game mode. This contract centralizes lookup and team relations.
*/
interface GameTeam<M> {
fun teamId(): String
fun members(): Collection<M>
fun leader(): M? = null
fun ready(): Boolean = false
fun contains(member: M): Boolean = members().contains(member)
}
class TeamRegistry<T : GameTeam<M>, M>(teams: Collection<T>) {
private val teamsById = LinkedHashMap<String, T>()
init {
teams.forEach { team ->
require(teamsById.put(team.teamId().lowercase(Locale.ROOT), team) == null) {
"Duplicate team id: ${team.teamId()}"
}
}
}
val teams: Collection<T>
get() = teamsById.values.toList()
fun findTeam(member: M): T? = teamsById.values.firstOrNull { it.contains(member) }
fun findById(id: String): T? = teamsById[id.lowercase(Locale.ROOT)]
fun allReady(): Boolean =
teamsById.isNotEmpty() && teamsById.values.all { it.ready() }
fun opposite(team: T): T {
require(teamsById.size == 2) { "opposite() requires exactly two teams" }
require(teamsById.values.any { it === team }) { "Unknown team: ${team.teamId()}" }
return teamsById.values.first { it !== team }
}
}
@@ -0,0 +1,49 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2026 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package de.steamwar.gamecore.wincondition
import java.util.function.ToDoubleFunction
interface WinCondition<T> {
fun id(): String
fun active(): Boolean = true
}
sealed interface GameResult<out T> {
data class Winner<T>(val winner: T) : GameResult<T>
data object Draw : GameResult<Nothing>
}
object WinConditions {
/**
* Selects the unique highest scoring entry or returns a draw.
*/
@JvmStatic
fun <T> winnerByScore(
entries: Collection<T>,
score: ToDoubleFunction<T>,
): GameResult<T> {
require(entries.isNotEmpty()) { "At least one entry is required" }
val highestScore = entries.maxOf(score::applyAsDouble)
val winners = entries.filter { score.applyAsDouble(it) == highestScore }
return if (winners.size == 1) GameResult.Winner(winners.single()) else GameResult.Draw
}
/**
* Selects the only remaining entry or returns a draw.
*/
@JvmStatic
fun <T> lastRemaining(entries: Collection<T>): GameResult<T> =
if (entries.size == 1) GameResult.Winner(entries.single()) else GameResult.Draw
}
+7
View File
@@ -0,0 +1,7 @@
name: GameCore
version: '1.0.0'
main: de.steamwar.gamecore.GameCore
load: POSTWORLD
api-version: '1.13'
depend:
- KotlinCore
@@ -0,0 +1,38 @@
package de.steamwar.gamecore
import de.steamwar.gamecore.config.GameRuntimeProperties
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.junit.Test
import java.util.Properties
class ArenaModeTest {
@Test
fun resolvesEveryFightSystemArenaMode() {
assertThat(mode(), equalTo(ArenaMode.NORMAL))
assertThat(mode(GameRuntimeProperties.FIGHT_ID to "42"), equalTo(ArenaMode.EVENT))
assertThat(mode(GameRuntimeProperties.FIGHT_ID to "-1"), equalTo(ArenaMode.TEST))
assertThat(mode(GameRuntimeProperties.REPLAY_ID to "42"), equalTo(ArenaMode.REPLAY))
assertThat(mode(GameRuntimeProperties.PREPARE_SCHEMATIC_ID to "42"), equalTo(ArenaMode.PREPARE))
assertThat(mode(GameRuntimeProperties.CHECK_SCHEMATIC_ID to "42"), equalTo(ArenaMode.CHECK))
}
@Test
fun keepsFightSystemResolutionPrecedence() {
val resolved = mode(
GameRuntimeProperties.CHECK_SCHEMATIC_ID to "1",
GameRuntimeProperties.PREPARE_SCHEMATIC_ID to "2",
GameRuntimeProperties.FIGHT_ID to "3",
GameRuntimeProperties.REPLAY_ID to "4",
)
assertThat(resolved, equalTo(ArenaMode.CHECK))
}
private fun mode(vararg values: Pair<String, String>): ArenaMode {
val properties = Properties().apply {
values.forEach { (name, value) -> setProperty(name, value) }
}
return ArenaMode.from(GameRuntimeProperties.load(properties))
}
}
@@ -0,0 +1,50 @@
package de.steamwar.gamecore.config
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.hamcrest.Matchers.nullValue
import org.junit.Test
import java.util.Properties
import java.util.UUID
class GameRuntimePropertiesTest {
@Test
fun loadsEveryFightSystemRuntimeProperty() {
val blueLeader = UUID.randomUUID()
val redLeader = UUID.randomUUID()
val properties = Properties().apply {
setProperty(GameRuntimeProperties.CHECK_SCHEMATIC_ID, "11")
setProperty(GameRuntimeProperties.PREPARE_SCHEMATIC_ID, "12")
setProperty(GameRuntimeProperties.REPLAY_ID, "-13")
setProperty(GameRuntimeProperties.CONFIG_FILE, "event.yml")
setProperty(GameRuntimeProperties.FIGHT_ID, "14")
setProperty(GameRuntimeProperties.BLUE_LEADER, blueLeader.toString())
setProperty(GameRuntimeProperties.RED_LEADER, redLeader.toString())
}
val runtime = GameRuntimeProperties.load(properties)
assertThat(runtime.checkSchematicId, equalTo(11))
assertThat(runtime.prepareSchematicId, equalTo(12))
assertThat(runtime.replayId, equalTo(-13))
assertThat(runtime.configFile, equalTo("event.yml"))
assertThat(runtime.fightId, equalTo(14))
assertThat(runtime.blueLeader, equalTo(blueLeader))
assertThat(runtime.redLeader, equalTo(redLeader))
assertThat(runtime.isReplayServer, equalTo(true))
}
@Test
fun usesFightSystemDefaults() {
val runtime = GameRuntimeProperties.load(Properties())
assertThat(runtime.checkSchematicId, equalTo(0))
assertThat(runtime.prepareSchematicId, equalTo(0))
assertThat(runtime.replayId, equalTo(0))
assertThat(runtime.configFile, equalTo("config.yml"))
assertThat(runtime.fightId, equalTo(0))
assertThat(runtime.blueLeader, nullValue())
assertThat(runtime.redLeader, nullValue())
assertThat(runtime.isReplayServer, equalTo(false))
}
}
@@ -0,0 +1,56 @@
package de.steamwar.gamecore.countdown
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.contains
import org.hamcrest.Matchers.equalTo
import org.hamcrest.Matchers.nullValue
import org.junit.Test
import java.util.function.IntConsumer
class CountdownMilestonesTest {
@Test
fun preservesFightSystemMilestones() {
assertThat(
CountdownMilestones.at(900),
equalTo(CountdownMilestone(CountdownMessage.MINUTES, 15)),
)
assertThat(
CountdownMilestones.at(60),
equalTo(CountdownMilestone(CountdownMessage.SECONDS, 60)),
)
assertThat(
CountdownMilestones.at(1),
equalTo(CountdownMilestone(CountdownMessage.SECOND, 1)),
)
assertThat(CountdownMilestones.at(0), nullValue())
assertThat(CountdownMilestones.at(59), nullValue())
}
@Test
fun presentationOwnsMessagesSoundsAndLevelUpdates() {
val messages = mutableListOf<CountdownMilestone>()
val pitches = mutableListOf<Float>()
val levels = mutableListOf<Int>()
val presentation = CountdownPresentation(
CountdownMessageSink { message, displayTime ->
messages += CountdownMilestone(message, displayTime)
},
CountdownSoundSink(pitches::add),
IntConsumer(levels::add),
)
presentation.update(120)
presentation.update(10)
presentation.finished()
assertThat(
messages,
contains(
CountdownMilestone(CountdownMessage.MINUTES, 2),
CountdownMilestone(CountdownMessage.SECONDS, 10),
),
)
assertThat(pitches, contains(1.0f, 2.0f))
assertThat(levels, contains(120, 10))
}
}
@@ -0,0 +1,53 @@
package de.steamwar.gamecore.state
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.contains
import org.hamcrest.Matchers.equalTo
import org.junit.Test
class GameStateMachineTest {
private enum class ConsumerState {
WAITING,
RUNNING,
END,
}
@Test
fun transitionsNotifyListenersOnce() {
val state = GameStateMachine(ConsumerState.WAITING)
val transitions = mutableListOf<Pair<ConsumerState, ConsumerState>>()
state.addListener { oldState, newState -> transitions.add(oldState to newState) }
state.transitionTo(ConsumerState.RUNNING)
state.transitionTo(ConsumerState.RUNNING)
assertThat(state.currentState, equalTo(ConsumerState.RUNNING))
assertThat(transitions, contains(ConsumerState.WAITING to ConsumerState.RUNNING))
}
@Test
fun consumerEnumDrivesRegisteredStateDependents() {
val lifecycle = GameStateMachine(ConsumerState.WAITING)
var enables = 0
var disables = 0
val feature = object : IStateDependent<ConsumerState> {
override fun enabled() = setOf(ConsumerState.RUNNING)
override fun enable() {
enables++
}
override fun disable() {
disables++
}
}
lifecycle.registerStateDependent(feature)
lifecycle.transitionTo(ConsumerState.RUNNING)
lifecycle.transitionTo(ConsumerState.END)
assertThat(enables, equalTo(1))
assertThat(disables, equalTo(1))
assertThat(lifecycle.currentState, equalTo(ConsumerState.END))
}
}
@@ -0,0 +1,25 @@
package de.steamwar.gamecore.team
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.junit.Test
class TeamRegistryTest {
private data class Team(
private val id: String,
private val players: Collection<String>,
) : GameTeam<String> {
override fun teamId() = id
override fun members() = players
}
@Test
fun findsMembersAndOppositeTeam() {
val red = Team("red", listOf("Ada"))
val blue = Team("blue", listOf("Linus"))
val registry = TeamRegistry(listOf(red, blue))
assertThat(registry.findTeam("Ada"), equalTo(red))
assertThat(registry.opposite(red), equalTo(blue))
}
}
@@ -0,0 +1,28 @@
package de.steamwar.gamecore.wincondition
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.junit.Test
import java.util.function.ToDoubleFunction
class WinConditionsTest {
@Test
fun selectsUniqueHighestScore() {
val result = WinConditions.winnerByScore(
listOf("red" to 4.0, "blue" to 7.0),
ToDoubleFunction { it.second },
)
assertThat(result, equalTo(GameResult.Winner("blue" to 7.0)))
}
@Test
fun equalScoresAreADraw() {
val result = WinConditions.winnerByScore(
listOf("red" to 7.0, "blue" to 7.0),
ToDoubleFunction { it.second },
)
assertThat(result, equalTo(GameResult.Draw))
}
}