mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 08:44:10 +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,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%)
|
||||
|
||||
Reference in New Issue
Block a user