From e2f37bb41d49e0ef3cf0598f179191618825008c Mon Sep 17 00:00:00 2001 From: Zixer1 <99333209+Zixer1@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:46:47 -0400 Subject: [PATCH] Doomsday Clock: judge teams against the same bar as solo sides (#4624) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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 --- src/client/components/DoomsdayClockPanel.ts | 44 +++++------- src/core/execution/DoomsdayClockExecution.ts | 18 ++--- src/core/game/DoomsdayClock.ts | 27 +++---- tests/DoomsdayClockExecution.test.ts | 74 +++++++++++--------- 4 files changed, 76 insertions(+), 87 deletions(-) diff --git a/src/client/components/DoomsdayClockPanel.ts b/src/client/components/DoomsdayClockPanel.ts index 4e1c6d6d2..99621854c 100644 --- a/src/client/components/DoomsdayClockPanel.ts +++ b/src/client/components/DoomsdayClockPanel.ts @@ -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): { - 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): 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), }); diff --git a/src/core/execution/DoomsdayClockExecution.ts b/src/core/execution/DoomsdayClockExecution.ts index 63692cbec..fb3efe4ae 100644 --- a/src/core/execution/DoomsdayClockExecution.ts +++ b/src/core/execution/DoomsdayClockExecution.ts @@ -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) { diff --git a/src/core/game/DoomsdayClock.ts b/src/core/game/DoomsdayClock.ts index 1cc64a24b..322c137d8 100644 --- a/src/core/game/DoomsdayClock.ts +++ b/src/core/game/DoomsdayClock.ts @@ -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; diff --git a/tests/DoomsdayClockExecution.test.ts b/tests/DoomsdayClockExecution.test.ts index 1a6d1d851..7a47b1359 100644 --- a/tests/DoomsdayClockExecution.test.ts +++ b/tests/DoomsdayClockExecution.test.ts @@ -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%)