mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 06:53:02 +00:00
**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
174 lines
7.5 KiB
TypeScript
174 lines
7.5 KiB
TypeScript
import {
|
|
doomsdayClockDrain,
|
|
doomsdayClockRequiredTiles,
|
|
} from "../game/DoomsdayClock";
|
|
import {
|
|
Execution,
|
|
Game,
|
|
GameMode,
|
|
Player,
|
|
PlayerType,
|
|
Team,
|
|
UnitType,
|
|
} from "../game/Game";
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* A side below the bar is marked (inDoomsdayClock -> blinking skull on the client)
|
|
* and, after the warn window, every member bleeds an escalating percentage of
|
|
* their troops (and warship health) until the side recovers or reaches the floor
|
|
* (drainFloorPercent of each max). The drain cripples, it does not wipe: a doomed
|
|
* side settles at 5% of max, not zero, so a brief dip below the bar is
|
|
* recoverable. Climbing back above the bar clears the mark and stops the drain;
|
|
* the rising bar still forces a finish by squeezing territory.
|
|
*
|
|
* Deterministic: integer-only. The threshold is one floored integer ratio (see
|
|
* DoomsdayClock.ts) and the drain a floored percentage, no floating-point. Off
|
|
* unless enabled in the GameConfig. Runs once per second (every 10 ticks), like
|
|
* WinCheckExecution.
|
|
*/
|
|
export class DoomsdayClockExecution implements Execution {
|
|
private active = true;
|
|
private mg: Game | null = null;
|
|
|
|
init(mg: Game, ticks: number): void {
|
|
this.mg = mg;
|
|
}
|
|
|
|
tick(ticks: number): void {
|
|
if (ticks % 10 !== 0) return; // once per second
|
|
if (this.mg === null) throw new Error("Not initialized");
|
|
const mg = this.mg;
|
|
const cfg = mg.config().doomsdayClockConfig();
|
|
if (!cfg.enabled) return;
|
|
// Warships bleed on their OWN gentler start + higher ceiling, and (via the
|
|
// curve exponent passed to the drain below) a STEEP convex ramp: a ship
|
|
// caught when its side is first doomed lasts ~as long as troops, but a side
|
|
// that has been under the clock the full ramp loses ships in ~2s.
|
|
const warshipDrainCfg = {
|
|
...cfg,
|
|
drainStartPercent: cfg.warshipDrainStartPercent,
|
|
drainMaxPercent: cfg.warshipDrainMaxPercent,
|
|
};
|
|
|
|
const elapsed = mg.elapsedGameSeconds();
|
|
// Humans and Nations are subject to it; the small map bots are not (the
|
|
// !== Bot idiom used across the codebase). players() already returns only
|
|
// alive players.
|
|
const contenders = mg.players().filter((p) => p.type() !== PlayerType.Bot);
|
|
|
|
// The bar applies per side: each player in FFA, each whole team otherwise.
|
|
const ffa = mg.config().gameConfig().gameMode === GameMode.FFA;
|
|
const sides = this.sides(contenders, ffa);
|
|
|
|
// A winner is already inevitable (one side left): idle. Before the first
|
|
// wave the bar is 0, so nobody is flagged anyway.
|
|
if (sides.length < 2) {
|
|
for (const p of contenders) p.clearDoomsdayClock();
|
|
return;
|
|
}
|
|
|
|
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
|
|
// leader always keeps its army: the game can never freeze with every
|
|
// remaining side crippled at the floor, and the final wave squeezes out
|
|
// everyone but the leader -> a single winner. First side with the most tiles
|
|
// wins ties (deterministic: sides are built in a fixed order).
|
|
const sideTiles = sides.map((members) =>
|
|
members.reduce((sum, m) => sum + m.numTilesOwned(), 0),
|
|
);
|
|
let leaderIdx = 0;
|
|
for (let i = 1; i < sideTiles.length; i++) {
|
|
if (sideTiles[i] > sideTiles[leaderIdx]) leaderIdx = i;
|
|
}
|
|
|
|
for (let i = 0; i < sides.length; i++) {
|
|
const members = sides[i];
|
|
// 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) {
|
|
for (const m of members) {
|
|
m.enterDoomsdayClock();
|
|
const secondsUnder = Math.floor(m.doomsdayClockTicks() / 10);
|
|
if (secondsUnder >= cfg.warnSeconds) {
|
|
const secondsPastWarn = secondsUnder - cfg.warnSeconds;
|
|
// The drain floors at drainFloorPercent of each max: a doomed side is
|
|
// crippled to 5%, not wiped to zero, so recovery is possible if it
|
|
// climbs back above the bar. Clamp the removal to what sits above the
|
|
// floor (integer-only, so the lockstep sim stays deterministic).
|
|
const maxTroops = mg.config().maxTroops(m);
|
|
const troopFloor = Math.floor(
|
|
(maxTroops * cfg.drainFloorPercent) / 100,
|
|
);
|
|
const chunk = doomsdayClockDrain(maxTroops, secondsPastWarn, cfg);
|
|
m.removeTroops(
|
|
Math.min(chunk, Math.max(0, m.troops() - troopFloor)),
|
|
);
|
|
// The navy bleeds on the same ramp but toward warshipDrainCfg's far
|
|
// higher ceiling (see above), so a doomed side's fleet is battered
|
|
// fast at full attrition, down to the SAME floor (drainFloorPercent of
|
|
// each warship's veterancy-adjusted max health) rather than sunk. No
|
|
// attacker is passed, so any loss is environmental, never a credited
|
|
// kill (see UnitImpl.delete). Healing is suppressed for flagged owners
|
|
// in WarshipExecution.healWarship so the decay actually lands.
|
|
for (const ws of m.units(UnitType.Warship)) {
|
|
const shipFloor = Math.floor(
|
|
(ws.maxHealth() * cfg.drainFloorPercent) / 100,
|
|
);
|
|
const removable = Math.max(0, ws.health() - shipFloor);
|
|
if (removable <= 0) continue;
|
|
const dmg = doomsdayClockDrain(
|
|
ws.maxHealth(),
|
|
secondsPastWarn,
|
|
warshipDrainCfg,
|
|
cfg.warshipDrainCurveExponent,
|
|
);
|
|
ws.modifyHealth(-Math.min(dmg, removable));
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
for (const m of members) m.clearDoomsdayClock();
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Group contenders into sides: singletons in FFA, by team otherwise. */
|
|
private sides(contenders: Player[], ffa: boolean): Player[][] {
|
|
if (ffa) return contenders.map((p) => [p]);
|
|
const byTeam = new Map<Team, Player[]>();
|
|
for (const p of contenders) {
|
|
const team = p.team();
|
|
if (team === null) continue;
|
|
const members = byTeam.get(team);
|
|
if (members) members.push(p);
|
|
else byTeam.set(team, [p]);
|
|
}
|
|
return Array.from(byTeam.values());
|
|
}
|
|
|
|
isActive(): boolean {
|
|
return this.active;
|
|
}
|
|
|
|
activeDuringSpawnPhase(): boolean {
|
|
return false;
|
|
}
|
|
}
|