diff --git a/src/client/graphics/PlayerIcons.ts b/src/client/graphics/PlayerIcons.ts index 18cf8d007..68711b79a 100644 --- a/src/client/graphics/PlayerIcons.ts +++ b/src/client/graphics/PlayerIcons.ts @@ -1,5 +1,6 @@ -import { AllPlayers, ColoredTeams, nukeTypes } from "../../core/game/Game"; +import { AllPlayers, nukeTypes } from "../../core/game/Game"; import { GameView, PlayerView } from "../../core/game/GameView"; +import { computeTeamTiles, findCrownTeam } from "../../core/game/TeamUtils"; import allianceIcon from "/images/AllianceIcon.svg?url"; import allianceIconFaded from "/images/AllianceIconFaded.svg?url"; import allianceRequestBlackIcon from "/images/AllianceRequestBlackIcon.svg?url"; @@ -59,24 +60,7 @@ export function getFirstPlacePlayer(game: GameView): PlayerView | null { /** Returns the team with the most total tiles, or null if no team leads. */ export function getCrownTeam(game: GameView): string | null { - const teamToTiles = new Map(); - for (const player of game.playerViews()) { - const team = player.team(); - if (team === null || team === ColoredTeams.Bot) continue; - teamToTiles.set( - team, - (teamToTiles.get(team) ?? 0) + player.numTilesOwned(), - ); - } - let maxTiles = 0; - let crownTeam: string | null = null; - for (const [team, tiles] of teamToTiles) { - if (tiles > maxTiles) { - maxTiles = tiles; - crownTeam = team; - } - } - return crownTeam; + return findCrownTeam(computeTeamTiles(game.playerViews())); } export function getPlayerIcons( diff --git a/src/client/graphics/layers/TeamStats.ts b/src/client/graphics/layers/TeamStats.ts index ab66b3d10..c39176a2b 100644 --- a/src/client/graphics/layers/TeamStats.ts +++ b/src/client/graphics/layers/TeamStats.ts @@ -8,6 +8,11 @@ import { UnitType, } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; +import { + computeTeamTiles, + findCrownTeam, + normalizeCrownSeconds, +} from "../../../core/game/TeamUtils"; import { SendWinnerEvent } from "../../Transport"; import { formatPercentage, @@ -98,15 +103,9 @@ export class TeamStats extends LitElement implements Layer { } private trackMetrics() { - const teamToTiles = new Map(); - for (const player of this.game.playerViews()) { - const team = player.team(); - if (team === null || team === ColoredTeams.Bot) continue; + const teamToTiles = computeTeamTiles(this.game.playerViews()); + for (const team of teamToTiles.keys()) { this._knownTeams.add(team); - teamToTiles.set( - team, - (teamToTiles.get(team) ?? 0) + player.numTilesOwned(), - ); } for (const [team, tiles] of teamToTiles) { const prev = this._peakTiles.get(team) ?? 0; @@ -119,14 +118,9 @@ export class TeamStats extends LitElement implements Layer { private freezeScores() { const numTilesWithoutFallout = this.game.numLandTiles() - this.game.numTilesWithFallout(); - for (const player of this.game.playerViews()) { - const team = player.team(); - if (team === null || team === ColoredTeams.Bot) continue; - this._frozenCurrentPercent.set( - team, - (this._frozenCurrentPercent.get(team) ?? 0) + - player.numTilesOwned() / numTilesWithoutFallout, - ); + const teamToTiles = computeTeamTiles(this.game.playerViews()); + for (const [team, tiles] of teamToTiles) { + this._frozenCurrentPercent.set(team, tiles / numTilesWithoutFallout); } for (const [team, peakTiles] of this._peakTiles) { this._frozenPeakPercent.set(team, peakTiles / numTilesWithoutFallout); @@ -174,32 +168,19 @@ export class TeamStats extends LitElement implements Layer { ? Math.min(Math.floor(elapsedGameTicks / 10), maxTimerValue * 60) : Math.floor(elapsedGameTicks / 10); const serverCrownTicks = this.game.teamCrownTicks() ?? {}; - // Crown holder = team with most tiles (same logic as WinCheckExecution) - let crownHolder: string | null = null; - let maxTilesNow = 0; - for (const [team, players] of Object.entries(grouped)) { - const teamTotal = players.reduce((sum, p) => sum + p.numTilesOwned(), 0); - if (teamTotal > maxTilesNow) { - maxTilesNow = teamTotal; - crownHolder = team; - } - } - // Non-holder teams get floor(ticks/10), holder gets remainder - const normalizedCrownTicks = new Map(); - let othersSeconds = 0; - for (const [team, ticks] of Object.entries(serverCrownTicks)) { - if (team !== crownHolder) { - const secs = Math.floor(ticks / 10); - normalizedCrownTicks.set(team, secs * 10); - othersSeconds += secs; - } - } - if (crownHolder !== null) { - normalizedCrownTicks.set( - crownHolder, - Math.max(0, elapsedSeconds - othersSeconds) * 10, - ); - } + const allTeamKeys = Object.keys(grouped); + const crownHolder = findCrownTeam( + computeTeamTiles(this.game.playerViews()), + ); + const serverTicksMap = new Map( + Object.entries(serverCrownTicks), + ); + const crownSecondsMap = normalizeCrownSeconds( + allTeamKeys, + serverTicksMap, + crownHolder, + elapsedSeconds, + ); this.teams = Object.entries(grouped) .map(([teamStr, teamPlayers]) => { @@ -245,7 +226,7 @@ export class TeamStats extends LitElement implements Layer { this._gameOver && this.game.competitiveScores() ? (this.game.competitiveScores()!.find((s) => s.team === teamStr) ?.crownTimeSeconds ?? 0) - : Math.floor((normalizedCrownTicks.get(teamStr) ?? 0) / 10), + : (crownSecondsMap.get(teamStr) ?? 0), totalLaunchers: renderNumber(totalLaunchers), totalSAMs: renderNumber(totalSAMs), diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index cd3f8fd46..71a8c439b 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -121,6 +121,7 @@ export class AttackExecution implements Execution { // CrownBreakPoint: 25% troop bonus when attacking the crown-holding team if ( + this.mg.config().gameConfig().competitiveScoring && this.mg.config().gameConfig().gameMode === GameMode.Team && this.target.isPlayer() ) { diff --git a/src/core/execution/TeamMetricsExecution.ts b/src/core/execution/TeamMetricsExecution.ts index aa25362bc..ea0979647 100644 --- a/src/core/execution/TeamMetricsExecution.ts +++ b/src/core/execution/TeamMetricsExecution.ts @@ -1,4 +1,5 @@ -import { ColoredTeams, Execution, Game, GameMode, Team } from "../game/Game"; +import { Execution, Game, GameMode } from "../game/Game"; +import { computeTeamTiles, findCrownTeam } from "../game/TeamUtils"; /** * Tracks team-level competitive metrics every 10 ticks: @@ -30,30 +31,13 @@ export class TeamMetricsExecution implements Execution { if (elapsedSeconds >= maxTimerValue * 60) return; } - const teamToTiles = new Map(); - for (const player of this.mg.players()) { - const team = player.team(); - if (team === null || team === ColoredTeams.Bot) continue; - teamToTiles.set( - team, - (teamToTiles.get(team) ?? 0) + player.numTilesOwned(), - ); - } + const teamToTiles = computeTeamTiles(this.mg.players()); - // Track peak tiles for each team for (const [team, tiles] of teamToTiles) { this.mg.updateTeamPeakTiles(team, tiles); } - // Track crown (team with most tiles) - let maxTiles = 0; - let crownTeam: Team | null = null; - for (const [team, tiles] of teamToTiles) { - if (tiles > maxTiles) { - maxTiles = tiles; - crownTeam = team; - } - } + const crownTeam = findCrownTeam(teamToTiles); if (crownTeam !== null) { this.mg.addCrownTick(crownTeam, 10); } diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index b06d205af..3a3f5e275 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -13,6 +13,11 @@ import { RankedType, Team, } from "../game/Game"; +import { + computeTeamTiles, + findCrownTeam, + normalizeCrownSeconds, +} from "../game/TeamUtils"; export class WinEvent implements GameEvent { constructor(public readonly winner: Player) {} @@ -148,7 +153,7 @@ export class WinCheckExecution implements Execution { if (winner === undefined) return; const scores = this.mg.config().gameConfig().competitiveScoring - ? this.computeScores(teamToTiles, numTilesWithoutFallout) + ? this.computeScores(this.mg, teamToTiles, numTilesWithoutFallout) : undefined; this.mg.setWinner(winner[0], this.mg.stats().stats(), scores); console.log(`${winner[0]} has won the game`); @@ -157,17 +162,15 @@ export class WinCheckExecution implements Execution { } private computeScores( + mg: Game, teamToTiles: Map, numTilesWithoutFallout: number, ) { - if (this.mg === null) return undefined; - - const eliminationOrder = this.mg.teamEliminationOrder(); + const eliminationOrder = mg.teamEliminationOrder(); const allTeams = Array.from(teamToTiles.keys()).filter( (t) => t !== ColoredTeams.Bot, ); - const totalGameTicks = - this.mg.ticks() - this.mg.config().numSpawnPhaseTurns(); + const totalGameTicks = mg.ticks() - mg.config().numSpawnPhaseTurns(); // Rank surviving teams by current tiles (more tiles = better placement) const survivingTeams = allTeams.filter( @@ -177,37 +180,19 @@ export class WinCheckExecution implements Execution { (a, b) => (teamToTiles.get(b) ?? 0) - (teamToTiles.get(a) ?? 0), ); - // Normalize crown seconds: non-crown teams get floor(ticks/10), - // crown holder gets the remainder so the sum matches the game timer. + const nonBotTiles = computeTeamTiles(mg.allPlayers()); + const crownHolder = findCrownTeam(nonBotTiles); const elapsedSeconds = Math.floor(Math.max(0, totalGameTicks) / 10); - let crownHolder: Team | null = null; - let maxTiles = 0; - for (const [team, tiles] of teamToTiles) { - if (team !== ColoredTeams.Bot && tiles > maxTiles) { - maxTiles = tiles; - crownHolder = team; - } - } - const allCrownTicks = this.mg!.allTeamCrownTicks(); - const teamCrownSeconds = new Map(); - let othersSum = 0; - for (const team of allTeams) { - const ticks = allCrownTicks.get(team) ?? 0; - if (team !== crownHolder) { - const secs = Math.floor(ticks / 10); - teamCrownSeconds.set(team, secs); - othersSum += secs; - } - } - if (crownHolder !== null) { - teamCrownSeconds.set( - crownHolder, - Math.max(0, elapsedSeconds - othersSum), - ); - } + const allCrownTicks = mg.allTeamCrownTicks(); + const teamCrownSeconds = normalizeCrownSeconds( + allTeams, + allCrownTicks, + crownHolder, + elapsedSeconds, + ); const metrics: TeamRawMetrics[] = allTeams.map((team) => { - const peakTiles = this.mg!.teamPeakTiles(team); + const peakTiles = mg.teamPeakTiles(team); const peakTilePercentage = (peakTiles / numTilesWithoutFallout) * 100; const crownTicks = allCrownTicks.get(team) ?? 0; const crownRatio = totalGameTicks > 0 ? crownTicks / totalGameTicks : 0; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index f63afb03a..ed1b63d47 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -51,6 +51,7 @@ import { createRailNetwork } from "./RailNetworkImpl"; import { Stats } from "./Stats"; import { StatsImpl } from "./StatsImpl"; import { assignTeams } from "./TeamAssignment"; +import { computeTeamTiles, findCrownTeam } from "./TeamUtils"; import { TerraNulliusImpl } from "./TerraNulliusImpl"; import { UnitGrid, UnitPredicate } from "./UnitGrid"; @@ -1256,28 +1257,11 @@ export class GameImpl implements Game { } crownTeam(): Team | null { - const teamToTiles = new Map(); - for (const player of this.players()) { - const team = player.team(); - if (team === null || team === ColoredTeams.Bot) continue; - teamToTiles.set( - team, - (teamToTiles.get(team) ?? 0) + player.numTilesOwned(), - ); - } - let maxTiles = 0; - let crown: Team | null = null; - for (const [team, tiles] of teamToTiles) { - if (tiles > maxTiles) { - maxTiles = tiles; - crown = team; - } - } - return crown; + return findCrownTeam(computeTeamTiles(this.players())); } allTeamCrownTicks(): ReadonlyMap { - return new Map(this._teamCrownTicks); + return this._teamCrownTicks; } addCrownTick(team: Team, amount: number): void { diff --git a/src/core/game/TeamUtils.ts b/src/core/game/TeamUtils.ts new file mode 100644 index 000000000..9e3d39a4f --- /dev/null +++ b/src/core/game/TeamUtils.ts @@ -0,0 +1,75 @@ +import { ColoredTeams, Team } from "./Game"; + +/** + * Any object that exposes a team and a tile count — + * satisfied by both server-side `Player` and client-side `PlayerView`. + */ +interface HasTeamAndTiles { + team(): Team | null; + numTilesOwned(): number; +} + +/** + * Sums tile counts per team from the given players. + * By default bots and null-team players are excluded. + */ +export function computeTeamTiles( + players: Iterable, + excludeBots = true, +): Map { + const teamToTiles = new Map(); + for (const player of players) { + const team = player.team(); + if (team === null) continue; + if (excludeBots && team === ColoredTeams.Bot) continue; + teamToTiles.set( + team, + (teamToTiles.get(team) ?? 0) + player.numTilesOwned(), + ); + } + return teamToTiles; +} + +/** + * Returns the team with the highest tile count, or `null` if the map is empty. + */ +export function findCrownTeam(teamToTiles: Map): Team | null { + let maxTiles = 0; + let crown: Team | null = null; + for (const [team, tiles] of teamToTiles) { + if (tiles > maxTiles) { + maxTiles = tiles; + crown = team; + } + } + return crown; +} + +/** + * Converts raw crown-tick counts into display-friendly seconds. + * + * Non-crown-holder teams get `floor(ticks / 10)`. + * The current crown holder receives the remainder so the total sums to + * `totalElapsedSeconds`, keeping the sidebar timer and crown time in sync. + */ +export function normalizeCrownSeconds( + allTeams: Team[], + crownTicks: ReadonlyMap, + crownHolder: Team | null, + totalElapsedSeconds: number, +): Map { + const result = new Map(); + let othersSum = 0; + for (const team of allTeams) { + const ticks = crownTicks.get(team) ?? 0; + if (team !== crownHolder) { + const secs = Math.floor(ticks / 10); + result.set(team, secs); + othersSum += secs; + } + } + if (crownHolder !== null) { + result.set(crownHolder, Math.max(0, totalElapsedSeconds - othersSum)); + } + return result; +}