mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
compet
This commit is contained in:
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user