forked from SteamWar/SteamWar
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:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user