diff --git a/resources/competitive-rules.md b/resources/competitive-rules.md new file mode 100644 index 000000000..9c7aed694 --- /dev/null +++ b/resources/competitive-rules.md @@ -0,0 +1,183 @@ +# Competitive Scoring Rules + +This document explains how the competitive scoring system works in OpenFront team matches. It is intended for tournament hosts, players, and casters. + +--- + +## How to Enable + +In a **private lobby**, toggle **"Competitive Scoring"** in the options section. This option only appears when the game mode is set to Teams. + +--- + +## Overview + +Each match awards up to **100 points** per team, split across three categories: + +| Category | Max Points | What It Measures | +| --------------- | ---------- | ------------------------------------------ | +| Max Tiles | 60 | Peak map control during the match | +| Crown Time | 30 | How long your team held the most territory | +| Final Placement | 10 | How long your team survived | + +The team with the most total points wins the match in competitive scoring. + +--- + +## Category Breakdown + +### 1. Max Tiles (60 points) + +This is the **highest percentage of the map your team controlled at any point** during the match. It does not matter if you lose that territory later — only your peak matters. + +Teams are ranked by their peak tile percentage, and points are awarded by rank. + +### 2. Crown Time (30 points) + +The **Crown** belongs to whichever team currently controls the most tiles on the map. All members of the crowned team display a crown icon. + +Crown Time tracks how long your team held the crown during the match, as a ratio of total game time. A team that held the crown for half the match has a crown ratio of 50%. + +Teams are ranked by their crown ratio, and points are awarded by rank. + +**Attacking the crown team grants a 25% troop bonus**, encouraging teams to contest the leading team rather than expand passively. + +### 3. Final Placement (10 points) + +This is the order in which teams are eliminated. The last team standing gets the best placement. If your team is wiped out first, you get the worst placement. + +Only the top 5 teams receive placement points. + +--- + +## Point Tables + +**Max Tiles (Top 10)** + +| Rank | Points | +| ---- | ------ | +| 1st | 60 | +| 2nd | 54 | +| 3rd | 48 | +| 4th | 42 | +| 5th | 36 | +| 6th | 30 | +| 7th | 24 | +| 8th | 18 | +| 9th | 12 | +| 10th | 6 | + +**Crown Time (Top 10)** + +| Rank | Points | +| ---- | ------ | +| 1st | 30 | +| 2nd | 27 | +| 3rd | 24 | +| 4th | 21 | +| 5th | 18 | +| 6th | 15 | +| 7th | 12 | +| 8th | 9 | +| 9th | 6 | +| 10th | 3 | + +**Final Placement (Top 5)** + +| Rank | Points | +| ---- | ------ | +| 1st | 10 | +| 2nd | 8 | +| 3rd | 6 | +| 4th | 4 | +| 5th | 2 | + +--- + +## Tie-Breaking + +If two or more teams have the same value in a category (e.g., identical peak tile percentage), they share the better rank and both receive the same points. The next rank is skipped. + +**Example:** If two teams tie for 1st in Crown Time, both receive 30 points. The next team gets 3rd place (24 points), not 2nd. + +--- + +## How the Crown Works + +- The crown is assigned to the **team** with the highest total tile count (not individual players). +- **All members** of the crowned team display the crown icon, making the leading team highly visible. +- The crown updates in real time as territory changes hands. +- Crown time only counts during active gameplay (not during spawn phase). + +--- + +## In-Game UI + +### During the Match + +The **Team Stats panel** has three views you can cycle through: + +1. **Control** — Current tile %, gold, max troops, crown time +2. **Units** — Launchers, SAMs, warships, cities +3. **Competitive** — Current tile %, peak tile %, crown time + +### At Match End + +When competitive scoring is enabled, the **win screen** displays a score breakdown table showing each team's points in all three categories and their total score. + +--- + +## Example Scenario + +A Trios match with 4 teams ends with these results: + +| Team | Peak Tiles | Crown Ratio | Eliminated | +| ------ | ---------- | ----------- | -------------- | +| Red | 35% | 45% | Winner | +| Blue | 28% | 30% | 3rd eliminated | +| Teal | 22% | 20% | 2nd eliminated | +| Purple | 18% | 5% | 1st eliminated | + +**Scoring:** + +| Team | Tiles Pts | Crown Pts | Place Pts | Total | +| ------ | --------- | --------- | --------- | ------- | +| Red | 60 | 30 | 10 | **100** | +| Blue | 54 | 27 | 6 | **87** | +| Teal | 48 | 24 | 4 | **76** | +| Purple | 42 | 21 | 2 | **65** | + +Red dominated all categories. But consider a different scenario where Blue held the crown longer than Red: + +| Team | Peak Tiles | Crown Ratio | Eliminated | +| ------ | ---------- | ----------- | -------------- | +| Red | 35% | 15% | Winner | +| Blue | 28% | 50% | 3rd eliminated | +| Teal | 22% | 30% | 2nd eliminated | +| Purple | 18% | 5% | 1st eliminated | + +| Team | Tiles Pts | Crown Pts | Place Pts | Total | +| ------ | --------- | --------- | --------- | ------ | +| Red | 60 | 24 | 10 | **94** | +| Blue | 54 | 30 | 6 | **90** | +| Teal | 48 | 27 | 4 | **79** | +| Purple | 42 | 21 | 2 | **65** | + +Here Blue nearly catches Red despite losing the match, because Blue held the crown for much longer. This rewards sustained dominance, not just final snowball. + +--- + +## Why This System? + +The old system scored teams only on Max Tiles Owned (peak map control). This meant: + +- Early aggression was disproportionately rewarded +- Once a team peaked, the match scoring was effectively decided +- There was no incentive to contest the crown or coordinate attacks on the leading team +- Comebacks were strategically meaningless + +The multi-metric system fixes this by making three different skills matter: + +- **Max Tiles** rewards macro expansion and map control +- **Crown Time** rewards sustained dominance and encourages teams to contest the leader +- **Final Placement** rewards survival and makes late-game play meaningful diff --git a/resources/lang/en.json b/resources/lang/en.json index 66dac140d..981361068 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -408,6 +408,7 @@ "infinite_troops": "Infinite troops", "donate_troops": "Donate troops", "compact_map": "Compact Map", + "competitive_scoring": "Competitive Scoring", "enables_title": "Enable Settings", "player": "Player", "players": "Players", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 978fa12ac..a6bcd83eb 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -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, }, bubbles: true, diff --git a/src/client/graphics/PlayerIcons.ts b/src/client/graphics/PlayerIcons.ts index d5e86315a..706e25cc7 100644 --- a/src/client/graphics/PlayerIcons.ts +++ b/src/client/graphics/PlayerIcons.ts @@ -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(); + 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 diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index e23d4d609..ae8eef51a 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -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 diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index 22d3b341a..5d2099d09 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -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) { diff --git a/src/client/graphics/layers/TeamStats.ts b/src/client/graphics/layers/TeamStats.ts index 5878c5aab..1dc458b48 100644 --- a/src/client/graphics/layers/TeamStats.ts +++ b/src/client/graphics/layers/TeamStats.ts @@ -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 = 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; 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(); + 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, + 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 = {}; @@ -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` +
+ ${text} +
+ `; + + 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 %")} +
+ 👑 +
+ `; + } + } + + 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`
${text}
`; + + switch (this.viewMode) { + case "control": + return html` +
+ ${td(team.teamName)} ${td(team.totalScoreStr)} ${td(team.totalGold)} + ${td(team.totalMaxTroops)} +
+ `; + case "units": + return html` +
+ ${td(team.teamName)} ${td(team.totalLaunchers)} + ${td(team.totalSAMs)} ${td(team.totalWarShips)} + ${td(team.totalCities)} +
+ `; + case "competitive": + return html` +
+ ${td(team.teamName)} ${td(team.totalScoreStr)} + ${td(team.peakScoreStr)} ${td(formatCrownTime(team.crownSeconds))} +
+ `; + } + } + 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`
-
- ${translateText("leaderboard.team")} -
- ${this.showUnits - ? html` -
- ${translateText("leaderboard.launchers")} -
-
- ${translateText("leaderboard.sams")} -
-
- ${translateText("leaderboard.warships")} -
-
- ${translateText("leaderboard.cities")} -
- ` - : html` -
- ${translateText("leaderboard.owned")} -
-
- ${translateText("leaderboard.gold")} -
-
- ${translateText("leaderboard.maxtroops")} -
- `} + ${this.renderHeader()}
- ${this.teams.map((team) => - this.showUnits - ? html` -
-
- ${team.teamName} -
-
- ${team.totalLaunchers} -
-
- ${team.totalSAMs} -
-
- ${team.totalWarShips} -
-
- ${team.totalCities} -
-
- ` - : html` -
-
- ${team.teamName} -
-
- ${team.totalScoreStr} -
-
- ${team.totalGold} -
-
- ${team.totalMaxTroops} -
-
- `, - )} + ${teamsToRender.map((team) => this.renderRow(team))}
-
`; diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index 1479d09e0..3f186eda4 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -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 {

${this._title || ""}

- ${this.innerHtml()} + ${this.competitiveScores + ? this.renderCompetitiveScores() + : this.innerHtml()}
+

+ Competitive Scores +

+ + + + + + + + + + + + + ${this.competitiveScores.map( + (s, i) => html` + + + + + + + + + `, + )} + +
#TeamTilesCrownPlaceTotal
${i + 1}${s.team}${s.maxTilesPoints}${s.crownTimePoints}${s.placementPoints} + ${s.totalScore} +
+
+ `; + } + 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; diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 3f9fd20ce..48d0a1ab4 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -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( diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 86c74292d..161fba49a 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -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(); diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 6021fbc09..cd6721a23 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -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); diff --git a/src/core/execution/CrownTrackingExecution.ts b/src/core/execution/CrownTrackingExecution.ts new file mode 100644 index 000000000..4b0595afd --- /dev/null +++ b/src/core/execution/CrownTrackingExecution.ts @@ -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; + } +} diff --git a/src/core/execution/TeamMetricsExecution.ts b/src/core/execution/TeamMetricsExecution.ts new file mode 100644 index 000000000..33ddedb93 --- /dev/null +++ b/src/core/execution/TeamMetricsExecution.ts @@ -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(); + 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; + } +} diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index 700d14fad..3159cce8a 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -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 = 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(); + 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, + 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; } diff --git a/src/core/game/CompetitiveScoring.ts b/src/core/game/CompetitiveScoring.ts new file mode 100644 index 000000000..dcf58ee15 --- /dev/null +++ b/src/core/game/CompetitiveScoring.ts @@ -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(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); +} diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 3c6eb50a8..681788825 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -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 { diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index ab2a179f5..2779f3eb3 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -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 = new Map(); + private _teamPeakTiles: Map = 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(); + 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: diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index a85912bda..f3879a2d7 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -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 { diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index bb55c02c5..83659b58f 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -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 { diff --git a/startup.sh b/startup.sh index 3eba5480b..ca7eafc4f 100644 --- a/startup.sh +++ b/startup.sh @@ -86,7 +86,7 @@ export CLOUDFLARE_TUNNEL_TOKEN=${TUNNEL_TOKEN} # Start supervisord if [ "$DOMAIN" = openfront.dev ] && [ "$SUBDOMAIN" != main ]; then - exec timeout 18h /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf + exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf else exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf fi