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:
Zixer1
2026-07-17 08:46:47 -07:00
committed by GitHub
parent 943740de32
commit 95840a2074
4 changed files with 76 additions and 87 deletions
+16 -28
View File
@@ -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),
});
+9 -9
View File
@@ -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) {
+9 -18
View File
@@ -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;
+42 -32
View File
@@ -3,7 +3,6 @@ import { PlayerExecution } from "../src/core/execution/PlayerExecution";
import {
doomsdayClockDrain,
doomsdayClockRequiredTiles,
doomsdayClockSideRequiredTiles,
doomsdayClockWaveState,
} from "../src/core/game/DoomsdayClock";
import {
@@ -410,13 +409,15 @@ describe("DoomsdayClockExecution (warship decay)", () => {
});
// ---------------------------------------------------------------------------
// Team modes: the bar applies to a whole team's combined territory, and every
// member shares the fate (skull + drain together).
// Team modes: the bar applies to a whole team's combined territory — the SAME
// bar a solo side faces, regardless of headcount — and every member shares the
// fate (skull + drain together).
// ---------------------------------------------------------------------------
describe("DoomsdayClockExecution (teams)", () => {
function teamGame(teams: { team: string; tiles: number[] }[]) {
// base bar 200 @ land 1000; a team's threshold = 200 x its member count.
// WAVE_TICK sits in the veryfast 26% hold @ land 1000 -> every side's bar
// is 260, team or solo.
const game = new FakeGame(1000, sdConfig(), []);
game.gameMode = GameMode.Team;
const players: FakePlayer[] = [];
@@ -432,8 +433,8 @@ describe("DoomsdayClockExecution (teams)", () => {
}
it("judges a team on combined territory and skulls every member when below", () => {
// Both teams size 2 -> threshold 200x2=400. Red 250+250=500 safe;
// Blue 50+50=100 below -> both Blue skulled.
// Bar 260. Red 250+250=500 above (and the leader); Blue 50+50=100 below
// -> both Blue members skulled together.
const { game, players } = teamGame([
{ team: "Red", tiles: [250, 250] },
{ team: "Blue", tiles: [50, 50] },
@@ -452,8 +453,8 @@ describe("DoomsdayClockExecution (teams)", () => {
});
it("spares a tiny member whose team is collectively above the bar", () => {
// Size 2 -> threshold 400. Red 400+40=440 -> safe, so the 40-tile member
// is NOT skulled.
// Bar 260. Red 400+40=440 above -> safe, so the 40-tile member is NOT
// skulled even though 40 is far below the bar on its own.
const { game, players } = teamGame([
{ team: "Red", tiles: [400, 40] },
{ team: "Blue", tiles: [50, 50] },
@@ -465,20 +466,44 @@ describe("DoomsdayClockExecution (teams)", () => {
expect(blue1.inDoomsdayClock()).toBe(true);
});
it("scales the threshold by team size (a bigger team must hold more)", () => {
// base bar 200. Red is 3 members -> threshold 600; Blue is 1 -> threshold 200.
// Blue leads on tiles (crown-exempt), so Red is squeezed purely by its size.
it("does NOT scale the bar by headcount (a team faces the same bar as a solo)", () => {
// Bar 260 for every side. Red (2 members, 300 combined) is safe — the old
// headcount-scaled rule would have demanded 260x2=520 and skulled them.
// Green (3 members, 240 combined) is below the SOLO bar -> skulled: being
// three players earns no leniency either.
const { game, players } = teamGame([
{ team: "Red", tiles: [200, 200, 100] }, // 500 combined, < 600, not leader
{ team: "Blue", tiles: [700] }, // leader, and 700 >= 200 -> safe
{ team: "Blue", tiles: [700] }, // leader
{ team: "Red", tiles: [150, 150] }, // 300 >= 260 -> safe
{ team: "Green", tiles: [100, 80, 60] }, // 240 < 260 -> skulled
]);
const [red1, red2, red3, blue1] = players;
const [blue1, red1, red2, green1, green2, green3] = players;
const exec = makeExec(game);
runAt(exec, game, WAVE_TICK);
expect(red1.inDoomsdayClock()).toBe(true); // 500 < 200x3
expect(red2.inDoomsdayClock()).toBe(true);
expect(red3.inDoomsdayClock()).toBe(true);
expect(blue1.inDoomsdayClock()).toBe(false); // leader
expect(red1.inDoomsdayClock()).toBe(false);
expect(red2.inDoomsdayClock()).toBe(false);
expect(green1.inDoomsdayClock()).toBe(true);
expect(green2.inDoomsdayClock()).toBe(true);
expect(green3.inDoomsdayClock()).toBe(true);
});
it("keeps the bar unchanged when a teammate dies", () => {
// Bar 260. Red holds 300+5: safe before AND after losing the 5-tile member
// (still 300 >= 260). The old rule moved the bar with the alive headcount:
// 520 while both lived (skulled), 260 after the death (saved by dying) —
// deaths must never move the bar.
const { game, players } = teamGame([
{ team: "Blue", tiles: [700] }, // leader
{ team: "Red", tiles: [300, 5] },
]);
const [, red1, red2] = players;
const exec = makeExec(game);
runAt(exec, game, WAVE_TICK);
expect(red1.inDoomsdayClock()).toBe(false); // 305 >= 260 (old rule: < 520)
expect(red2.inDoomsdayClock()).toBe(false);
red2.kill();
runAt(exec, game, WAVE_TICK + 10);
expect(red1.inDoomsdayClock()).toBe(false); // 300 >= 260, bar unmoved
});
it("idles when only one team remains", () => {
@@ -529,21 +554,6 @@ describe("doomsdayClockRequiredTiles (ramping waves)", () => {
});
});
describe("doomsdayClockSideRequiredTiles (headcount scaling)", () => {
const land = 10000;
it("scales the base share by side size and caps at the whole map", () => {
// veryfast at 800s sits in the 26% hold -> base 2600 tiles.
expect(doomsdayClockRequiredTiles("veryfast", land, 800)).toBe(2600);
expect(doomsdayClockSideRequiredTiles("veryfast", land, 800, 1)).toBe(2600); // solo
expect(doomsdayClockSideRequiredTiles("veryfast", land, 800, 2)).toBe(5200); // 2x
expect(doomsdayClockSideRequiredTiles("veryfast", land, 800, 4)).toBe(
10000,
); // 10400 capped at the map
expect(doomsdayClockSideRequiredTiles("veryfast", land, 800, 0)).toBe(2600); // min size 1
});
});
describe("doomsdayClockWaveState", () => {
it("reports the live share and target while ramping", () => {
const s = doomsdayClockWaveState("normal", 704); // mid the first ramp (0->4%)