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
+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;