From 15bcc0241e71f89a11faf857f887166862ab0284 Mon Sep 17 00:00:00 2001 From: Ryan Barlow Date: Sun, 1 Mar 2026 13:16:22 +0000 Subject: [PATCH] update --- .../graphics/layers/GameRightSidebar.ts | 20 +- src/client/graphics/layers/TeamStats.ts | 187 +++++++++--------- src/client/graphics/layers/WinModal.ts | 79 +++++++- src/core/GameRunner.ts | 8 + src/core/execution/WinCheckExecution.ts | 42 +++- src/core/game/CompetitiveScoring.ts | 5 + src/core/game/Game.ts | 2 +- src/core/game/GameImpl.ts | 4 +- src/core/game/GameUpdates.ts | 2 + src/core/game/GameView.ts | 5 + 10 files changed, 242 insertions(+), 112 deletions(-) diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index 2d2f78891..4cc09f070 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -51,7 +51,6 @@ export class GameRightSidebar extends LitElement implements Layer { this.game?.config()?.gameConfig()?.gameType === GameType.Singleplayer || this.game.config().isReplay(); this._isVisible = true; - this.game.inSpawnPhase(); this.eventBus.on(SpawnBarVisibleEvent, (e) => { this.spawnBarVisible = e.visible; @@ -64,7 +63,7 @@ export class GameRightSidebar extends LitElement implements Layer { this.eventBus.on(SendWinnerEvent, () => { this.hasWinner = true; - this.requestUpdate(); + this.updateTimer(); }); this.requestUpdate(); @@ -75,28 +74,27 @@ export class GameRightSidebar extends LitElement implements Layer { } tick() { - // Timer logic - // Check if the player is the lobby creator if (!this.isLobbyCreator && this.game.myPlayer()?.isLobbyCreator()) { this.isLobbyCreator = true; this.requestUpdate(); } + if (this.hasWinner) return; + + this.updateTimer(); + } + + private updateTimer() { const maxTimerValue = this.game.config().gameConfig().maxTimerValue; const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns(); - const ticks = this.game.ticks(); - const gameTicks = Math.max(0, ticks - spawnPhaseTurns); - const elapsedSeconds = Math.floor(gameTicks / 10); // 10 ticks per second + const gameTicks = Math.max(0, this.game.ticks() - spawnPhaseTurns); + const elapsedSeconds = Math.floor(gameTicks / 10); if (this.game.inSpawnPhase()) { this.timer = maxTimerValue !== undefined ? maxTimerValue * 60 : 0; return; } - if (this.hasWinner) { - return; - } - if (maxTimerValue !== undefined) { this.timer = Math.max(0, maxTimerValue * 60 - elapsedSeconds); } else { diff --git a/src/client/graphics/layers/TeamStats.ts b/src/client/graphics/layers/TeamStats.ts index 1dc458b48..9ae01e494 100644 --- a/src/client/graphics/layers/TeamStats.ts +++ b/src/client/graphics/layers/TeamStats.ts @@ -7,8 +7,8 @@ import { Team, UnitType, } from "../../../core/game/Game"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, PlayerView } from "../../../core/game/GameView"; +import { SendWinnerEvent } from "../../Transport"; import { formatPercentage, renderNumber, @@ -17,7 +17,8 @@ import { } from "../../Utils"; import { Layer } from "./Layer"; -function formatCrownTime(seconds: number): string { +function formatCrownTime(ticks: number): string { + const seconds = Math.floor(ticks / 10); if (seconds <= 0) return "0:00"; const m = Math.floor(seconds / 60); const s = seconds % 60; @@ -38,7 +39,7 @@ interface TeamEntry { totalWarShips: string; totalCities: string; totalScoreSort: number; - crownSeconds: number; + crownTicks: number; players: PlayerView[]; } @@ -52,20 +53,30 @@ export class TeamStats extends LitElement implements Layer { private _shownOnInit = false; private viewMode: ViewMode = "control"; private _myTeam: Team | null = null; - /** Crown time in game ticks accumulated per team (client-side tracking). */ - private _crownTicks: Map = new Map(); /** Peak tile count per team (client-side tracking). */ private _peakTiles: Map = new Map(); - /** Last game tick we processed metrics for. */ - private _lastMetricsTick: number = 0; /** Whether the game has ended (win detected). */ private _gameOver: boolean = false; + /** Frozen current % per team at game end. */ + private _frozenCurrentPercent: Map = new Map(); + /** Frozen peak % per team at game end. */ + private _frozenPeakPercent: Map = new Map(); + /** Game tick when game ended (for freezing elapsed time). */ + private _endTick: number | null = null; + /** All teams we've ever seen (so eliminated teams still appear). */ + private _knownTeams: Set = new Set(); createRenderRoot() { return this; // use light DOM for Tailwind } - init() {} + init() { + this.eventBus.on(SendWinnerEvent, () => { + this._gameOver = true; + this._endTick = this.game.ticks(); + this.freezeScores(); + }); + } getTickIntervalMs() { return 100; @@ -76,7 +87,10 @@ export class TeamStats extends LitElement implements Layer { if (!this._shownOnInit && !this.game.inSpawnPhase()) { this._shownOnInit = true; - this._lastMetricsTick = this.game.ticks(); + if (this.game.config().gameConfig().competitiveScoring) { + this.viewMode = "competitive"; + this.visible = true; + } this.updateTeamStats(); } @@ -91,90 +105,39 @@ export class TeamStats extends LitElement implements Layer { } private trackMetrics() { - const currentTick = this.game.ticks(); - const tickDelta = currentTick - this._lastMetricsTick; - this._lastMetricsTick = currentTick; - if (tickDelta <= 0) return; - - const players = this.game.playerViews(); const teamToTiles = new Map(); - for (const player of players) { + for (const player of this.game.playerViews()) { const team = player.team(); if (team === null || team === ColoredTeams.Bot) continue; + this._knownTeams.add(team); teamToTiles.set( team, (teamToTiles.get(team) ?? 0) + player.numTilesOwned(), ); } - - const hasWinUpdate = this.hasWinUpdate(); - const winConditionMet = this.isTeamWinConditionMet( - teamToTiles, - currentTick, - ); - - // Fallback for missed WinUpdate polling: stop immediately once we detect - // the board already satisfies the team win condition. - if (!hasWinUpdate && winConditionMet) { - this._gameOver = true; - return; - } - - // Track peak tiles for (const [team, tiles] of teamToTiles) { const prev = this._peakTiles.get(team) ?? 0; if (tiles > prev) { this._peakTiles.set(team, tiles); } } - - // Track crown time (in game ticks) - let maxTiles = 0; - let crownTeam: Team | null = null; - for (const [team, tiles] of teamToTiles) { - if (tiles > maxTiles) { - maxTiles = tiles; - crownTeam = team; - } - } - if (crownTeam !== null && maxTiles > 0) { - this._crownTicks.set( - crownTeam, - (this._crownTicks.get(crownTeam) ?? 0) + tickDelta, - ); - } - - if (hasWinUpdate || winConditionMet) { - this._gameOver = true; - } } - private hasWinUpdate(): boolean { - const updates = this.game.updatesSinceLastTick(); - const winUpdates = updates !== null ? updates[GameUpdateType.Win] : []; - return winUpdates.length > 0; - } - - private isTeamWinConditionMet( - teamToTiles: Map, - currentTick: number, - ): boolean { + private freezeScores() { const numTilesWithoutFallout = this.game.numLandTiles() - this.game.numTilesWithFallout(); - if (numTilesWithoutFallout <= 0 || teamToTiles.size === 0) return false; - - const maxTiles = Math.max(...Array.from(teamToTiles.values())); - const percentage = (maxTiles / numTilesWithoutFallout) * 100; - const territoryWin = - percentage > this.game.config().percentageTilesOwnedToWin(); - - const maxTimer = this.game.config().gameConfig().maxTimerValue; - const timeElapsedSeconds = - (currentTick - this.game.config().numSpawnPhaseTurns()) / 10; - const timerWin = - maxTimer !== undefined && timeElapsedSeconds - maxTimer * 60 >= 0; - - return territoryWin || timerWin; + 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, + ); + } + for (const [team, peakTiles] of this._peakTiles) { + this._frozenPeakPercent.set(team, peakTiles / numTilesWithoutFallout); + } } private updateTeamStats() { @@ -193,9 +156,54 @@ export class TeamStats extends LitElement implements Layer { grouped[team].push(player); } + // In competitive view, ensure eliminated teams still appear + if (this.viewMode === "competitive") { + for (const team of this._knownTeams) { + if (!(team in grouped)) { + grouped[team] = []; + } + } + } + const numTilesWithoutFallout = this.game.numLandTiles() - this.game.numTilesWithFallout(); + // Read per-team crown ticks from the server (authoritative source). + // Normalize so displayed seconds sum to the sidebar elapsed seconds. + const currentTick = this._endTick ?? this.game.ticks(); + const elapsedGameTicks = Math.max( + 0, + currentTick - this.game.config().numSpawnPhaseTurns(), + ); + const elapsedSeconds = 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, + ); + } + this.teams = Object.entries(grouped) .map(([teamStr, teamPlayers]) => { let totalGold = 0n; @@ -218,9 +226,14 @@ export class TeamStats extends LitElement implements Layer { } } - const totalScorePercent = totalScoreSort / numTilesWithoutFallout; - const peakTiles = this._peakTiles.get(teamStr) ?? 0; - const peakPercent = peakTiles / numTilesWithoutFallout; + const totalScorePercent = this._gameOver + ? (this._frozenCurrentPercent.get(teamStr) ?? + totalScoreSort / numTilesWithoutFallout) + : totalScoreSort / numTilesWithoutFallout; + const rawPeakPercent = this._gameOver + ? (this._frozenPeakPercent.get(teamStr) ?? 0) + : (this._peakTiles.get(teamStr) ?? 0) / numTilesWithoutFallout; + const peakPercent = Math.max(rawPeakPercent, totalScorePercent); return { teamName: teamStr, @@ -231,7 +244,7 @@ export class TeamStats extends LitElement implements Layer { totalGold: renderNumber(totalGold), totalMaxTroops: renderTroops(totalMaxTroops), players: teamPlayers, - crownSeconds: Math.floor((this._crownTicks.get(teamStr) ?? 0) / 10), + crownTicks: normalizedCrownTicks.get(teamStr) ?? 0, totalLaunchers: renderNumber(totalLaunchers), totalSAMs: renderNumber(totalSAMs), @@ -272,7 +285,7 @@ export class TeamStats extends LitElement implements Layer { const cell = (text: string, title?: string) => html`
${text}
@@ -296,14 +309,10 @@ export class TeamStats extends LitElement implements Layer { `; case "competitive": return html` - ${cell(translateText("leaderboard.team"))} ${cell("Current %")} - ${cell("Peak %")} -
- 👑 -
+ ${cell(translateText("leaderboard.team"))} + ${cell("Current %", "Percentage of land currently controlled")} + ${cell("Peak %", "Highest percentage of land ever controlled")} + ${cell("👑", "Total time spent holding the most territory")} `; } } @@ -333,7 +342,7 @@ export class TeamStats extends LitElement implements Layer { return html`
${td(team.teamName)} ${td(team.totalScoreStr)} - ${td(team.peakScoreStr)} ${td(formatCrownTime(team.crownSeconds))} + ${td(team.peakScoreStr)} ${td(formatCrownTime(team.crownTicks))}
`; } diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index 3f186eda4..15f7372e0 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -108,6 +108,43 @@ export class WinModal extends LitElement implements Layer { `; } + private formatTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}m${String(s).padStart(2, "0")}s`; + } + + private _tooltip: HTMLDivElement | null = null; + + private _showTooltip(e: MouseEvent, text: string) { + if (!this._tooltip) { + this._tooltip = document.createElement("div"); + this._tooltip.className = + "fixed bg-black text-white text-xs rounded px-2 py-1.5 z-[99999] max-w-[250px] pointer-events-none"; + document.body.appendChild(this._tooltip); + } + this._tooltip.textContent = text; + this._tooltip.style.display = "block"; + const rect = (e.target as HTMLElement).getBoundingClientRect(); + this._tooltip.style.left = `${rect.left + rect.width / 2 - 125}px`; + this._tooltip.style.top = `${rect.top - this._tooltip.offsetHeight - 4}px`; + } + + private _hideTooltip() { + if (this._tooltip) { + this._tooltip.style.display = "none"; + } + } + + private renderInfoIcon(tooltip: string) { + return html` this._showTooltip(e, tooltip)} + @mouseleave=${() => this._hideTooltip()} + >?`; + } + private renderCompetitiveScores() { if (!this.competitiveScores) return html``; return html` @@ -120,9 +157,24 @@ export class WinModal extends LitElement implements Layer { # Team - Tiles - Crown - Place + + Tiles + ${this.renderInfoIcon( + "Points for peak territory % (1st: 60, 2nd: 54, 3rd: 48...)", + )} + + + Crown + ${this.renderInfoIcon( + "Points for time holding the most territory (1st: 30, 2nd: 27, 3rd: 24...)", + )} + + + Place + ${this.renderInfoIcon( + "Points for survival placement (1st: 10, 2nd: 8, 3rd: 6...)", + )} + Total @@ -136,9 +188,24 @@ export class WinModal extends LitElement implements Layer { > ${i + 1} ${s.team} - ${s.maxTilesPoints} - ${s.crownTimePoints} - ${s.placementPoints} + + ${s.maxTilesPoints} + ${this.renderInfoIcon( + `${s.team} ranked #${s.maxTilesRank} in peak territory (${s.peakTilePercentage.toFixed(1)}%)`, + )} + + + ${s.crownTimePoints} + ${this.renderInfoIcon( + `${s.team} ranked #${s.crownTimeRank} in crown time (${this.formatTime(s.crownTimeSeconds)})`, + )} + + + ${s.placementPoints} + ${this.renderInfoIcon( + `${s.team} placed #${s.placementRank} in survival`, + )} + ${s.totalScore} diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 48d0a1ab4..068aa86dc 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -174,12 +174,20 @@ export class GameRunner { const packedTileUpdates = this.game.drainPackedTileUpdates(); const packedMotionPlans = this.game.drainPackedMotionPlans(); + // Build per-team crown ticks for competitive mode + const crownTicks = this.game.allTeamCrownTicks(); + const teamCrownTicks: Record | undefined = + crownTicks.size > 0 + ? Object.fromEntries(crownTicks.entries()) + : undefined; + this.callBack({ tick: this.game.ticks(), packedTileUpdates, ...(packedMotionPlans ? { packedMotionPlans } : {}), updates: updates, playerNameViewData: this.playerViewData, + teamCrownTicks, tickExecutionDuration: tickExecutionDuration, pendingTurns: pendingTurns ?? 0, }); diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index 3159cce8a..30724d51e 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -109,7 +109,7 @@ export class WinCheckExecution implements Execution { checkWinnerTeam(): void { if (this.mg === null) throw new Error("Not initialized"); const teamToTiles = new Map(); - for (const player of this.mg.players()) { + for (const player of this.mg.allPlayers()) { const team = player.team(); // Sanity check, team should not be null here if (team === null) continue; @@ -167,10 +167,39 @@ 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 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 metrics: TeamRawMetrics[] = allTeams.map((team) => { const peakTiles = this.mg!.teamPeakTiles(team); const peakTilePercentage = (peakTiles / numTilesWithoutFallout) * 100; - const crownTicks = this.mg!.teamCrownTicks(team); + const crownTicks = allCrownTicks.get(team) ?? 0; const crownRatio = totalGameTicks > 0 ? crownTicks / totalGameTicks : 0; const elimIndex = eliminationOrder.indexOf(team); @@ -185,7 +214,14 @@ export class WinCheckExecution implements Execution { placementRank = elimIndex + 1; } - return { team, peakTilePercentage, crownRatio, placementRank }; + const crownTimeSeconds = teamCrownSeconds.get(team) ?? 0; + return { + team, + peakTilePercentage, + crownRatio, + crownTimeSeconds, + placementRank, + }; }); return computeCompetitiveScores(metrics); diff --git a/src/core/game/CompetitiveScoring.ts b/src/core/game/CompetitiveScoring.ts index dcf58ee15..3b85682ba 100644 --- a/src/core/game/CompetitiveScoring.ts +++ b/src/core/game/CompetitiveScoring.ts @@ -4,6 +4,7 @@ export interface TeamRawMetrics { team: Team; peakTilePercentage: number; crownRatio: number; + crownTimeSeconds: number; placementRank: number; } @@ -11,8 +12,10 @@ export interface TeamScoreBreakdown { team: Team; maxTilesRank: number; maxTilesPoints: number; + peakTilePercentage: number; crownTimeRank: number; crownTimePoints: number; + crownTimeSeconds: number; placementRank: number; placementPoints: number; totalScore: number; @@ -74,8 +77,10 @@ export function computeCompetitiveScores( team: m.team, maxTilesRank, maxTilesPoints, + peakTilePercentage: m.peakTilePercentage, crownTimeRank, crownTimePoints, + crownTimeSeconds: m.crownTimeSeconds, placementRank, placementPoints, totalScore: maxTilesPoints + crownTimePoints + placementPoints, diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 681788825..f3001b5f9 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -862,7 +862,7 @@ export interface Game extends GameMap { // Crown tracking (team-based) crownTeam(): Team | null; - teamCrownTicks(team: Team): number; + allTeamCrownTicks(): ReadonlyMap; addCrownTick(team: Team, amount: number): void; // Peak tile tracking (team-based) diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 2779f3eb3..6ee555bd3 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -1276,8 +1276,8 @@ export class GameImpl implements Game { return crown; } - teamCrownTicks(team: Team): number { - return this._teamCrownTicks.get(team) ?? 0; + allTeamCrownTicks(): ReadonlyMap { + return this._teamCrownTicks; } addCrownTick(team: Team, amount: number): void { diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index f3879a2d7..201ef4314 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -33,6 +33,8 @@ export interface GameUpdateViewData { */ packedMotionPlans?: Uint32Array; playerNameViewData: Record; + /** Per-team crown ticks from the server (authoritative). */ + teamCrownTicks?: Record; tickExecutionDuration?: number; pendingTurns?: number; } diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index f08b8178b..7c98da8a6 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -674,6 +674,11 @@ export class GameView implements GameMap { return this.lastUpdate?.updates ?? null; } + /** Per-team crown ticks from the server (authoritative). */ + public teamCrownTicks(): Record | undefined { + return this.lastUpdate?.teamCrownTicks; + } + public motionPlans(): ReadonlyMap< number, {