mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-24 02:52:50 +00:00
Doomsday Clock: judge teams against the same bar as solo sides (#4624)
**Add approved & assigned issue number here:** Resolves #(to be filed — happy to open the matching issue; flagging directly since this is live in prod team lobbies) ## Description: **Bug.** In Team mode the Doomsday Clock threshold is scaled by the team's *alive* headcount (`base × members.length`, capped at the whole map). In real team lobbies this breaks the mechanic: - With ~16 players per team, wave 1 already demands `4 % × 16 = 64 %+` of the map per team while all humans combined hold ~35 % (bots own the rest). Every non-leading team is skulled ~1 minute into wave 1 and drained to the 5 % floor ~90 s later — even on **slow**, whose squeeze is designed to run to 45:00. The game is decided by whoever leads at grace-end. - The bar/forecast **drops when a teammate dies** (players see "will rise to 68 %" → "44 %" mid-game), and disconnected-but-alive teammates silently inflate it. - Past the 100 % cap, the per-capita scaling stops meaning anything anyway. **Evidence.** Prod game `JEUieLzK` (`Team, playerTeams: 4, 69 players, doomsdayClock slow, bots: 400, no timer`): ended **16:47** via the 95 % team-domination check. From the archived record: 25 deaths before 10:00, only 8 after — the drain cripples rather than kills, so the collapse is invisible in death stats but decisive in outcome. Reconstruction with the real schedule + drain constants: every non-leading team skulled ~11:00–11:30 and pinned at the floor by ~12:30; the crown-exempt leader rolled to 95 % by 16:47. **Why flat is correct.** The wave levels encode a viable-**side** count — `floor(100 / wave%)` sides fit above the bar (25 → 11 → 6 → 3 → 2 → 1). Elimination and victory happen at side granularity (a team is out when the whole team is out; the winner is a team), so the slots must be counted in sides. A team now faces exactly the bar a solo player faces, judged on its combined territory — and each speed preset means the same thing in teams as it does in FFA. **Changes:** - `DoomsdayClock.ts`: remove `doomsdayClockSideRequiredTiles`; the sim and the HUD both use `doomsdayClockRequiredTiles` (hoisted out of the per-side loop — it no longer varies per side). - `DoomsdayClockExecution.ts`: flat per-side bar; comments updated. - `DoomsdayClockPanel.ts`: drop `scalePct`/headcount from the readout — the zone forecast becomes one universal, monotonic number for every player (fixes the 68 % → 44 % whipsaw). `sideStats` → `sideTiles`. - Tests: headcount-scaling expectations replaced with flat-bar + death-invariance regression tests. (History note: an earlier commit restricted team lobbies to normal/fast presets; it was reverted in-branch — with the flat bar the presets carry the same meaning as in FFA, so the full rotation stays. Squash-merge leaves the fix only.) FFA behaviour is unchanged throughout (sides of size 1: flat ≡ current). Deterministic integer math untouched (strictly fewer ops). `npm test` fully green. Rule comparison on `JEUieLzK`'s shape (real death ticks + constants; modeled share trajectories): | rule | trailing teams skulled | all non-leaders at 5 % floor | |---|---|---| | current (× alive) | 11:01–11:27 | **12:34** ← matches the real 16:47 blowout | | flat per-side bar | 26:01–30:42 | 31:49 (late-game backstop, as designed) | ## Please complete the following: - [ ] I have added screenshots for all UI updates — _no layout/element changes; the existing readout shows unscaled values (numbers only)_ - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file — _no new strings; existing `doomsday_clock.*` keys reused with the same params_ - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: zixer
This commit is contained in:
@@ -3,7 +3,7 @@ import { customElement, property } from "lit/decorators.js";
|
||||
import { assetUrl } from "../../core/AssetUrls";
|
||||
import {
|
||||
doomsdayClockDrain,
|
||||
doomsdayClockSideRequiredTiles,
|
||||
doomsdayClockRequiredTiles,
|
||||
doomsdayClockWaveState,
|
||||
} from "../../core/game/DoomsdayClock";
|
||||
import { GameMode, PlayerType, Team } from "../../core/game/Game";
|
||||
@@ -42,26 +42,20 @@ export class DoomsdayClockPanel extends LitElement {
|
||||
}
|
||||
|
||||
// The player's "side" (matching the sim): themselves in FFA, their whole team
|
||||
// otherwise. Returns the combined tiles and the headcount (the sim scales the
|
||||
// threshold by headcount, so the HUD needs it too).
|
||||
private sideStats(me: ReturnType<GameView["myPlayer"]>): {
|
||||
tiles: number;
|
||||
size: number;
|
||||
} {
|
||||
if (!me) return { tiles: 0, size: 1 };
|
||||
// otherwise. The sim judges a side on its combined territory against the same
|
||||
// bar as a solo player, so the HUD only needs the tile sum.
|
||||
private sideTiles(me: ReturnType<GameView["myPlayer"]>): number {
|
||||
if (!me) return 0;
|
||||
const ffa = this.game.config().gameConfig().gameMode === GameMode.FFA;
|
||||
const myTeam = me.team();
|
||||
if (ffa || myTeam === null) return { tiles: me.numTilesOwned(), size: 1 };
|
||||
const mates = this.game
|
||||
if (ffa || myTeam === null) return me.numTilesOwned();
|
||||
return this.game
|
||||
.playerViews()
|
||||
.filter(
|
||||
(p) =>
|
||||
p.team() === myTeam && p.isAlive() && p.type() !== PlayerType.Bot,
|
||||
);
|
||||
return {
|
||||
tiles: mates.reduce((sum, p) => sum + p.numTilesOwned(), 0),
|
||||
size: mates.length,
|
||||
};
|
||||
)
|
||||
.reduce((sum, p) => sum + p.numTilesOwned(), 0);
|
||||
}
|
||||
|
||||
// Localized team name (e.g. "Red"), matching TeamStats; falls back to the raw
|
||||
@@ -91,17 +85,11 @@ export class DoomsdayClockPanel extends LitElement {
|
||||
const elapsed = Math.floor(this.game.elapsedGameSeconds());
|
||||
const land = this.game.numLandTiles() - this.game.numTilesWithFallout();
|
||||
const myTeam = me?.team() ?? null;
|
||||
const { tiles: yourTiles, size: mySize } = this.sideStats(me);
|
||||
// Threshold is scaled by the side's headcount (same as the sim).
|
||||
const requiredTiles = doomsdayClockSideRequiredTiles(
|
||||
sd.speed,
|
||||
land,
|
||||
elapsed,
|
||||
mySize,
|
||||
);
|
||||
const yourTiles = this.sideTiles(me);
|
||||
// Same bar for every side, solo or team (same as the sim) — so the zone
|
||||
// readout is one universal, monotonic number for every player.
|
||||
const requiredTiles = doomsdayClockRequiredTiles(sd.speed, land, elapsed);
|
||||
const wave = doomsdayClockWaveState(sd.speed, elapsed);
|
||||
// Wave readout percentages scale by headcount too (capped at the whole map).
|
||||
const scalePct = (p: number) => Math.min(100, p * mySize);
|
||||
// Match the sim: no land -> no bar, no percentages (avoid div-by-zero / >100%).
|
||||
const requiredPct = land > 0 ? (requiredTiles / land) * 100 : 0;
|
||||
const yourPct = land > 0 ? (yourTiles / land) * 100 : 0;
|
||||
@@ -119,15 +107,15 @@ export class DoomsdayClockPanel extends LitElement {
|
||||
// AND while collapsing, so you can still see the bar rising as you bleed.
|
||||
const zoneDetail = wave.done
|
||||
? translateText("doomsday_clock.final", {
|
||||
pct: scalePct(wave.currentPercent),
|
||||
pct: wave.currentPercent,
|
||||
})
|
||||
: wave.growing
|
||||
? translateText("doomsday_clock.growing", {
|
||||
pct: scalePct(wave.targetPercent),
|
||||
pct: wave.targetPercent,
|
||||
time: this.secondsToHms(wave.secondsToTarget),
|
||||
})
|
||||
: translateText("doomsday_clock.next_wave", {
|
||||
pct: scalePct(wave.targetPercent),
|
||||
pct: wave.targetPercent,
|
||||
time: this.secondsToHms(wave.secondsToNextGrowth),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
doomsdayClockDrain,
|
||||
doomsdayClockSideRequiredTiles,
|
||||
doomsdayClockRequiredTiles,
|
||||
} from "../game/DoomsdayClock";
|
||||
import {
|
||||
Execution,
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
* Doomsday Clock (anti-stall). Once armed, every side must hold a rising
|
||||
* share of the whole map: each player in FFA, each whole team in team modes (so
|
||||
* a team is judged on its combined territory and every member shares the fate).
|
||||
* The bar is the SAME for every side regardless of headcount — the clock shrinks
|
||||
* the number of viable SIDES, and sides are the unit of elimination/victory, so
|
||||
* a team faces exactly the bar a solo player faces. (It was previously scaled by
|
||||
* member count, which saturated at "hold the whole map" right after the grace in
|
||||
* real team lobbies and dropped whenever a teammate died — deciding games at
|
||||
* ~minute 12 on the slow preset instead of acting as a late-game backstop.)
|
||||
* The bar rises in discrete waves (battle-royale zone), stepping up to each
|
||||
* wave's level (chosen by the speed preset, see DoomsdayClock.ts) and holding. As
|
||||
* it rises the bottom is cut, which forces consolidation and guarantees a finish.
|
||||
@@ -75,6 +81,8 @@ export class DoomsdayClockExecution implements Execution {
|
||||
}
|
||||
|
||||
const land = mg.numLandTiles() - mg.numTilesWithFallout();
|
||||
// One bar for every side this tick (solo or team — headcount never scales it).
|
||||
const required = doomsdayClockRequiredTiles(cfg.speed, land, elapsed);
|
||||
|
||||
// The leading side (the crown holder in FFA, the top team otherwise) is
|
||||
// never doomed. Doomsday Clock culls the challengers toward the leader, so the
|
||||
@@ -92,14 +100,6 @@ export class DoomsdayClockExecution implements Execution {
|
||||
|
||||
for (let i = 0; i < sides.length; i++) {
|
||||
const members = sides[i];
|
||||
// Threshold scales with the side's headcount: a team of N must hold N× a
|
||||
// solo player's share (FFA sides are size 1, unscaled).
|
||||
const required = doomsdayClockSideRequiredTiles(
|
||||
cfg.speed,
|
||||
land,
|
||||
elapsed,
|
||||
members.length,
|
||||
);
|
||||
// A non-leading side below the bar skulls and drains every member; the
|
||||
// leader (and any side above the bar) clears them all.
|
||||
if (i !== leaderIdx && sideTiles[i] < required) {
|
||||
|
||||
@@ -107,8 +107,15 @@ function requiredBasisPoints(
|
||||
}
|
||||
|
||||
/**
|
||||
* Base minimum tiles one player must own at `elapsed` game seconds. One floored
|
||||
* integer ratio, so every client agrees.
|
||||
* Minimum tiles one SIDE must hold at `elapsed` game seconds — a solo player in
|
||||
* FFA, a whole team's combined territory in team modes. The bar is identical
|
||||
* for every side regardless of headcount: the clock's job is to shrink the
|
||||
* number of viable sides (floor(100 / wave%) can be above the bar at once,
|
||||
* down to 1 at the final 55% wave), and elimination happens at side
|
||||
* granularity, so scaling by member count only distorts it — a headcount-
|
||||
* scaled bar saturated at "hold the whole map" for big teams the moment the
|
||||
* grace ended, and dropped whenever a teammate died. One floored integer
|
||||
* ratio, so every client agrees.
|
||||
*/
|
||||
export function doomsdayClockRequiredTiles(
|
||||
speed: DoomsdayClockSpeed,
|
||||
@@ -119,22 +126,6 @@ export function doomsdayClockRequiredTiles(
|
||||
return Math.floor((requiredBasisPoints(speed, elapsed) * land) / 10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Threshold a whole side must hold: the base per-player share scaled by the
|
||||
* side's headcount, so a team of N must hold N× what a solo player holds (FFA
|
||||
* sides are size 1, i.e. unscaled). Capped at the whole map. Shared by the sim
|
||||
* and the HUD so the two always agree.
|
||||
*/
|
||||
export function doomsdayClockSideRequiredTiles(
|
||||
speed: DoomsdayClockSpeed,
|
||||
land: number,
|
||||
elapsed: number,
|
||||
sideSize: number,
|
||||
): number {
|
||||
const base = doomsdayClockRequiredTiles(speed, land, elapsed);
|
||||
return Math.min(land, base * Math.max(1, sideSize));
|
||||
}
|
||||
|
||||
export interface DoomsdayClockWaveState {
|
||||
/** Required share right now, as a percent of the map (ramps during a wave). */
|
||||
currentPercent: number;
|
||||
|
||||
Reference in New Issue
Block a user