mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 17:05:12 +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:
@@ -1,5 +1,6 @@
|
||||
import { placeName, placeSpawnName } from "../client/hud/NameBoxCalculator";
|
||||
import { Config } from "./configuration/Config";
|
||||
import { DoomsdayClockExecution } from "./execution/DoomsdayClockExecution";
|
||||
import { Executor } from "./execution/ExecutionManager";
|
||||
import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterExecution";
|
||||
import { SpawnTimerExecution } from "./execution/SpawnTimerExecution";
|
||||
@@ -112,6 +113,9 @@ export class GameRunner {
|
||||
);
|
||||
}
|
||||
this.game.addExecution(new WinCheckExecution());
|
||||
if (this.game.config().doomsdayClockConfig().enabled) {
|
||||
this.game.addExecution(new DoomsdayClockExecution());
|
||||
}
|
||||
if (!this.game.config().isUnitDisabled(UnitType.Factory)) {
|
||||
this.game.addExecution(
|
||||
new RecomputeRailClusterExecution(this.game.railNetwork()),
|
||||
|
||||
@@ -249,6 +249,16 @@ const TeamCountConfigSchema = z.union([
|
||||
]);
|
||||
export type TeamCountConfig = z.infer<typeof TeamCountConfigSchema>;
|
||||
|
||||
// Doomsday Clock (anti-stall). Below a rising share of the map a player (or, in
|
||||
// team modes, their whole team) gets skulled and their troops drain to zero. The
|
||||
// required share rises in discrete waves per the `speed` preset (see
|
||||
// DoomsdayClock.ts). Only `enabled` and `speed` are wire-configurable; the
|
||||
// drain/warn tuning lives in DOOMSDAY_CLOCK_DEFAULTS (Config.ts).
|
||||
export const DoomsdayClockConfigSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
speed: z.enum(["slow", "normal", "fast", "veryfast"]).optional(),
|
||||
});
|
||||
|
||||
export const GameConfigSchema = z.object({
|
||||
gameMap: z.enum(GameMapType),
|
||||
difficulty: z.enum(Difficulty),
|
||||
@@ -258,6 +268,7 @@ export const GameConfigSchema = z.object({
|
||||
gameMode: z.enum(GameMode),
|
||||
rankedType: z.enum(RankedType).optional(), // Only set for ranked games.
|
||||
gameMapSize: z.enum(GameMapSize),
|
||||
doomsdayClock: DoomsdayClockConfigSchema.optional(),
|
||||
publicGameModifiers: z
|
||||
.object({
|
||||
isCompact: z.boolean().optional(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { PlayerView } from "../../client/view";
|
||||
import { AssetManifest } from "../AssetUrls";
|
||||
import { DoomsdayClockSpeed } from "../game/DoomsdayClock";
|
||||
import {
|
||||
Difficulty,
|
||||
Game,
|
||||
@@ -80,6 +81,21 @@ export const JwksSchema = z.object({
|
||||
/** SAM launcher construction duration in ticks (non-instant-build). */
|
||||
export const SAM_CONSTRUCTION_TICKS = 30 * 10;
|
||||
|
||||
// Doomsday Clock tunables (anti-stall). Off unless enabled in GameConfig.
|
||||
// Times in seconds. The required map share rises in waves (levels + times in
|
||||
// DoomsdayClock.ts, chosen by `speed`). A side caught below the bar gets a
|
||||
// warnSeconds cooldown ("Danger, decay in Xs"), then troops bleed to zero: the
|
||||
// warn (10s) + the linear drain (~55s from full troops, sooner with fewer troops
|
||||
// or a shrinking territory) make ~1 minute from caught to wiped out.
|
||||
const DOOMSDAY_CLOCK_DEFAULTS = {
|
||||
enabled: false,
|
||||
speed: "normal" as DoomsdayClockSpeed,
|
||||
warnSeconds: 10, // cooldown before decay after the bar catches you
|
||||
drainStartPercent: 2, // starts bleeding at once (already beats troop income)
|
||||
drainMaxPercent: 6,
|
||||
drainRampSeconds: 50, // ramps LINEARLY to the max over this long
|
||||
};
|
||||
|
||||
export class Config {
|
||||
private unitInfoCache = new Map<UnitType, UnitInfo>();
|
||||
constructor(
|
||||
@@ -101,6 +117,21 @@ export class Config {
|
||||
traitorDuration(): number {
|
||||
return 30 * 10; // 30 seconds
|
||||
}
|
||||
|
||||
// Doomsday Clock config, resolved against defaults. One read per tick.
|
||||
doomsdayClockConfig(): typeof DOOMSDAY_CLOCK_DEFAULTS {
|
||||
const c = this._gameConfig.doomsdayClock;
|
||||
const d = DOOMSDAY_CLOCK_DEFAULTS;
|
||||
return {
|
||||
enabled: c?.enabled ?? d.enabled,
|
||||
speed: c?.speed ?? d.speed,
|
||||
// Drain/warn tuning is internal (not wire-configurable): always defaults.
|
||||
warnSeconds: d.warnSeconds,
|
||||
drainStartPercent: d.drainStartPercent,
|
||||
drainMaxPercent: d.drainMaxPercent,
|
||||
drainRampSeconds: d.drainRampSeconds,
|
||||
};
|
||||
}
|
||||
spawnImmunityDuration(): Tick {
|
||||
return (
|
||||
this._gameConfig.spawnImmunityDuration ?? DEFAULT_SPAWN_IMMUNITY_TICKS
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Doomsday Clock threshold math, shared by the authoritative sim
|
||||
* (DoomsdayClockExecution) and the client HUD readout so the two always agree.
|
||||
*
|
||||
* The required share of the map rises in WAVES (a battle-royale zone): one flat
|
||||
* grace at the very start, then each wave grows the share up LINEARLY over
|
||||
* rampSeconds to its level, followed by a flat pauseSeconds hold before the next
|
||||
* wave. So the bar climbs smoothly and briefly rests, it never jumps. Levels
|
||||
* track the ofstats FFA territory median and are the same for every preset; the
|
||||
* presets only change the pace (slower or faster). A side below the bar gets a
|
||||
* warn countdown, then bleeds troops. Integer-only and floored, deterministic.
|
||||
*/
|
||||
|
||||
export type DoomsdayClockSpeed = "slow" | "normal" | "fast" | "veryfast";
|
||||
|
||||
/** In selector order. */
|
||||
export const DOOMSDAY_CLOCK_SPEEDS: DoomsdayClockSpeed[] = [
|
||||
"slow",
|
||||
"normal",
|
||||
"fast",
|
||||
"veryfast",
|
||||
];
|
||||
|
||||
interface WaveSchedule {
|
||||
/** Flat 0% for this long at the very start (the one grace period). */
|
||||
graceSeconds: number;
|
||||
/** Each wave grows its share up linearly over this long. */
|
||||
rampSeconds: number;
|
||||
/** Flat hold after each ramp before the next one starts. */
|
||||
pauseSeconds: number;
|
||||
/** Share (basis points, 100 = 1%) reached at the end of each ramp, ascending. */
|
||||
levels: number[];
|
||||
}
|
||||
|
||||
// Grace once, then a repeating cycle of [ramp up over rampSeconds] + [hold for
|
||||
// pauseSeconds]. The share rises linearly during each ramp and is flat during
|
||||
// the grace and every pause. Easy to tune: change grace, ramp, pause, or levels.
|
||||
// Same levels everywhere (the ofstats FFA territory median, then a final 55%
|
||||
// squeeze); the presets only change the pace. The median run is 3/5/10/20/30%;
|
||||
// normal hits it dead on at 10/15/20/25/30 min. The 6th wave (55%) only one side
|
||||
// can hold, so, together with the crown exemption, it forces out everyone but
|
||||
// the leader for a single winner. slow is ~20% slower, fast ~30% faster, very
|
||||
// fast 50% faster.
|
||||
const LEVELS = [300, 500, 1000, 2000, 3000, 5500]; // 3, 5, 10, 20, 30, 55%
|
||||
const SCHEDULES: Record<DoomsdayClockSpeed, WaveSchedule> = {
|
||||
// grace 5:30, 4:30 ramps + 30s pauses -> 3/5/10/20/30/55% at 10/15/20/25/30/35 min.
|
||||
normal: {
|
||||
graceSeconds: 330,
|
||||
rampSeconds: 270,
|
||||
pauseSeconds: 30,
|
||||
levels: LEVELS,
|
||||
},
|
||||
// grace 6:30, 5:30 ramps -> reaches at 12/18/24/30/36/42 min.
|
||||
slow: {
|
||||
graceSeconds: 390,
|
||||
rampSeconds: 330,
|
||||
pauseSeconds: 30,
|
||||
levels: LEVELS,
|
||||
},
|
||||
// grace 4:30, 2:50 ramps -> reaches at 7:20/10:40/14/17:20/20:40/24 min.
|
||||
fast: {
|
||||
graceSeconds: 270,
|
||||
rampSeconds: 170,
|
||||
pauseSeconds: 30,
|
||||
levels: LEVELS,
|
||||
},
|
||||
// grace 3:00, 2:00 ramps -> reaches at 5/7:30/10/12:30/15/17:30 min.
|
||||
veryfast: {
|
||||
graceSeconds: 180,
|
||||
rampSeconds: 120,
|
||||
pauseSeconds: 30,
|
||||
levels: LEVELS,
|
||||
},
|
||||
};
|
||||
|
||||
function schedule(speed: DoomsdayClockSpeed): WaveSchedule {
|
||||
return SCHEDULES[speed] ?? SCHEDULES.normal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Required share of the map (basis points) at `elapsed` game seconds: 0 through
|
||||
* the grace, then a linear ramp to each successive level with a flat pause after
|
||||
* each. Integer-only (floored) so every client agrees.
|
||||
*/
|
||||
function requiredBasisPoints(
|
||||
speed: DoomsdayClockSpeed,
|
||||
elapsed: number,
|
||||
): number {
|
||||
const s = schedule(speed);
|
||||
if (elapsed <= s.graceSeconds) return 0;
|
||||
const cycle = s.rampSeconds + s.pauseSeconds;
|
||||
const t = elapsed - s.graceSeconds;
|
||||
const i = Math.floor(t / cycle);
|
||||
if (i >= s.levels.length) return s.levels[s.levels.length - 1];
|
||||
const into = t - i * cycle;
|
||||
const prev = i === 0 ? 0 : s.levels[i - 1];
|
||||
const target = s.levels[i];
|
||||
if (into >= s.rampSeconds) return target; // in the pause: hold
|
||||
return prev + Math.floor(((target - prev) * into) / s.rampSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base minimum tiles one player must own at `elapsed` game seconds. One floored
|
||||
* integer ratio, so every client agrees.
|
||||
*/
|
||||
export function doomsdayClockRequiredTiles(
|
||||
speed: DoomsdayClockSpeed,
|
||||
land: number,
|
||||
elapsed: number,
|
||||
): number {
|
||||
if (land <= 0) return 0;
|
||||
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;
|
||||
/** The share the current (or next) ramp climbs to. */
|
||||
targetPercent: number;
|
||||
/** True while the share is actively ramping up. */
|
||||
growing: boolean;
|
||||
/** Seconds until the next ramp begins (0 while growing or once done). */
|
||||
secondsToNextGrowth: number;
|
||||
/** Within 5s before or after a ramp starting (the orange cue window). */
|
||||
waveFlash: boolean;
|
||||
/** True once the final level has been reached. */
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-only companion for the HUD: the live share, whether it is ramping or
|
||||
* holding, and the cue window. Lives here so the schedule is defined once.
|
||||
*/
|
||||
export function doomsdayClockWaveState(
|
||||
speed: DoomsdayClockSpeed,
|
||||
elapsed: number,
|
||||
): DoomsdayClockWaveState {
|
||||
const s = schedule(speed);
|
||||
const currentPercent = requiredBasisPoints(speed, elapsed) / 100;
|
||||
const cycle = s.rampSeconds + s.pauseSeconds;
|
||||
const n = s.levels.length;
|
||||
const last = s.levels[n - 1] / 100;
|
||||
|
||||
// Grace: flat 0; the first ramp starts at graceSeconds.
|
||||
if (elapsed <= s.graceSeconds) {
|
||||
return {
|
||||
currentPercent: 0,
|
||||
targetPercent: s.levels[0] / 100,
|
||||
growing: false,
|
||||
secondsToNextGrowth: s.graceSeconds - elapsed,
|
||||
waveFlash: s.graceSeconds - elapsed <= 5,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
|
||||
const t = elapsed - s.graceSeconds;
|
||||
const i = Math.floor(t / cycle);
|
||||
if (i >= n) {
|
||||
return {
|
||||
currentPercent,
|
||||
targetPercent: last,
|
||||
growing: false,
|
||||
secondsToNextGrowth: 0,
|
||||
waveFlash: false,
|
||||
done: true,
|
||||
};
|
||||
}
|
||||
|
||||
const into = t - i * cycle;
|
||||
const growing = into < s.rampSeconds;
|
||||
const isLast = i === n - 1;
|
||||
const nextRampStart = s.graceSeconds + (i + 1) * cycle;
|
||||
return {
|
||||
currentPercent,
|
||||
targetPercent: (growing || isLast ? s.levels[i] : s.levels[i + 1]) / 100,
|
||||
growing,
|
||||
secondsToNextGrowth: growing || isLast ? 0 : nextRampStart - elapsed,
|
||||
// 5s into a ramp (just started) or 5s before the next ramp begins.
|
||||
waveFlash: into <= 5 || (!isLast && nextRampStart - elapsed <= 5),
|
||||
done: isLast && !growing,
|
||||
};
|
||||
}
|
||||
|
||||
export interface DoomsdayClockDrainConfig {
|
||||
drainStartPercent: number;
|
||||
drainMaxPercent: number;
|
||||
drainRampSeconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Troops a skulled side loses this second: a LINEAR ramp from drainStartPercent
|
||||
* up to drainMaxPercent over drainRampSeconds. It is a percentage of the side's
|
||||
* MAX troop capacity (not current), so it outpaces troop income from the first
|
||||
* second and accelerates as it grows, driving the side to zero in ~55s from full
|
||||
* troops (sooner with fewer troops or a shrinking territory). The caller caps it
|
||||
* at the side's current troops (removeTroops does, and the HUD shows
|
||||
* min(current, this)). Shared by the sim and the HUD.
|
||||
*/
|
||||
export function doomsdayClockDrain(
|
||||
maxTroops: number,
|
||||
secondsPastWarn: number,
|
||||
cfg: DoomsdayClockDrainConfig,
|
||||
): number {
|
||||
const t = Math.max(0, secondsPastWarn);
|
||||
const r = cfg.drainRampSeconds;
|
||||
const span = cfg.drainMaxPercent - cfg.drainStartPercent;
|
||||
const pct =
|
||||
r <= 0 || t >= r
|
||||
? cfg.drainMaxPercent
|
||||
: cfg.drainStartPercent + Math.floor((span * t) / r);
|
||||
return Math.max(1, Math.floor((maxTroops * pct) / 100));
|
||||
}
|
||||
@@ -552,6 +552,11 @@ export interface Player {
|
||||
isAlive(): boolean;
|
||||
isTraitor(): boolean;
|
||||
markTraitor(): void;
|
||||
// Doomsday Clock (anti-stall): marked when below the rising territory bar.
|
||||
inDoomsdayClock(): boolean;
|
||||
doomsdayClockTicks(): number;
|
||||
enterDoomsdayClock(): void;
|
||||
clearDoomsdayClock(): void;
|
||||
largestClusterBoundingBox: { min: Cell; max: Cell } | null;
|
||||
lastTileChange(): Tick;
|
||||
|
||||
|
||||
@@ -58,6 +58,14 @@ export function diffPlayerUpdate(
|
||||
"traitorRemainingTicks",
|
||||
prev.traitorRemainingTicks === next.traitorRemainingTicks,
|
||||
);
|
||||
setIfDifferent(
|
||||
"inDoomsdayClock",
|
||||
prev.inDoomsdayClock === next.inDoomsdayClock,
|
||||
);
|
||||
setIfDifferent(
|
||||
"markedDoomsdayClockTick",
|
||||
prev.markedDoomsdayClockTick === next.markedDoomsdayClockTick,
|
||||
);
|
||||
setIfDifferent("hasSpawned", prev.hasSpawned === next.hasSpawned);
|
||||
setIfDifferent("spawnTile", prev.spawnTile === next.spawnTile);
|
||||
setIfDifferent("betrayals", prev.betrayals === next.betrayals);
|
||||
@@ -119,6 +127,11 @@ export function applyStateUpdate(target: PlayerState, pu: PlayerUpdate): void {
|
||||
if (pu.traitorRemainingTicks !== undefined) {
|
||||
target.traitorRemainingTicks = Math.max(0, pu.traitorRemainingTicks);
|
||||
}
|
||||
if (pu.inDoomsdayClock !== undefined)
|
||||
target.inDoomsdayClock = pu.inDoomsdayClock;
|
||||
if (pu.markedDoomsdayClockTick !== undefined) {
|
||||
target.markedDoomsdayClockTick = pu.markedDoomsdayClockTick;
|
||||
}
|
||||
if (pu.betrayals !== undefined) target.betrayals = pu.betrayals;
|
||||
if (pu.hasSpawned !== undefined) target.hasSpawned = pu.hasSpawned;
|
||||
if (pu.spawnTile !== undefined) target.spawnTile = pu.spawnTile;
|
||||
|
||||
@@ -233,6 +233,8 @@ export interface PlayerUpdate {
|
||||
embargoes?: Set<PlayerID>;
|
||||
isTraitor?: boolean;
|
||||
traitorRemainingTicks?: number;
|
||||
inDoomsdayClock?: boolean;
|
||||
markedDoomsdayClockTick?: number;
|
||||
targets?: number[];
|
||||
outgoingEmojis?: EmojiMessage[];
|
||||
outgoingAttacks?: AttackUpdate[];
|
||||
|
||||
@@ -98,6 +98,7 @@ export class PlayerImpl implements Player {
|
||||
private _troops: bigint;
|
||||
|
||||
markedTraitorTick = -1;
|
||||
markedDoomsdayClockTick = -1;
|
||||
private _betrayalCount: number = 0;
|
||||
|
||||
private embargoes = new Map<PlayerID, Embargo>();
|
||||
@@ -315,6 +316,8 @@ export class PlayerImpl implements Player {
|
||||
embargoes: embargoes,
|
||||
isTraitor: this.isTraitor(),
|
||||
traitorRemainingTicks: this.getTraitorRemainingTicks(),
|
||||
inDoomsdayClock: this.inDoomsdayClock(),
|
||||
markedDoomsdayClockTick: this.markedDoomsdayClockTick,
|
||||
targets: targets,
|
||||
outgoingEmojis: outgoingEmojis,
|
||||
outgoingAttacks: outgoingAttacks,
|
||||
@@ -741,6 +744,30 @@ export class PlayerImpl implements Player {
|
||||
this.mg.stats().betray(this);
|
||||
}
|
||||
|
||||
// A dead player is never "in doomsday clock": nothing clears the mark on death
|
||||
// (the execution only processes alive contenders), so gate on isAlive() to
|
||||
// avoid a stuck skull/panel and per-tick update churn for eliminated players.
|
||||
inDoomsdayClock(): boolean {
|
||||
return this.isAlive() && this.markedDoomsdayClockTick >= 0;
|
||||
}
|
||||
|
||||
// Ticks spent continuously below the doomsday-clock bar (0 when not marked or dead).
|
||||
doomsdayClockTicks(): number {
|
||||
return this.inDoomsdayClock()
|
||||
? this.mg.ticks() - this.markedDoomsdayClockTick
|
||||
: 0;
|
||||
}
|
||||
|
||||
enterDoomsdayClock(): void {
|
||||
if (this.markedDoomsdayClockTick < 0) {
|
||||
this.markedDoomsdayClockTick = this.mg.ticks();
|
||||
}
|
||||
}
|
||||
|
||||
clearDoomsdayClock(): void {
|
||||
this.markedDoomsdayClockTick = -1;
|
||||
}
|
||||
|
||||
betrayals(): number {
|
||||
return this._betrayalCount;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user