This commit is contained in:
Ryan Barlow
2026-03-01 13:16:22 +00:00
parent 4c560c7ba9
commit 15bcc0241e
10 changed files with 242 additions and 112 deletions
+9 -11
View File
@@ -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 {
+98 -89
View File
@@ -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<Team, number> = new Map();
/** Peak tile count per team (client-side tracking). */
private _peakTiles: Map<Team, number> = 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<Team, number> = new Map();
/** Frozen peak % per team at game end. */
private _frozenPeakPercent: Map<Team, number> = 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<Team> = 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<Team, number>();
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<Team, number>,
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<string, number>();
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`
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
title=${title ?? ""}
title="${title ?? ""}"
>
${text}
</div>
@@ -296,14 +309,10 @@ export class TeamStats extends LitElement implements Layer {
`;
case "competitive":
return html`
${cell(translateText("leaderboard.team"))} ${cell("Current %")}
${cell("Peak %")}
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
title="Crown time (time holding most territory)"
>
👑
</div>
${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`
<div class="${rowClass}">
${td(team.teamName)} ${td(team.totalScoreStr)}
${td(team.peakScoreStr)} ${td(formatCrownTime(team.crownSeconds))}
${td(team.peakScoreStr)} ${td(formatCrownTime(team.crownTicks))}
</div>
`;
}
+73 -6
View File
@@ -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`<span
class="inline-flex items-center justify-center w-4 h-4 rounded-full bg-slate-600 text-slate-300 text-[10px] font-bold leading-none cursor-help shrink-0 ml-0.5"
@mouseenter=${(e: MouseEvent) => this._showTooltip(e, tooltip)}
@mouseleave=${() => this._hideTooltip()}
>?</span
>`;
}
private renderCompetitiveScores() {
if (!this.competitiveScores) return html``;
return html`
@@ -120,9 +157,24 @@ export class WinModal extends LitElement implements Layer {
<tr class="text-slate-300 border-b border-slate-600">
<th class="py-1.5 px-1">#</th>
<th class="py-1.5 px-1 text-left">Team</th>
<th class="py-1.5 px-1">Tiles</th>
<th class="py-1.5 px-1">Crown</th>
<th class="py-1.5 px-1">Place</th>
<th class="py-1.5 px-1">
Tiles
${this.renderInfoIcon(
"Points for peak territory % (1st: 60, 2nd: 54, 3rd: 48...)",
)}
</th>
<th class="py-1.5 px-1">
Crown
${this.renderInfoIcon(
"Points for time holding the most territory (1st: 30, 2nd: 27, 3rd: 24...)",
)}
</th>
<th class="py-1.5 px-1">
Place
${this.renderInfoIcon(
"Points for survival placement (1st: 10, 2nd: 8, 3rd: 6...)",
)}
</th>
<th class="py-1.5 px-1 font-bold">Total</th>
</tr>
</thead>
@@ -136,9 +188,24 @@ export class WinModal extends LitElement implements Layer {
>
<td class="py-1.5 px-1">${i + 1}</td>
<td class="py-1.5 px-1 text-left">${s.team}</td>
<td class="py-1.5 px-1">${s.maxTilesPoints}</td>
<td class="py-1.5 px-1">${s.crownTimePoints}</td>
<td class="py-1.5 px-1">${s.placementPoints}</td>
<td class="py-1.5 px-1">
${s.maxTilesPoints}
${this.renderInfoIcon(
`${s.team} ranked #${s.maxTilesRank} in peak territory (${s.peakTilePercentage.toFixed(1)}%)`,
)}
</td>
<td class="py-1.5 px-1">
${s.crownTimePoints}
${this.renderInfoIcon(
`${s.team} ranked #${s.crownTimeRank} in crown time (${this.formatTime(s.crownTimeSeconds)})`,
)}
</td>
<td class="py-1.5 px-1">
${s.placementPoints}
${this.renderInfoIcon(
`${s.team} placed #${s.placementRank} in survival`,
)}
</td>
<td class="py-1.5 px-1 font-bold text-yellow-300">
${s.totalScore}
</td>
+8
View File
@@ -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<string, number> | 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,
});
+39 -3
View File
@@ -109,7 +109,7 @@ export class WinCheckExecution implements Execution {
checkWinnerTeam(): void {
if (this.mg === null) throw new Error("Not initialized");
const teamToTiles = new Map<Team, number>();
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<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 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);
+5
View File
@@ -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,
+1 -1
View File
@@ -862,7 +862,7 @@ export interface Game extends GameMap {
// Crown tracking (team-based)
crownTeam(): Team | null;
teamCrownTicks(team: Team): number;
allTeamCrownTicks(): ReadonlyMap<Team, number>;
addCrownTick(team: Team, amount: number): void;
// Peak tile tracking (team-based)
+2 -2
View File
@@ -1276,8 +1276,8 @@ export class GameImpl implements Game {
return crown;
}
teamCrownTicks(team: Team): number {
return this._teamCrownTicks.get(team) ?? 0;
allTeamCrownTicks(): ReadonlyMap<Team, number> {
return this._teamCrownTicks;
}
addCrownTick(team: Team, amount: number): void {
+2
View File
@@ -33,6 +33,8 @@ export interface GameUpdateViewData {
*/
packedMotionPlans?: Uint32Array;
playerNameViewData: Record<string, NameViewData>;
/** Per-team crown ticks from the server (authoritative). */
teamCrownTicks?: Record<string, number>;
tickExecutionDuration?: number;
pendingTurns?: number;
}
+5
View File
@@ -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<string, number> | undefined {
return this.lastUpdate?.teamCrownTicks;
}
public motionPlans(): ReadonlyMap<
number,
{