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
+183
View File
@@ -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
+1
View File
@@ -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",
+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 {
+1 -1
View File
@@ -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