diff --git a/src/client/graphics/layers/TeamStats.ts b/src/client/graphics/layers/TeamStats.ts
index c011d421f..ab66b3d10 100644
--- a/src/client/graphics/layers/TeamStats.ts
+++ b/src/client/graphics/layers/TeamStats.ts
@@ -32,7 +32,7 @@ interface TeamEntry {
totalWarShips: string;
totalCities: string;
totalScoreSort: number;
- crownTicks: number;
+ crownSeconds: number;
players: PlayerView[];
}
@@ -168,7 +168,11 @@ export class TeamStats extends LitElement implements Layer {
0,
currentTick - this.game.config().numSpawnPhaseTurns(),
);
- const elapsedSeconds = Math.floor(elapsedGameTicks / 10);
+ const maxTimerValue = this.game.config().gameConfig().maxTimerValue;
+ const elapsedSeconds =
+ maxTimerValue !== undefined
+ ? 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;
@@ -237,7 +241,11 @@ export class TeamStats extends LitElement implements Layer {
totalGold: renderNumber(totalGold),
totalMaxTroops: renderTroops(totalMaxTroops),
players: teamPlayers,
- crownTicks: normalizedCrownTicks.get(teamStr) ?? 0,
+ crownSeconds:
+ this._gameOver && this.game.competitiveScores()
+ ? (this.game.competitiveScores()!.find((s) => s.team === teamStr)
+ ?.crownTimeSeconds ?? 0)
+ : Math.floor((normalizedCrownTicks.get(teamStr) ?? 0) / 10),
totalLaunchers: renderNumber(totalLaunchers),
totalSAMs: renderNumber(totalSAMs),
@@ -335,8 +343,7 @@ export class TeamStats extends LitElement implements Layer {
return html`
${td(team.teamName)} ${td(team.totalScoreStr)}
- ${td(team.peakScoreStr)}
- ${td(secondsToHms(Math.floor(team.crownTicks / 10)))}
+ ${td(team.peakScoreStr)} ${td(secondsToHms(team.crownSeconds))}
`;
}
diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts
index a82b9afc0..f86d824f5 100644
--- a/src/client/graphics/layers/WinModal.ts
+++ b/src/client/graphics/layers/WinModal.ts
@@ -9,7 +9,6 @@ import {
} from "../../../client/Utils";
import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas";
import { EventBus } from "../../../core/EventBus";
-import { TeamScoreBreakdown } from "../../../core/game/CompetitiveScoring";
import { RankedType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
@@ -46,8 +45,9 @@ export class WinModal extends LitElement implements Layer {
@state()
private patternContent: TemplateResult | null = null;
- @state()
- private competitiveScores: TeamScoreBreakdown[] | null = null;
+ private get competitiveScores() {
+ return this.game.competitiveScores();
+ }
private _title: string;
@@ -409,9 +409,6 @@ export class WinModal extends LitElement implements Layer {
// ...
} else if (wu.winner[0] === "team") {
this.eventBus.emit(new SendWinnerEvent(wu.winner, wu.allPlayersStats));
- if (wu.competitiveScores) {
- this.competitiveScores = wu.competitiveScores;
- }
if (wu.winner[1] === this.game.myPlayer()?.team()) {
this._title = translateText("win_modal.your_team");
this.isWin = true;
diff --git a/src/core/execution/TeamMetricsExecution.ts b/src/core/execution/TeamMetricsExecution.ts
index 33ddedb93..aa25362bc 100644
--- a/src/core/execution/TeamMetricsExecution.ts
+++ b/src/core/execution/TeamMetricsExecution.ts
@@ -22,6 +22,14 @@ export class TeamMetricsExecution implements Execution {
if (ticks % 10 !== 0) return;
if (this.mg === null) throw new Error("Not initialized");
+ // Stop tracking after the game timer expires.
+ const maxTimerValue = this.mg.config().gameConfig().maxTimerValue;
+ if (maxTimerValue !== undefined) {
+ const elapsedSeconds =
+ (ticks - this.mg.config().numSpawnPhaseTurns()) / 10;
+ if (elapsedSeconds >= maxTimerValue * 60) return;
+ }
+
const teamToTiles = new Map();
for (const player of this.mg.players()) {
const team = player.team();
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index 7c98da8a6..075e57479 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -6,6 +6,7 @@ import { PatternDecoder } from "../PatternDecoder";
import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas";
import { createRandomName } from "../Util";
import { WorkerClient } from "../worker/WorkerClient";
+import { TeamScoreBreakdown } from "./CompetitiveScoring";
import {
Cell,
EmojiMessage,
@@ -33,6 +34,7 @@ import {
GameUpdateViewData,
PlayerUpdate,
UnitUpdate,
+ WinUpdate,
} from "./GameUpdates";
import { MotionPlanRecord, unpackMotionPlans } from "./MotionPlans";
import { TerrainMapData } from "./TerrainMapLoader";
@@ -631,6 +633,7 @@ export class GameView implements GameMap {
private trainUnitToEngine = new Map();
private toDelete = new Set();
+ private _competitiveScores: TeamScoreBreakdown[] | null = null;
private _cosmetics: Map = new Map();
@@ -679,6 +682,11 @@ export class GameView implements GameMap {
return this.lastUpdate?.teamCrownTicks;
}
+ /** Competitive scores set once at game end (authoritative). */
+ public competitiveScores(): TeamScoreBreakdown[] | null {
+ return this._competitiveScores;
+ }
+
public motionPlans(): ReadonlyMap<
number,
{
@@ -803,6 +811,17 @@ export class GameView implements GameMap {
}
});
+ // Capture competitive scores from WinUpdate (once)
+ if (this._competitiveScores === null && gu.updates) {
+ const winUpdates = gu.updates[GameUpdateType.Win] as WinUpdate[];
+ for (const wu of winUpdates) {
+ if (wu.competitiveScores) {
+ this._competitiveScores = wu.competitiveScores;
+ break;
+ }
+ }
+ }
+
this.advanceMotionPlannedUnits(gu.tick);
this.rebuildMotionPlannedUnitIdsCacheIfDirty();
}