This commit is contained in:
Ryan Barlow
2026-03-01 15:04:56 +00:00
parent 401501aa2d
commit 13a75e8519
7 changed files with 129 additions and 135 deletions
+3 -19
View File
@@ -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 { GameView, PlayerView } from "../../core/game/GameView";
import { computeTeamTiles, findCrownTeam } from "../../core/game/TeamUtils";
import allianceIcon from "/images/AllianceIcon.svg?url"; import allianceIcon from "/images/AllianceIcon.svg?url";
import allianceIconFaded from "/images/AllianceIconFaded.svg?url"; import allianceIconFaded from "/images/AllianceIconFaded.svg?url";
import allianceRequestBlackIcon from "/images/AllianceRequestBlackIcon.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. */ /** Returns the team with the most total tiles, or null if no team leads. */
export function getCrownTeam(game: GameView): string | null { export function getCrownTeam(game: GameView): string | null {
const teamToTiles = new Map<string, number>(); return findCrownTeam(computeTeamTiles(game.playerViews()));
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;
} }
export function getPlayerIcons( export function getPlayerIcons(
+24 -43
View File
@@ -8,6 +8,11 @@ import {
UnitType, UnitType,
} from "../../../core/game/Game"; } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView"; import { GameView, PlayerView } from "../../../core/game/GameView";
import {
computeTeamTiles,
findCrownTeam,
normalizeCrownSeconds,
} from "../../../core/game/TeamUtils";
import { SendWinnerEvent } from "../../Transport"; import { SendWinnerEvent } from "../../Transport";
import { import {
formatPercentage, formatPercentage,
@@ -98,15 +103,9 @@ export class TeamStats extends LitElement implements Layer {
} }
private trackMetrics() { private trackMetrics() {
const teamToTiles = new Map<Team, number>(); const teamToTiles = computeTeamTiles(this.game.playerViews());
for (const player of this.game.playerViews()) { for (const team of teamToTiles.keys()) {
const team = player.team();
if (team === null || team === ColoredTeams.Bot) continue;
this._knownTeams.add(team); this._knownTeams.add(team);
teamToTiles.set(
team,
(teamToTiles.get(team) ?? 0) + player.numTilesOwned(),
);
} }
for (const [team, tiles] of teamToTiles) { for (const [team, tiles] of teamToTiles) {
const prev = this._peakTiles.get(team) ?? 0; const prev = this._peakTiles.get(team) ?? 0;
@@ -119,14 +118,9 @@ export class TeamStats extends LitElement implements Layer {
private freezeScores() { private freezeScores() {
const numTilesWithoutFallout = const numTilesWithoutFallout =
this.game.numLandTiles() - this.game.numTilesWithFallout(); this.game.numLandTiles() - this.game.numTilesWithFallout();
for (const player of this.game.playerViews()) { const teamToTiles = computeTeamTiles(this.game.playerViews());
const team = player.team(); for (const [team, tiles] of teamToTiles) {
if (team === null || team === ColoredTeams.Bot) continue; this._frozenCurrentPercent.set(team, tiles / numTilesWithoutFallout);
this._frozenCurrentPercent.set(
team,
(this._frozenCurrentPercent.get(team) ?? 0) +
player.numTilesOwned() / numTilesWithoutFallout,
);
} }
for (const [team, peakTiles] of this._peakTiles) { for (const [team, peakTiles] of this._peakTiles) {
this._frozenPeakPercent.set(team, peakTiles / numTilesWithoutFallout); 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.min(Math.floor(elapsedGameTicks / 10), maxTimerValue * 60)
: Math.floor(elapsedGameTicks / 10); : Math.floor(elapsedGameTicks / 10);
const serverCrownTicks = this.game.teamCrownTicks() ?? {}; const serverCrownTicks = this.game.teamCrownTicks() ?? {};
// Crown holder = team with most tiles (same logic as WinCheckExecution) const allTeamKeys = Object.keys(grouped);
let crownHolder: string | null = null; const crownHolder = findCrownTeam(
let maxTilesNow = 0; computeTeamTiles(this.game.playerViews()),
for (const [team, players] of Object.entries(grouped)) { );
const teamTotal = players.reduce((sum, p) => sum + p.numTilesOwned(), 0); const serverTicksMap = new Map<Team, number>(
if (teamTotal > maxTilesNow) { Object.entries(serverCrownTicks),
maxTilesNow = teamTotal; );
crownHolder = team; const crownSecondsMap = normalizeCrownSeconds(
} allTeamKeys,
} serverTicksMap,
// Non-holder teams get floor(ticks/10), holder gets remainder crownHolder,
const normalizedCrownTicks = new Map<string, number>(); elapsedSeconds,
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,
);
}
this.teams = Object.entries(grouped) this.teams = Object.entries(grouped)
.map(([teamStr, teamPlayers]) => { .map(([teamStr, teamPlayers]) => {
@@ -245,7 +226,7 @@ export class TeamStats extends LitElement implements Layer {
this._gameOver && this.game.competitiveScores() this._gameOver && this.game.competitiveScores()
? (this.game.competitiveScores()!.find((s) => s.team === teamStr) ? (this.game.competitiveScores()!.find((s) => s.team === teamStr)
?.crownTimeSeconds ?? 0) ?.crownTimeSeconds ?? 0)
: Math.floor((normalizedCrownTicks.get(teamStr) ?? 0) / 10), : (crownSecondsMap.get(teamStr) ?? 0),
totalLaunchers: renderNumber(totalLaunchers), totalLaunchers: renderNumber(totalLaunchers),
totalSAMs: renderNumber(totalSAMs), totalSAMs: renderNumber(totalSAMs),
+1
View File
@@ -121,6 +121,7 @@ export class AttackExecution implements Execution {
// CrownBreakPoint: 25% troop bonus when attacking the crown-holding team // CrownBreakPoint: 25% troop bonus when attacking the crown-holding team
if ( if (
this.mg.config().gameConfig().competitiveScoring &&
this.mg.config().gameConfig().gameMode === GameMode.Team && this.mg.config().gameConfig().gameMode === GameMode.Team &&
this.target.isPlayer() this.target.isPlayer()
) { ) {
+4 -20
View File
@@ -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: * Tracks team-level competitive metrics every 10 ticks:
@@ -30,30 +31,13 @@ export class TeamMetricsExecution implements Execution {
if (elapsedSeconds >= maxTimerValue * 60) return; if (elapsedSeconds >= maxTimerValue * 60) return;
} }
const teamToTiles = new Map<Team, number>(); const teamToTiles = computeTeamTiles(this.mg.players());
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(),
);
}
// Track peak tiles for each team
for (const [team, tiles] of teamToTiles) { for (const [team, tiles] of teamToTiles) {
this.mg.updateTeamPeakTiles(team, tiles); this.mg.updateTeamPeakTiles(team, tiles);
} }
// Track crown (team with most tiles) const crownTeam = findCrownTeam(teamToTiles);
let maxTiles = 0;
let crownTeam: Team | null = null;
for (const [team, tiles] of teamToTiles) {
if (tiles > maxTiles) {
maxTiles = tiles;
crownTeam = team;
}
}
if (crownTeam !== null) { if (crownTeam !== null) {
this.mg.addCrownTick(crownTeam, 10); this.mg.addCrownTick(crownTeam, 10);
} }
+19 -34
View File
@@ -13,6 +13,11 @@ import {
RankedType, RankedType,
Team, Team,
} from "../game/Game"; } from "../game/Game";
import {
computeTeamTiles,
findCrownTeam,
normalizeCrownSeconds,
} from "../game/TeamUtils";
export class WinEvent implements GameEvent { export class WinEvent implements GameEvent {
constructor(public readonly winner: Player) {} constructor(public readonly winner: Player) {}
@@ -148,7 +153,7 @@ export class WinCheckExecution implements Execution {
if (winner === undefined) return; if (winner === undefined) return;
const scores = this.mg.config().gameConfig().competitiveScoring const scores = this.mg.config().gameConfig().competitiveScoring
? this.computeScores(teamToTiles, numTilesWithoutFallout) ? this.computeScores(this.mg, teamToTiles, numTilesWithoutFallout)
: undefined; : undefined;
this.mg.setWinner(winner[0], this.mg.stats().stats(), scores); this.mg.setWinner(winner[0], this.mg.stats().stats(), scores);
console.log(`${winner[0]} has won the game`); console.log(`${winner[0]} has won the game`);
@@ -157,17 +162,15 @@ export class WinCheckExecution implements Execution {
} }
private computeScores( private computeScores(
mg: Game,
teamToTiles: Map<Team, number>, teamToTiles: Map<Team, number>,
numTilesWithoutFallout: number, numTilesWithoutFallout: number,
) { ) {
if (this.mg === null) return undefined; const eliminationOrder = mg.teamEliminationOrder();
const eliminationOrder = this.mg.teamEliminationOrder();
const allTeams = Array.from(teamToTiles.keys()).filter( const allTeams = Array.from(teamToTiles.keys()).filter(
(t) => t !== ColoredTeams.Bot, (t) => t !== ColoredTeams.Bot,
); );
const totalGameTicks = const totalGameTicks = mg.ticks() - mg.config().numSpawnPhaseTurns();
this.mg.ticks() - this.mg.config().numSpawnPhaseTurns();
// Rank surviving teams by current tiles (more tiles = better placement) // Rank surviving teams by current tiles (more tiles = better placement)
const survivingTeams = allTeams.filter( const survivingTeams = allTeams.filter(
@@ -177,37 +180,19 @@ export class WinCheckExecution implements Execution {
(a, b) => (teamToTiles.get(b) ?? 0) - (teamToTiles.get(a) ?? 0), (a, b) => (teamToTiles.get(b) ?? 0) - (teamToTiles.get(a) ?? 0),
); );
// Normalize crown seconds: non-crown teams get floor(ticks/10), const nonBotTiles = computeTeamTiles(mg.allPlayers());
// crown holder gets the remainder so the sum matches the game timer. const crownHolder = findCrownTeam(nonBotTiles);
const elapsedSeconds = Math.floor(Math.max(0, totalGameTicks) / 10); const elapsedSeconds = Math.floor(Math.max(0, totalGameTicks) / 10);
let crownHolder: Team | null = null; const allCrownTicks = mg.allTeamCrownTicks();
let maxTiles = 0; const teamCrownSeconds = normalizeCrownSeconds(
for (const [team, tiles] of teamToTiles) { allTeams,
if (team !== ColoredTeams.Bot && tiles > maxTiles) { allCrownTicks,
maxTiles = tiles; crownHolder,
crownHolder = team; elapsedSeconds,
} );
}
const allCrownTicks = this.mg!.allTeamCrownTicks();
const teamCrownSeconds = new Map<Team, number>();
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 metrics: TeamRawMetrics[] = allTeams.map((team) => { const metrics: TeamRawMetrics[] = allTeams.map((team) => {
const peakTiles = this.mg!.teamPeakTiles(team); const peakTiles = mg.teamPeakTiles(team);
const peakTilePercentage = (peakTiles / numTilesWithoutFallout) * 100; const peakTilePercentage = (peakTiles / numTilesWithoutFallout) * 100;
const crownTicks = allCrownTicks.get(team) ?? 0; const crownTicks = allCrownTicks.get(team) ?? 0;
const crownRatio = totalGameTicks > 0 ? crownTicks / totalGameTicks : 0; const crownRatio = totalGameTicks > 0 ? crownTicks / totalGameTicks : 0;
+3 -19
View File
@@ -51,6 +51,7 @@ import { createRailNetwork } from "./RailNetworkImpl";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { StatsImpl } from "./StatsImpl"; import { StatsImpl } from "./StatsImpl";
import { assignTeams } from "./TeamAssignment"; import { assignTeams } from "./TeamAssignment";
import { computeTeamTiles, findCrownTeam } from "./TeamUtils";
import { TerraNulliusImpl } from "./TerraNulliusImpl"; import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { UnitGrid, UnitPredicate } from "./UnitGrid"; import { UnitGrid, UnitPredicate } from "./UnitGrid";
@@ -1256,28 +1257,11 @@ export class GameImpl implements Game {
} }
crownTeam(): Team | null { crownTeam(): Team | null {
const teamToTiles = new Map<Team, number>(); return findCrownTeam(computeTeamTiles(this.players()));
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;
} }
allTeamCrownTicks(): ReadonlyMap<Team, number> { allTeamCrownTicks(): ReadonlyMap<Team, number> {
return new Map(this._teamCrownTicks); return this._teamCrownTicks;
} }
addCrownTick(team: Team, amount: number): void { addCrownTick(team: Team, amount: number): void {
+75
View File
@@ -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<HasTeamAndTiles>,
excludeBots = true,
): Map<Team, number> {
const teamToTiles = new Map<Team, number>();
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, number>): 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<Team, number>,
crownHolder: Team | null,
totalElapsedSeconds: number,
): Map<Team, number> {
const result = new Map<Team, number>();
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;
}