Team balancing & substitution mechanics

- Guthix assignment now uses a shared selector: combat-level vacancy target first, otherwise numerically smaller team.
- Added the 60 combat-level imbalance threshold.
- Replaced silent first-waiter teleporting with a substitution dialog: Do you wish to join the current game?
- First acceptance claims the slot; later acceptances fail.
- Declining keeps the player in the waiting room and excludes them from that current substitution offer.
- Spawn-room exit portals now actually remove the leaving player from the active team and trigger the vacancy flow.
- Waiting-room entry and game ticks now check for combat-level imbalance vacancies.
This commit is contained in:
dam 2026-05-11 18:58:59 +03:00
parent 87210b4b3c
commit 76abee373c
No known key found for this signature in database
GPG key ID: 4AF4E722399663FB
5 changed files with 222 additions and 41 deletions

View file

@ -42,6 +42,9 @@ object CastleWarsConstants {
/** Minimum players per team required to start a game */
const val MIN_PLAYERS_PER_TEAM = 2
/** Combat-level gap that opens a Castle Wars substitution vacancy */
const val COMBAT_LEVEL_VACANCY_THRESHOLD = 60
/** Catapult targeting inaccuracy radius */
const val CATAPULT_INACCURACY_RADIUS = 2

View file

@ -216,26 +216,9 @@ class CastleWarsListeners : InteractionListener {
on(CastleWars.joinGuthixTeamPortal, IntType.SCENERY, "Enter") { player, _ ->
if (GameWorld.settings?.enable_castle_wars != true) return@on false
if (!player.isArtificial && joinError(player)) return@on true
val saraTotal =
CastleWarsWaitingArea.waitingSaradominPlayers.size + CastleWarsGameArea.saradominPlayers.size
val zamTotal = CastleWarsWaitingArea.waitingZamorakPlayers.size + CastleWarsGameArea.zamorakPlayers.size
when {
saraTotal < zamTotal -> {
player.properties.teleportLocation = CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc
}
saraTotal > zamTotal -> {
player.properties.teleportLocation = CastleWarsWaitingArea.zamorakWaitingRoom.randomWalkableLoc
}
else -> {
if (Math.random() < 0.5) {
player.properties.teleportLocation =
CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc
} else {
player.properties.teleportLocation = CastleWarsWaitingArea.zamorakWaitingRoom.randomWalkableLoc
}
}
player.properties.teleportLocation = when (CastleWarsGameArea.selectGuthixTeam()) {
CastleWarsTeam.SARADOMIN -> CastleWarsWaitingArea.saradominWaitingRoom.randomWalkableLoc
CastleWarsTeam.ZAMORAK -> CastleWarsWaitingArea.zamorakWaitingRoom.randomWalkableLoc
}
player.setAttribute(CastleWars.portalAttribute, CastleWars.guthixName)
return@on true
@ -390,11 +373,13 @@ class CastleWarsListeners : InteractionListener {
}
on(CastleWars.saradominExitPortal, IntType.SCENERY, "Leave") { player, _ ->
CastleWarsGameArea.removePlayerFromGame(player)
player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc
sendMessage(player, "You have left the Castle Wars game.")
return@on true
}
on(CastleWars.zamorakExitPortal, IntType.SCENERY, "Leave") { player, _ ->
CastleWarsGameArea.removePlayerFromGame(player)
player.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc
sendMessage(player, "You have left the Castle Wars game.")
return@on true

View file

@ -69,7 +69,11 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener {
var ticksLeftInGame = -1
private val activeVacancyOffers = mutableMapOf<CastleWarsTeam, MutableSet<Player>>()
private val declinedSubstitutionOffers = mutableMapOf<CastleWarsTeam, MutableSet<Player>>()
fun startGame() {
clearSubstitutionState()
CastleWarsGameState.resetGame()
CastleWarsItemHandler.clearAllBarricades()
CastleWarsWaitingArea.gameStartCountdown = -1
@ -101,32 +105,120 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener {
return CastleWarsGameZone.handleCastleWarsDeath(player)
}
fun clearSubstitutionState() {
activeVacancyOffers.clear()
declinedSubstitutionOffers.clear()
}
fun selectGuthixTeam(): CastleWarsTeam {
combatLevelVacancyTeam()?.let { return it }
val saraTotal = CastleWarsWaitingArea.waitingSaradominPlayers.size + saradominPlayers.size
val zamTotal = CastleWarsWaitingArea.waitingZamorakPlayers.size + zamorakPlayers.size
return when {
saraTotal < zamTotal -> CastleWarsTeam.SARADOMIN
saraTotal > zamTotal -> CastleWarsTeam.ZAMORAK
Math.random() < 0.5 -> CastleWarsTeam.SARADOMIN
else -> CastleWarsTeam.ZAMORAK
}
}
fun combatLevelVacancyTeam(): CastleWarsTeam? {
if (ticksLeftInGame <= 0) return null
val saraTotal = teamCombatLevelTotal(saradominPlayers)
val zamTotal = teamCombatLevelTotal(zamorakPlayers)
return when {
zamTotal - saraTotal > CastleWarsConstants.COMBAT_LEVEL_VACANCY_THRESHOLD -> CastleWarsTeam.SARADOMIN
saraTotal - zamTotal > CastleWarsConstants.COMBAT_LEVEL_VACANCY_THRESHOLD -> CastleWarsTeam.ZAMORAK
else -> null
}
}
private fun teamCombatLevelTotal(players: Set<Player>): Int {
return players.sumOf { it.properties.currentCombatLevel }
}
/**
* Try to fill a vacancy on the given team by pulling a player from the waiting room.
* Offer a vacancy on the given team to every eligible player in that team's waiting room.
* Called when a player leaves an active game.
* @param team The team that lost a player
*/
fun fillVacancy(team: CastleWarsTeam) {
if (ticksLeftInGame <= 0) return
if (activeVacancyOffers[team]?.isNotEmpty() == true) return
val waitingSet = when (team) {
CastleWarsTeam.SARADOMIN -> CastleWarsWaitingArea.waitingSaradominPlayers
CastleWarsTeam.ZAMORAK -> CastleWarsWaitingArea.waitingZamorakPlayers
}
val declined = declinedSubstitutionOffers[team].orEmpty()
val candidates = waitingSet.filter { it !in declined }
if (candidates.isEmpty()) return
activeVacancyOffers[team] = candidates.toMutableSet()
for (candidate in candidates) {
openDialogue(candidate, CastleWarsSubstitutionDialogue(team))
}
}
fun offerCombatLevelVacancyIfNeeded() {
val team = combatLevelVacancyTeam() ?: return
fillVacancy(team)
}
fun claimVacancy(player: Player, team: CastleWarsTeam): Boolean {
val offer = activeVacancyOffers[team] ?: return false
if (!offer.contains(player)) return false
val waitingSet = when (team) {
CastleWarsTeam.SARADOMIN -> CastleWarsWaitingArea.waitingSaradominPlayers
CastleWarsTeam.ZAMORAK -> CastleWarsWaitingArea.waitingZamorakPlayers
}
if (!waitingSet.remove(player)) return false
activeVacancyOffers.remove(team)
declinedSubstitutionOffers[team]?.remove(player)
val gameSet = when (team) {
CastleWarsTeam.SARADOMIN -> saradominPlayers
CastleWarsTeam.ZAMORAK -> zamorakPlayers
}
if (waitingSet.isEmpty()) return
val replacement = waitingSet.first()
waitingSet.remove(replacement)
gameSet.add(replacement)
setAttribute(replacement, CastleWarsConstants.ATTR_JOIN_TICK, getWorldTicks())
gameSet.add(player)
setAttribute(player, CastleWarsConstants.ATTR_JOIN_TICK, getWorldTicks())
val respawnLoc = when (team) {
CastleWarsTeam.SARADOMIN -> CastleWarsRespawnArea.saradominRespawnRoom.randomWalkableLoc
CastleWarsTeam.ZAMORAK -> CastleWarsRespawnArea.zamorakRespawnRoom.randomWalkableLoc
}
replacement.properties.teleportLocation = respawnLoc
sendMessage(replacement, "A spot has opened up! You have been moved into the Castle Wars game.")
player.properties.teleportLocation = respawnLoc
sendMessage(player, "A spot has opened up! You have been moved into the Castle Wars game.")
offerCombatLevelVacancyIfNeeded()
return true
}
fun declineVacancyOffer(player: Player, team: CastleWarsTeam) {
activeVacancyOffers[team]?.remove(player)
declinedSubstitutionOffers.getOrPut(team) { mutableSetOf() }.add(player)
if (activeVacancyOffers[team]?.isEmpty() == true) {
activeVacancyOffers.remove(team)
}
}
fun removePlayerFromGame(player: Player, offerReplacement: Boolean = true): CastleWarsTeam? {
val leavingTeam = when {
saradominPlayers.contains(player) -> CastleWarsTeam.SARADOMIN
zamorakPlayers.contains(player) -> CastleWarsTeam.ZAMORAK
else -> null
}
if (leavingTeam == null) return null
CastleWarsGameState.handlePlayerLeave(player)
player.unhook(SaradominInterfaceCloseHook)
player.unhook(ZamorakInterfaceCloseHook)
loggedOutPlayers.remove(player)
saradominPlayers.remove(player)
zamorakPlayers.remove(player)
CastleWarsGameState.clearHintIconForPlayer(player)
player.interaction.remove(Option._P_ATTACK)
if (offerReplacement) {
fillVacancy(leavingTeam)
offerCombatLevelVacancyIfNeeded()
}
return leavingTeam
}
object SaradominInterfaceCloseHook : EventHook<InterfaceCloseEvent> { // TODO: this might be useless.
@ -160,6 +252,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener {
CastleWarsWaitingArea.postGameCooldown = true
CastleWarsGameState.endGame()
CastleWarsItemHandler.clearAllBarricades()
clearSubstitutionState()
ticksLeftInGame = -1
saradominPlayers.forEach { player ->
player.unhook(SaradominInterfaceCloseHook)
@ -218,17 +311,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener {
CastleWarsRespawnArea.saradominRespawnRoom.insideBorder(player.location) ||
CastleWarsRespawnArea.zamorakRespawnRoom.insideBorder(player.location)
if (!stillInCastleWars) {
val leavingTeam = CastleWarsGameState.getPlayerTeam(player)
CastleWarsGameState.handlePlayerLeave(player)
player.unhook(SaradominInterfaceCloseHook)
player.unhook(ZamorakInterfaceCloseHook)
saradominPlayers.remove(player)
zamorakPlayers.remove(player)
CastleWarsGameState.clearHintIconForPlayer(player)
player.interaction.remove(Option._P_ATTACK)
if (leavingTeam != null) {
fillVacancy(leavingTeam)
}
removePlayerFromGame(player)
}
}
@ -282,6 +365,7 @@ class CastleWarsGameArea : CastleWarsArea(), TickListener {
}
CastleWarsGameState.checkFlagCarriers()
CastleWarsGameState.checkDroppedFlagTimeouts()
offerCombatLevelVacancyIfNeeded()
for (player in saradominPlayers) {
val currentOverlayId = player.interfaceManager.overlay?.id
if (currentOverlayId == null || currentOverlayId != 58) {

View file

@ -104,6 +104,8 @@ class CastleWarsWaitingArea : CastleWarsArea(), TickListener {
// Open the white text overlay
player.interfaceManager.openOverlay(Component(Components.CASTLEWARS_STATUS_OVERLAY_57))
CastleWarsGameArea.offerCombatLevelVacancyIfNeeded()
}
override fun exitArea(player: Player) {

View file

@ -1,15 +1,17 @@
package content.minigame.castlewars
import TestUtils
import content.minigame.castlewars.areas.CastleWarsGameArea
import content.minigame.castlewars.areas.CastleWarsWaitingArea
import content.global.skill.thieving.Pickpockets
import content.minigame.castlewars.areas.CastleWarsGameArea
import content.minigame.castlewars.areas.CastleWarsRespawnArea
import content.minigame.castlewars.areas.CastleWarsWaitingArea
import core.api.EquipmentSlot
import core.game.node.entity.skill.Skills
import core.game.global.action.EquipHandler
import core.game.interaction.IntType
import core.game.interaction.InteractionListener
import core.game.interaction.InteractionListeners
import core.game.node.entity.player.Player
import core.game.node.item.Item
import core.game.node.scenery.Scenery
import core.game.world.map.Location
@ -50,9 +52,105 @@ class CastleWarsTests {
CastleWarsWaitingArea.gameStartCountdown = -1
CastleWarsWaitingArea.postGameCooldown = false
CastleWarsGameArea.ticksLeftInGame = -1
CastleWarsGameArea.clearSubstitutionState()
CastleWarsGameState.resetGame()
}
@Test
fun guthixPortalRoutesToNumericallySmallerTeamWhenNoCombatVacancyExists() {
TestUtils.getMockPlayer("cwsara1").use { sara1 ->
TestUtils.getMockPlayer("cwsara2").use { sara2 ->
TestUtils.getMockPlayer("cwzam1").use { zam1 ->
CastleWarsGameArea.saradominPlayers.add(sara1)
CastleWarsWaitingArea.waitingSaradominPlayers.add(sara2)
CastleWarsGameArea.zamorakPlayers.add(zam1)
assertEquals(CastleWarsTeam.ZAMORAK, CastleWarsGameArea.selectGuthixTeam())
}
}
}
}
@Test
fun guthixPortalRoutesToCombatDisadvantagedTeamDuringActiveMatch() {
TestUtils.getMockPlayer("cwweak").use { saradomin ->
TestUtils.getMockPlayer("cwstrong").use { zamorak ->
CastleWarsGameArea.ticksLeftInGame = 100
setCombatLevel(saradomin, 50)
setCombatLevel(zamorak, 120)
CastleWarsGameArea.saradominPlayers.add(saradomin)
CastleWarsGameArea.zamorakPlayers.add(zamorak)
assertEquals(CastleWarsTeam.SARADOMIN, CastleWarsGameArea.selectGuthixTeam())
}
}
}
@Test
fun spawnRoomPortalOffersVacancyAndFirstAcceptanceWins() {
TestUtils.getMockPlayer("cwleaver").use { leaver ->
TestUtils.getMockPlayer("cwsub1").use { firstWaiting ->
TestUtils.getMockPlayer("cwsub2").use { secondWaiting ->
CastleWarsGameArea.ticksLeftInGame = 100
CastleWarsGameArea.saradominPlayers.add(leaver)
CastleWarsWaitingArea.waitingSaradominPlayers.add(firstWaiting)
CastleWarsWaitingArea.waitingSaradominPlayers.add(secondWaiting)
CastleWarsGameArea.removePlayerFromGame(leaver)
assertFalse(CastleWarsGameArea.saradominPlayers.contains(leaver))
assertSubstitutionDialogue(firstWaiting)
assertSubstitutionDialogue(secondWaiting)
assertTrue(CastleWarsGameArea.claimVacancy(secondWaiting, CastleWarsTeam.SARADOMIN))
assertFalse(CastleWarsGameArea.claimVacancy(firstWaiting, CastleWarsTeam.SARADOMIN))
assertTrue(CastleWarsGameArea.saradominPlayers.contains(secondWaiting))
assertFalse(CastleWarsWaitingArea.waitingSaradominPlayers.contains(secondWaiting))
assertTrue(CastleWarsWaitingArea.waitingSaradominPlayers.contains(firstWaiting))
assertTrue(CastleWarsRespawnArea.saradominRespawnRoom.insideBorder(secondWaiting.properties.teleportLocation))
}
}
}
}
@Test
fun decliningSubstitutionKeepsPlayerInWaitingRoom() {
TestUtils.getMockPlayer("cwdecline").use { waiting ->
CastleWarsGameArea.ticksLeftInGame = 100
CastleWarsWaitingArea.waitingZamorakPlayers.add(waiting)
CastleWarsGameArea.fillVacancy(CastleWarsTeam.ZAMORAK)
CastleWarsGameArea.declineVacancyOffer(waiting, CastleWarsTeam.ZAMORAK)
assertTrue(CastleWarsWaitingArea.waitingZamorakPlayers.contains(waiting))
assertFalse(CastleWarsGameArea.zamorakPlayers.contains(waiting))
assertFalse(CastleWarsGameArea.claimVacancy(waiting, CastleWarsTeam.ZAMORAK))
}
}
@Test
fun combatLevelImbalanceAutomaticallyOffersVacancyToDisadvantagedWaitingRoom() {
TestUtils.getMockPlayer("cwweak2").use { saradomin ->
TestUtils.getMockPlayer("cwstrong2").use { zamorak ->
TestUtils.getMockPlayer("cwsub3").use { waiting ->
CastleWarsGameArea.ticksLeftInGame = 100
setCombatLevel(saradomin, 50)
setCombatLevel(zamorak, 120)
CastleWarsGameArea.saradominPlayers.add(saradomin)
CastleWarsGameArea.zamorakPlayers.add(zamorak)
CastleWarsWaitingArea.waitingSaradominPlayers.add(waiting)
CastleWarsGameArea.offerCombatLevelVacancyIfNeeded()
assertSubstitutionDialogue(waiting)
assertTrue(CastleWarsGameArea.claimVacancy(waiting, CastleWarsTeam.SARADOMIN))
assertTrue(CastleWarsGameArea.saradominPlayers.contains(waiting))
assertFalse(CastleWarsWaitingArea.waitingSaradominPlayers.contains(waiting))
}
}
}
}
@Test
fun idleGameAreaTickDoesNotCountDownForever() {
CastleWarsGameArea.ticksLeftInGame = -1
@ -274,4 +372,13 @@ class CastleWarsTests {
}
}
}
private fun assertSubstitutionDialogue(player: Player) {
assertTrue(player.dialogueInterpreter.dialogue.file is CastleWarsSubstitutionDialogue)
}
@Suppress("DEPRECATION")
private fun setCombatLevel(player: Player, level: Int) {
player.properties.setCombatLevel(level)
}
}