mirror of
https://gitlab.com/2009scape/2009scape.git
synced 2026-08-24 20:05:13 -06:00
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:
parent
87210b4b3c
commit
76abee373c
5 changed files with 222 additions and 41 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue