This commit is contained in:
Ryan Barlow
2026-02-28 15:41:43 +00:00
parent aa451e217f
commit 42c1d3ed7a
20 changed files with 911 additions and 116 deletions
+12
View File
@@ -73,6 +73,7 @@ export class HostLobbyModal extends BaseModal {
@state() private lobbyUrlSuffix = "";
@state() private clients: ClientInfo[] = [];
@state() private useRandomMap: boolean = false;
@state() private competitiveScoring: boolean = false;
@state() private disabledUnits: UnitType[] = [];
@state() private lobbyCreatorClientID: string = "";
@state() private nationCount: number = 0;
@@ -293,6 +294,11 @@ export class HostLobbyModal extends BaseModal {
labelKey: "host_modal.compact_map",
checked: this.compactMap,
},
{
labelKey: "host_modal.competitive_scoring",
checked: this.competitiveScoring,
hidden: this.gameMode !== GameMode.Team,
},
],
inputCards,
},
@@ -449,6 +455,7 @@ export class HostLobbyModal extends BaseModal {
this.goldMultiplierValue = undefined;
this.startingGold = false;
this.startingGoldValue = undefined;
this.competitiveScoring = false;
this.leaveLobbyOnClose = true;
}
@@ -528,6 +535,10 @@ export class HostLobbyModal extends BaseModal {
case "host_modal.compact_map":
this.handleCompactMapChange(checked);
break;
case "host_modal.competitive_scoring":
this.competitiveScoring = checked;
this.putGameConfig();
break;
default:
break;
}
@@ -771,6 +782,7 @@ export class HostLobbyModal extends BaseModal {
: undefined,
startingGold:
this.startingGold === true ? this.startingGoldValue : undefined,
competitiveScoring: this.competitiveScoring || undefined,
} satisfies Partial<GameConfig>,
},
bubbles: true,
+37 -4
View File
@@ -1,4 +1,4 @@
import { AllPlayers, nukeTypes } from "../../core/game/Game";
import { AllPlayers, ColoredTeams, nukeTypes } from "../../core/game/Game";
import { GameView, PlayerView } from "../../core/game/GameView";
import allianceIcon from "/images/AllianceIcon.svg?url";
import allianceIconFaded from "/images/AllianceIconFaded.svg?url";
@@ -45,6 +45,8 @@ export interface PlayerIconParams {
includeAllianceIcon: boolean;
/** Player currently in first place, used for the crown icon */
firstPlace: PlayerView | null;
/** In competitive mode, the team currently holding the crown (all members get crown icon) */
crownTeam?: string | null;
}
export function getFirstPlacePlayer(game: GameView): PlayerView | null {
@@ -55,10 +57,32 @@ export function getFirstPlacePlayer(game: GameView): PlayerView | null {
return sorted.length > 0 ? sorted[0] : 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<string, number>();
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(
params: PlayerIconParams,
): PlayerIconDescriptor[] {
const { game, player, includeAllianceIcon, firstPlace } = params;
const { game, player, includeAllianceIcon, firstPlace, crownTeam } = params;
const myPlayer = game.myPlayer();
const userSettings = game.config().userSettings();
@@ -67,9 +91,18 @@ export function getPlayerIcons(
const icons: PlayerIconDescriptor[] = [];
// Crown icon for first place
if (player === firstPlace) {
// Crown icon: in competitive mode, all members of the crown team get it;
// otherwise only the individual first-place player.
if (
crownTeam !== null &&
crownTeam !== undefined &&
player.team() === crownTeam
) {
icons.push({ id: "crown", kind: "image", src: crownIcon });
} else if (crownTeam === null || crownTeam === undefined) {
if (player === firstPlace) {
icons.push({ id: "crown", kind: "image", src: crownIcon });
}
}
// Traitor icon
+6
View File
@@ -10,6 +10,7 @@ import { createCanvas, renderNumber, renderTroops } from "../../Utils";
import {
computeAllianceClipPath,
createAllianceProgressIcon,
getCrownTeam,
getFirstPlacePlayer,
getPlayerIcons,
PlayerIconId,
@@ -45,6 +46,7 @@ export class NameLayer implements Layer {
private userSettings: UserSettings = new UserSettings();
private isVisible: boolean = true;
private firstPlace: PlayerView | null = null;
private crownTeam: string | null = null;
constructor(
private game: GameView,
@@ -140,6 +142,9 @@ export class NameLayer implements Layer {
public tick() {
// Precompute the first-place player for performance
this.firstPlace = getFirstPlacePlayer(this.game);
this.crownTeam = this.game.config().gameConfig().competitiveScoring
? getCrownTeam(this.game)
: null;
for (const player of this.game.playerViews()) {
if (player.isAlive()) {
@@ -373,6 +378,7 @@ export class NameLayer implements Layer {
player: render.player,
includeAllianceIcon: true,
firstPlace: this.firstPlace,
crownTeam: this.crownTeam,
});
// Build a set of desired icon IDs
@@ -24,7 +24,11 @@ import {
renderTroops,
translateText,
} from "../../Utils";
import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons";
import {
getCrownTeam,
getFirstPlacePlayer,
getPlayerIcons,
} from "../PlayerIcons";
import { TransformHandler } from "../TransformHandler";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
import { Layer } from "./Layer";
@@ -252,12 +256,16 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
private renderPlayerNameIcons(player: PlayerView) {
const firstPlace = getFirstPlacePlayer(this.game);
const crownTeam = this.game.config().gameConfig().competitiveScoring
? getCrownTeam(this.game)
: null;
const icons = getPlayerIcons({
game: this.game,
player,
// Because we already show the alliance icon next to the alliance expiration timer, we don't need to show it a second time in this render
includeAllianceIcon: false,
firstPlace,
crownTeam,
});
if (icons.length === 0) {
+228 -106
View File
@@ -1,7 +1,13 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameMode, Team, UnitType } from "../../../core/game/Game";
import {
ColoredTeams,
GameMode,
Team,
UnitType,
} from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
import {
formatPercentage,
@@ -11,10 +17,20 @@ import {
} from "../../Utils";
import { Layer } from "./Layer";
function formatCrownTime(seconds: number): string {
if (seconds <= 0) return "0:00";
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
type ViewMode = "control" | "units" | "competitive";
interface TeamEntry {
teamName: string;
isMyTeam: boolean;
totalScoreStr: string;
peakScoreStr: string;
totalGold: string;
totalMaxTroops: string;
totalSAMs: string;
@@ -22,6 +38,7 @@ interface TeamEntry {
totalWarShips: string;
totalCities: string;
totalScoreSort: number;
crownSeconds: number;
players: PlayerView[];
}
@@ -33,8 +50,16 @@ export class TeamStats extends LitElement implements Layer {
@property({ type: Boolean }) visible = false;
teams: TeamEntry[] = [];
private _shownOnInit = false;
private showUnits = 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;
createRenderRoot() {
return this; // use light DOM for Tailwind
@@ -43,7 +68,7 @@ export class TeamStats extends LitElement implements Layer {
init() {}
getTickIntervalMs() {
return 1000;
return 100;
}
tick() {
@@ -51,14 +76,107 @@ export class TeamStats extends LitElement implements Layer {
if (!this._shownOnInit && !this.game.inSpawnPhase()) {
this._shownOnInit = true;
this._lastMetricsTick = this.game.ticks();
this.updateTeamStats();
}
// Track crown time and peak tiles based on game ticks
if (!this.game.inSpawnPhase() && !this._gameOver) {
this.trackMetrics();
}
if (!this.visible) return;
this.updateTeamStats();
}
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) {
const team = player.team();
if (team === null || team === ColoredTeams.Bot) continue;
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 {
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;
}
private updateTeamStats() {
const players = this.game.playerViews();
const grouped: Record<Team, PlayerView[]> = {};
@@ -75,6 +193,9 @@ export class TeamStats extends LitElement implements Layer {
grouped[team].push(player);
}
const numTilesWithoutFallout =
this.game.numLandTiles() - this.game.numTilesWithFallout();
this.teams = Object.entries(grouped)
.map(([teamStr, teamPlayers]) => {
let totalGold = 0n;
@@ -97,18 +218,20 @@ export class TeamStats extends LitElement implements Layer {
}
}
const numTilesWithoutFallout =
this.game.numLandTiles() - this.game.numTilesWithFallout();
const totalScorePercent = totalScoreSort / numTilesWithoutFallout;
const peakTiles = this._peakTiles.get(teamStr) ?? 0;
const peakPercent = peakTiles / numTilesWithoutFallout;
return {
teamName: teamStr,
isMyTeam: teamStr === this._myTeam,
totalScoreStr: formatPercentage(totalScorePercent),
peakScoreStr: formatPercentage(peakPercent),
totalScoreSort,
totalGold: renderNumber(totalGold),
totalMaxTroops: renderTroops(totalMaxTroops),
players: teamPlayers,
crownSeconds: Math.floor((this._crownTicks.get(teamStr) ?? 0) / 10),
totalLaunchers: renderNumber(totalLaunchers),
totalSAMs: renderNumber(totalSAMs),
@@ -121,15 +244,110 @@ export class TeamStats extends LitElement implements Layer {
this.requestUpdate();
}
private cycleViewMode() {
const modes: ViewMode[] = ["control", "units", "competitive"];
const idx = modes.indexOf(this.viewMode);
this.viewMode = modes[(idx + 1) % modes.length];
this.requestUpdate();
}
private get viewModeButtonLabel(): string {
switch (this.viewMode) {
case "control":
return translateText("leaderboard.show_units");
case "units":
return "Show Competitive";
case "competitive":
return translateText("leaderboard.show_control");
}
}
renderLayer(context: CanvasRenderingContext2D) {}
shouldTransform(): boolean {
return false;
}
private renderHeader() {
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 ?? ""}
>
${text}
</div>
`;
switch (this.viewMode) {
case "control":
return html`
${cell(translateText("leaderboard.team"))}
${cell(translateText("leaderboard.owned"))}
${cell(translateText("leaderboard.gold"))}
${cell(translateText("leaderboard.maxtroops"))}
`;
case "units":
return html`
${cell(translateText("leaderboard.team"))}
${cell(translateText("leaderboard.launchers"))}
${cell(translateText("leaderboard.sams"))}
${cell(translateText("leaderboard.warships"))}
${cell(translateText("leaderboard.cities"))}
`;
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>
`;
}
}
private renderRow(team: TeamEntry) {
const rowClass = `contents hover:bg-slate-600/60 text-center cursor-pointer ${team.isMyTeam ? "font-bold" : ""}`;
const td = (text: string) =>
html`<div class="py-1.5 border-b border-slate-500">${text}</div>`;
switch (this.viewMode) {
case "control":
return html`
<div class="${rowClass}">
${td(team.teamName)} ${td(team.totalScoreStr)} ${td(team.totalGold)}
${td(team.totalMaxTroops)}
</div>
`;
case "units":
return html`
<div class="${rowClass}">
${td(team.teamName)} ${td(team.totalLaunchers)}
${td(team.totalSAMs)} ${td(team.totalWarShips)}
${td(team.totalCities)}
</div>
`;
case "competitive":
return html`
<div class="${rowClass}">
${td(team.teamName)} ${td(team.totalScoreStr)}
${td(team.peakScoreStr)} ${td(formatCrownTime(team.crownSeconds))}
</div>
`;
}
}
render() {
if (!this.visible) return html``;
const numCols = this.viewMode === "units" ? 5 : 4;
const teamsToRender =
this.viewMode === "competitive"
? this.teams.filter((team) => team.teamName !== ColoredTeams.Bot)
: this.teams;
return html`
<div
class="max-h-[30vh] overflow-x-hidden overflow-y-auto grid bg-slate-800/85 w-full text-white text-xs md:text-sm mt-2 rounded-lg"
@@ -137,114 +355,18 @@ export class TeamStats extends LitElement implements Layer {
>
<div
class="grid w-full grid-cols-[repeat(var(--cols),1fr)]"
style="--cols:${this.showUnits ? 5 : 4};"
style="--cols:${numCols};"
>
<!-- Header -->
<div class="contents font-bold bg-slate-700/60">
<div class="p-1.5 md:p-2.5 text-center border-b border-slate-500">
${translateText("leaderboard.team")}
</div>
${this.showUnits
? html`
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.launchers")}
</div>
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.sams")}
</div>
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.warships")}
</div>
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.cities")}
</div>
`
: html`
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.owned")}
</div>
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.gold")}
</div>
<div
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
>
${translateText("leaderboard.maxtroops")}
</div>
`}
${this.renderHeader()}
</div>
<!-- Data rows -->
${this.teams.map((team) =>
this.showUnits
? html`
<div
class="contents hover:bg-slate-600/60 text-center cursor-pointer ${team.isMyTeam
? "font-bold"
: ""}"
>
<div class="py-1.5 border-b border-slate-500">
${team.teamName}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalLaunchers}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalSAMs}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalWarShips}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalCities}
</div>
</div>
`
: html`
<div
class="contents hover:bg-slate-600/60 text-center cursor-pointer ${team.isMyTeam
? "font-bold"
: ""}"
>
<div class="py-1.5 border-b border-slate-500">
${team.teamName}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalScoreStr}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalGold}
</div>
<div class="py-1.5 border-b border-slate-500">
${team.totalMaxTroops}
</div>
</div>
`,
)}
${teamsToRender.map((team) => this.renderRow(team))}
</div>
<button
class="team-stats-button"
aria-pressed=${String(this.showUnits)}
@click=${() => {
this.showUnits = !this.showUnits;
this.requestUpdate();
}}
>
${this.showUnits
? translateText("leaderboard.show_control")
: translateText("leaderboard.show_units")}
<button class="team-stats-button" @click=${() => this.cycleViewMode()}>
${this.viewModeButtonLabel}
</button>
</div>
`;
+53 -1
View File
@@ -8,6 +8,7 @@ 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";
@@ -44,6 +45,9 @@ export class WinModal extends LitElement implements Layer {
@state()
private patternContent: TemplateResult | null = null;
@state()
private competitiveScores: TeamScoreBreakdown[] | null = null;
private _title: string;
private rand = Math.random();
@@ -67,7 +71,9 @@ export class WinModal extends LitElement implements Layer {
<h2 class="m-0 mb-4 text-[26px] text-center text-white">
${this._title || ""}
</h2>
${this.innerHtml()}
${this.competitiveScores
? this.renderCompetitiveScores()
: this.innerHtml()}
<div
class="${this.showButtons
? "flex justify-between gap-2.5"
@@ -102,6 +108,49 @@ export class WinModal extends LitElement implements Layer {
`;
}
private renderCompetitiveScores() {
if (!this.competitiveScores) return html``;
return html`
<div class="mb-4 bg-black/30 p-3 rounded-sm overflow-x-auto">
<h3 class="text-lg font-semibold text-white mb-2 text-center">
Competitive Scores
</h3>
<table class="w-full text-sm text-center">
<thead>
<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 font-bold">Total</th>
</tr>
</thead>
<tbody>
${this.competitiveScores.map(
(s, i) => html`
<tr
class="${s.team === this.game.myPlayer()?.team()
? "bg-blue-500/20 font-bold"
: ""} border-b border-slate-700"
>
<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 font-bold text-yellow-300">
${s.totalScore}
</td>
</tr>
`,
)}
</tbody>
</table>
</div>
`;
}
innerHtml() {
if (isInIframe()) {
return this.steamWishlist();
@@ -298,6 +347,9 @@ 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;
+2
View File
@@ -2,6 +2,7 @@ import { placeName } from "../client/graphics/NameBoxCalculator";
import { getConfig } from "./configuration/ConfigLoader";
import { Executor } from "./execution/ExecutionManager";
import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterExecution";
import { TeamMetricsExecution } from "./execution/TeamMetricsExecution";
import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
@@ -103,6 +104,7 @@ export class GameRunner {
if (this.game.config().spawnNations()) {
this.game.addExecution(...this.execManager.nationExecutions());
}
this.game.addExecution(new TeamMetricsExecution());
this.game.addExecution(new WinCheckExecution());
if (!this.game.config().isUnitDisabled(UnitType.Factory)) {
this.game.addExecution(
+1
View File
@@ -232,6 +232,7 @@ export const GameConfigSchema = z.object({
playerTeams: TeamCountConfigSchema.optional(),
goldMultiplier: z.number().min(0.1).max(1000).optional(),
startingGold: z.number().int().min(0).max(1000000000).optional(),
competitiveScoring: z.boolean().optional(),
});
export const TeamSchema = z.string();
+21
View File
@@ -4,6 +4,7 @@ import {
Difficulty,
Execution,
Game,
GameMode,
MessageType,
Player,
PlayerID,
@@ -117,6 +118,26 @@ export class AttackExecution implements Execution {
this.refreshToConquer();
}
// CrownBreakPoint: 25% troop bonus when attacking the crown-holding team
if (
this.mg.config().gameConfig().gameMode === GameMode.Team &&
this.target.isPlayer()
) {
const targetPlayer = this.target as Player;
const targetTeam = targetPlayer.team();
const crownTeam = this.mg.crownTeam();
const attackerTeam = this._owner.team();
if (
targetTeam !== null &&
crownTeam !== null &&
targetTeam === crownTeam &&
attackerTeam !== crownTeam
) {
const bonus = Math.floor(this.attack.troops() * 0.25);
this.attack.setTroops(this.attack.troops() + bonus);
}
}
// Record stats
this.mg.stats().attack(this._owner, this.target, this.startTroops);
@@ -0,0 +1,44 @@
import { Execution, Game, GameMode, Team } from "../game/Game";
/**
* Tracks which team holds the "crown" (most total tiles) and accumulates
* crown ticks per team for competition scoring.
*
* Crown time contributes 20% of a team's competition score.
* Only active in Team game mode.
*/
export class CrownTrackingExecution implements Execution {
private active = true;
private mg: Game | null = null;
init(mg: Game, _ticks: number) {
this.mg = mg;
// Only relevant in team mode
if (mg.config().gameConfig().gameMode !== GameMode.Team) {
this.active = false;
}
}
tick(ticks: number) {
if (ticks % 10 !== 0) return;
if (this.mg === null) throw new Error("Not initialized");
const crown = this.computeCrownTeam();
if (crown !== null) {
this.mg.addCrownTick(crown, 10);
}
}
private computeCrownTeam(): Team | null {
if (this.mg === null) return null;
return this.mg.crownTeam();
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
@@ -0,0 +1,61 @@
import { ColoredTeams, Execution, Game, GameMode, Team } from "../game/Game";
/**
* Tracks team-level competitive metrics every 10 ticks:
* - Crown ticks: accumulated time the leading team holds most tiles
* - Peak tiles: highest tile count each team reaches during the match
*
* Only active in Team game mode.
*/
export class TeamMetricsExecution implements Execution {
private active = true;
private mg: Game | null = null;
init(mg: Game, _ticks: number) {
this.mg = mg;
if (mg.config().gameConfig().gameMode !== GameMode.Team) {
this.active = false;
}
}
tick(ticks: number) {
if (ticks % 10 !== 0) return;
if (this.mg === null) throw new Error("Not initialized");
const teamToTiles = new Map<Team, number>();
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) {
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;
}
}
if (crownTeam !== null) {
this.mg.addCrownTick(crownTeam, 10);
}
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+80 -1
View File
@@ -1,4 +1,8 @@
import { GameEvent } from "../EventBus";
import {
computeCompetitiveScores,
TeamRawMetrics,
} from "../game/CompetitiveScoring";
import {
ColoredTeams,
Execution,
@@ -18,6 +22,7 @@ export class WinCheckExecution implements Execution {
private active = true;
private mg: Game | null = null;
private knownAliveTeams: Set<Team> = new Set();
constructor() {}
@@ -34,10 +39,35 @@ export class WinCheckExecution implements Execution {
if (this.mg.config().gameConfig().gameMode === GameMode.FFA) {
this.checkWinnerFFA();
} else {
if (this.mg.config().gameConfig().competitiveScoring) {
this.trackTeamEliminations();
}
this.checkWinnerTeam();
}
}
private trackTeamEliminations(): void {
if (this.mg === null) return;
const currentAlive = new Set<Team>();
for (const player of this.mg.players()) {
const team = player.team();
if (team === null || team === ColoredTeams.Bot) continue;
if (player.numTilesOwned() > 0) {
currentAlive.add(team);
}
}
// Record teams that just died
for (const team of this.knownAliveTeams) {
if (!currentAlive.has(team)) {
this.mg.recordTeamElimination(team);
}
}
this.knownAliveTeams = currentAlive;
}
checkWinnerFFA(): void {
if (this.mg === null) throw new Error("Not initialized");
const sorted = this.mg
@@ -106,12 +136,61 @@ export class WinCheckExecution implements Execution {
timeElapsed - this.mg.config().gameConfig().maxTimerValue! * 60 >= 0)
) {
if (max[0] === ColoredTeams.Bot) return;
this.mg.setWinner(max[0], this.mg.stats().stats());
const scores = this.mg.config().gameConfig().competitiveScoring
? this.computeScores(teamToTiles, numTilesWithoutFallout)
: undefined;
this.mg.setWinner(max[0], this.mg.stats().stats(), scores);
console.log(`${max[0]} has won the game`);
this.active = false;
}
}
private computeScores(
teamToTiles: Map<Team, number>,
numTilesWithoutFallout: number,
) {
if (this.mg === null) return undefined;
const eliminationOrder = this.mg.teamEliminationOrder();
const allTeams = Array.from(teamToTiles.keys()).filter(
(t) => t !== ColoredTeams.Bot,
);
const totalGameTicks =
this.mg.ticks() - this.mg.config().numSpawnPhaseTurns();
// Rank surviving teams by current tiles (more tiles = better placement)
const survivingTeams = allTeams.filter(
(t) => !eliminationOrder.includes(t),
);
survivingTeams.sort(
(a, b) => (teamToTiles.get(b) ?? 0) - (teamToTiles.get(a) ?? 0),
);
const metrics: TeamRawMetrics[] = allTeams.map((team) => {
const peakTiles = this.mg!.teamPeakTiles(team);
const peakTilePercentage = (peakTiles / numTilesWithoutFallout) * 100;
const crownTicks = this.mg!.teamCrownTicks(team);
const crownRatio = totalGameTicks > 0 ? crownTicks / totalGameTicks : 0;
const elimIndex = eliminationOrder.indexOf(team);
let placementRank: number;
if (elimIndex === -1) {
// Surviving teams ranked by current tiles (best = highest rank)
const survivalIndex = survivingTeams.indexOf(team);
placementRank =
eliminationOrder.length + (survivingTeams.length - survivalIndex);
} else {
// First eliminated = 1, second = 2, etc.
placementRank = elimIndex + 1;
}
return { team, peakTilePercentage, crownRatio, placementRank };
});
return computeCompetitiveScores(metrics);
}
isActive(): boolean {
return this.active;
}
+85
View File
@@ -0,0 +1,85 @@
import { Team } from "./Game";
export interface TeamRawMetrics {
team: Team;
peakTilePercentage: number;
crownRatio: number;
placementRank: number;
}
export interface TeamScoreBreakdown {
team: Team;
maxTilesRank: number;
maxTilesPoints: number;
crownTimeRank: number;
crownTimePoints: number;
placementRank: number;
placementPoints: number;
totalScore: number;
}
function assignRanksDescending(values: number[]): number[] {
const indexed = values.map((v, i) => ({ v, i }));
indexed.sort((a, b) => b.v - a.v);
const ranks = new Array<number>(values.length);
let rank = 1;
for (let i = 0; i < indexed.length; i++) {
if (i > 0 && indexed[i].v < indexed[i - 1].v) {
rank = i + 1;
}
ranks[indexed[i].i] = rank;
}
return ranks;
}
function pointsForRank(rank: number, table: number[]): number {
if (rank < 1 || rank > table.length) return 0;
return table[rank - 1];
}
export function computeCompetitiveScores(
metrics: TeamRawMetrics[],
): TeamScoreBreakdown[] {
const maxTilesPointsTable = [60, 54, 48, 42, 36, 30, 24, 18, 12, 6];
const crownTimePointsTable = [30, 27, 24, 21, 18, 15, 12, 9, 6, 3];
const placementPointsTable = [10, 8, 6, 4, 2];
const maxTilesRanks = assignRanksDescending(
metrics.map((m) => m.peakTilePercentage),
);
const crownTimeRanks = assignRanksDescending(
metrics.map((m) => m.crownRatio),
);
// Placement rank: higher placementRank = survived longer = better
const placementRanks = assignRanksDescending(
metrics.map((m) => m.placementRank),
);
return metrics
.map((m, i) => {
const maxTilesRank = maxTilesRanks[i];
const crownTimeRank = crownTimeRanks[i];
const placementRank = placementRanks[i];
const maxTilesPoints = pointsForRank(maxTilesRank, maxTilesPointsTable);
const crownTimePoints = pointsForRank(
crownTimeRank,
crownTimePointsTable,
);
const placementPoints = pointsForRank(
placementRank,
placementPointsTable,
);
return {
team: m.team,
maxTilesRank,
maxTilesPoints,
crownTimeRank,
crownTimePoints,
placementRank,
placementPoints,
totalScore: maxTilesPoints + crownTimePoints + placementPoints,
};
})
.sort((a, b) => b.totalScore - a.totalScore);
}
+19 -1
View File
@@ -3,6 +3,7 @@ import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph";
import { PathFinder } from "../pathfinding/types";
import { AllPlayersStats, ClientID } from "../Schemas";
import { getClanTag } from "../Util";
import { TeamScoreBreakdown } from "./CompetitiveScoring";
import { GameMap, TileRef } from "./GameMap";
import {
GameUpdate,
@@ -784,7 +785,11 @@ export interface Game extends GameMap {
drainPackedTileUpdates(): Uint32Array;
recordMotionPlan(record: MotionPlanRecord): void;
drainPackedMotionPlans(): Uint32Array | null;
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void;
setWinner(
winner: Player | Team,
allPlayersStats: AllPlayersStats,
competitiveScores?: TeamScoreBreakdown[],
): void;
getWinner(): Player | Team | null;
config(): Config;
isPaused(): boolean;
@@ -854,6 +859,19 @@ export interface Game extends GameMap {
miniWaterGraph(): AbstractGraph | null;
getWaterComponent(tile: TileRef): number | null;
hasWaterComponent(tile: TileRef, component: number): boolean;
// Crown tracking (team-based)
crownTeam(): Team | null;
teamCrownTicks(team: Team): number;
addCrownTick(team: Team, amount: number): void;
// Peak tile tracking (team-based)
teamPeakTiles(team: Team): number;
updateTeamPeakTiles(team: Team, currentTiles: number): void;
// Elimination tracking (team-based)
teamEliminationOrder(): Team[];
recordTeamElimination(team: Team): void;
}
export interface PlayerActions {
+63 -1
View File
@@ -11,6 +11,7 @@ import { ATTACK_INDEX_SENT } from "../StatsSchemas";
import { simpleHash } from "../Util";
import { AllianceImpl } from "./AllianceImpl";
import { AllianceRequestImpl } from "./AllianceRequestImpl";
import { TeamScoreBreakdown } from "./CompetitiveScoring";
import {
Alliance,
AllianceRequest,
@@ -109,6 +110,9 @@ export class GameImpl implements Game {
private _isPaused: boolean = false;
private _winner: Player | Team | null = null;
private _teamCrownTicks: Map<Team, number> = new Map();
private _teamPeakTiles: Map<Team, number> = new Map();
private _teamEliminationOrder: Team[] = [];
private _miniWaterGraph: AbstractGraph | null = null;
private _miniWaterHPA: AStarWaterHierarchical | null = null;
private _teamGameSpawnAreas: TeamGameSpawnAreas | undefined;
@@ -806,12 +810,17 @@ export class GameImpl implements Game {
});
}
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void {
setWinner(
winner: Player | Team,
allPlayersStats: AllPlayersStats,
competitiveScores?: TeamScoreBreakdown[],
): void {
this._winner = winner;
this.addUpdate({
type: GameUpdateType.Win,
winner: this.makeWinner(winner),
allPlayersStats,
competitiveScores,
});
}
@@ -1245,6 +1254,59 @@ export class GameImpl implements Game {
gold,
});
}
crownTeam(): Team | null {
const teamToTiles = new Map<Team, number>();
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;
}
teamCrownTicks(team: Team): number {
return this._teamCrownTicks.get(team) ?? 0;
}
addCrownTick(team: Team, amount: number): void {
this._teamCrownTicks.set(
team,
(this._teamCrownTicks.get(team) ?? 0) + amount,
);
}
teamPeakTiles(team: Team): number {
return this._teamPeakTiles.get(team) ?? 0;
}
updateTeamPeakTiles(team: Team, currentTiles: number): void {
const prev = this._teamPeakTiles.get(team) ?? 0;
if (currentTiles > prev) {
this._teamPeakTiles.set(team, currentTiles);
}
}
teamEliminationOrder(): Team[] {
return this._teamEliminationOrder;
}
recordTeamElimination(team: Team): void {
if (!this._teamEliminationOrder.includes(team)) {
this._teamEliminationOrder.push(team);
}
}
}
// Or a more dynamic approach that will catch new enum values:
+2
View File
@@ -1,4 +1,5 @@
import { AllPlayersStats, ClientID, Winner } from "../Schemas";
import { TeamScoreBreakdown } from "./CompetitiveScoring";
import {
EmojiMessage,
GameUpdates,
@@ -264,6 +265,7 @@ export interface WinUpdate {
type: GameUpdateType.Win;
allPlayersStats: AllPlayersStats;
winner: Winner;
competitiveScores?: TeamScoreBreakdown[];
}
export interface HashUpdate {
+3
View File
@@ -157,6 +157,9 @@ export class GameServer {
if (gameConfig.startingGold !== undefined) {
this.gameConfig.startingGold = gameConfig.startingGold;
}
if (gameConfig.competitiveScoring !== undefined) {
this.gameConfig.competitiveScoring = gameConfig.competitiveScoring;
}
}
private isKicked(clientID: ClientID): boolean {