mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 12:15:25 +00:00
feat(doomsday-clock): battle-royale style zone gamemode (#4469)
Resolves Issue #4463 ## Description: An optional game mode that (almost) guarantees a finish instead of letting late-game stalemates drag on. Originally called sudden death, renamed to Doomsday clock Once enabled, every side (each player in FFA, each whole team in team modes) must hold a rising share of the map. A side below the bar is skulled; after a short warn its troops bleed to zero, forcing consolidation to a winner. ### How it works - **Rising zone:** a grace period, then the required share ramps up linearly to each level with 30s pauses between (a battle-royale "zone"). Levels track the ofstats FFA territory median (3/5/10/20/30%). - **Four speed presets** (slow / normal / fast / very fast) change only the pace: normal ends ~30 min, very fast ~15. - **Troop decay:** a linear ramp as a % of max capacity, ~50s from caught to zero (10s warn + ~50s ≈ 1 min total). - **UI:** a HUD panel (live share vs target, wave/decay countdowns, red/orange cues) and an on-map skull above flagged players (blinks in danger, steady while draining). ### Notes for review - Off by default; no effect on existing games. However, as discussed we can add it to the modifier pool for public games to see how popular the gamemode is vs normal play. - Sim is deterministic (integer-only, in `src/core`), covered by unit + integration tests. - One-line addition to `GameServer.updateGameConfig` so the setting survives the host → server → client round-trip. - Status is packed into the existing name-pass data slot (`pd4.w`: 0/1/2 = none/danger/draining); the skull is composited into the icon atlas at load. ### Testing `npm test`, `npm run lint`, `npx prettier --check .`, `npm run build-prod` all pass. ### UI: <img width="243" height="100" alt="Image" src="https://github.com/user-attachments/assets/c4c9eeb0-4feb-437d-9aac-b2786a841b74" /> Dropdown between slow, normal, fast, very fast Before zone: <img width="302" height="175" alt="Image" src="https://github.com/user-attachments/assets/7359a1ea-4951-446d-a23c-0711fe06cc5d" /> Zone started, player not affected the pannel also blinks orange for 10s: <img width="297" height="175" alt="Image" src="https://github.com/user-attachments/assets/fcc565a5-d5d0-47a7-97ea-d0ba9d9ad899" /> Player affected, grace period (Danger): <img width="314" height="170" alt="Image" src="https://github.com/user-attachments/assets/ff96d21e-96f3-4ef9-8190-48eecc7aac0f" /> Skull icon blinking over player (everyone sees it) - older screenshot, the clipping has been fixed <img width="462" height="145" alt="Image" src="https://github.com/user-attachments/assets/53899211-33b1-40e1-83f2-77f2096f0cad" /> Player affected, grace period ended (Draining): <img width="360" height="159" alt="Image" src="https://github.com/user-attachments/assets/4b226d57-da4d-4866-ab5f-db48e4ed1ea2" /> Skull icon no longer blinking, everyone can see you are in a state of decay, and troops are draining: <img width="732" height="146" alt="image" src="https://github.com/user-attachments/assets/cd10fedb-6e87-4dfc-9fbf-55d3945a7901" /> Skull is visible like alliances icon also on player tab <img width="558" height="81" alt="Image" src="https://github.com/user-attachments/assets/6acdbe91-bdd0-40c7-942b-3990d4dae87f" /> (just UI example, best way to see it is to hop on a solo game and play against AI) ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [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:
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
doomsdayClockDrain,
|
||||
doomsdayClockSideRequiredTiles,
|
||||
} from "../game/DoomsdayClock";
|
||||
import {
|
||||
Execution,
|
||||
Game,
|
||||
GameMode,
|
||||
Player,
|
||||
PlayerType,
|
||||
Team,
|
||||
} 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 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 until the side recovers or hits zero. Climbing back above the bar
|
||||
* clears the mark and stops the drain.
|
||||
*
|
||||
* 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;
|
||||
|
||||
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();
|
||||
|
||||
// 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 bled to zero, 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];
|
||||
// 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) {
|
||||
for (const m of members) {
|
||||
m.enterDoomsdayClock();
|
||||
const secondsUnder = Math.floor(m.doomsdayClockTicks() / 10);
|
||||
if (secondsUnder >= cfg.warnSeconds) {
|
||||
const chunk = doomsdayClockDrain(
|
||||
mg.config().maxTroops(m),
|
||||
secondsUnder - cfg.warnSeconds,
|
||||
cfg,
|
||||
);
|
||||
m.removeTroops(chunk); // caps at current troops
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user