mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 20:25:37 +00:00
Resolves #<add your approved & assigned issue number> ## Description: Rebalances the Doomsday Clock so it acts as a **late-game stalemate-breaker** rather than an early-game culler, softens how fast it removes troops and warships, and makes the HUD countdown clearer. **Clock schedule (all presets):** - A flat **10-minute grace** at 0% required share — the early game is decided by combat, not the clock. - Then a 6-wave squeeze at accelerating levels (4 / 9 / 16 / 26 / 40 / 55%) with short pauses, reaching the final 55% at each preset's cap: **45 / 35 / 25 / 15 min** for slow / normal / fast / veryfast. - `WaveSchedule` `rampSeconds`/`pauseSeconds` are now **per-wave arrays**, so the curve can be shaped (gentle early, steeper late) instead of one uniform ramp. `requiredBasisPoints` and the HUD companion `doomsdayClockWaveState` walk the per-wave segments in lockstep. **Troop drain:** warn window `10s → 30s`, drain eased (`2%→5%` over `90s`), so a caught side takes ~2 minutes to wipe instead of ~1. **Warship attrition:** warships get their own gentler start plus a **convex** decay curve — a ship caught when its side is first doomed lasts about as long as troops, but the rate ramps up steeply so a side at full attrition still loses its fleet in ~2s. Adds a `curveExponent` argument to `doomsdayClockDrain` (1 = linear, used for troops; higher = convex, used for ships). **Determinism:** the drain curve is **integer-only** (fixed-point power, no floats), so the floored per-tick loss is bit-identical on every client in the lockstep sim. The linear troop path keeps its exact existing integer form; only the convex warship path is reshaped. **HUD countdown clarity:** the clock readout now shows a live countdown in both states — `Will reach 16% in M:SS` while the bar is actively climbing, and `Starts rising to 26% in M:SS` during a pause (previously `Next 26% in …`, which read as if it jumped there instantly). Backed by a new `secondsToTarget` field on the shared wave state so the sim and HUD stay in agreement. All display text goes through `translateText()` / `en.json`. <img width="278" height="116" alt="image" src="https://github.com/user-attachments/assets/e0be3d2c-bb88-46be-b344-34d63e4859bd" /> <img width="304" height="171" alt="image" src="https://github.com/user-attachments/assets/da74bd05-0b9a-4aec-bbf5-e7380fbda88e" /> **Danger skull:** while a side is below the bar in the warn window, its on-map skull now blinks progressively faster as the countdown runs out (accelerating to the moment the drain begins), then holds steady once it is actually draining — a clearer "you are about to be hit" cue. Rationale: the previous schedule removed players heavily in the first half of a match and could leave a drawn-out endgame. Holding the clock at 0% early keeps the opening about fighting, and concentrating its pressure in the back half reserves it for actually breaking stalemates. Values are tuning starting points and easy to adjust in `DoomsdayClock.ts` / the config defaults. ## 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: <add your Discord username>
283 lines
10 KiB
TypeScript
283 lines
10 KiB
TypeScript
/**
|
||
* 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: a COMBAT-ONLY window — the clock
|
||
* does not touch real players early; eliminations there come from fighting. */
|
||
graceSeconds: number;
|
||
/** Per-wave: wave i grows its share up linearly over rampSeconds[i]. */
|
||
rampSeconds: number[];
|
||
/** Per-wave: flat hold after wave i's 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 (a long COMBAT-ONLY window — 0% bar, the clock ignores everyone),
|
||
// then per wave a [ramp up over rampSeconds[i]] + [hold for pauseSeconds[i]]. The
|
||
// share rises linearly during each ramp and is flat during the grace and pauses.
|
||
//
|
||
// Design: the clock is a STALEMATE-BREAKER, not an early-game culler. It stays at
|
||
// 0% for the first 10 minutes (combat decides the early game), then a 6-wave
|
||
// squeeze climbs to 55% by the preset's cap. Levels accelerate (4/9/16/26/40/55%)
|
||
// so the endgame tightens; the 6th wave (55%) only one side can hold, so — with
|
||
// the crown exemption — it forces out everyone but the leader for a single winner.
|
||
// Grace is a flat 10:00 on every preset; presets differ only in how long the
|
||
// squeeze takes: 55% at 45/35/25/15 min for slow/normal/fast/veryfast.
|
||
const LEVELS = [400, 900, 1600, 2600, 4000, 5500]; // 4, 9, 16, 26, 40, 55%
|
||
const SCHEDULES: Record<DoomsdayClockSpeed, WaveSchedule> = {
|
||
// grace 10:00, then six ~208s ramps + 50s pauses -> 55% at 35:00.
|
||
normal: {
|
||
graceSeconds: 600,
|
||
rampSeconds: [208, 208, 208, 208, 208, 210],
|
||
pauseSeconds: [50, 50, 50, 50, 50, 0],
|
||
levels: LEVELS,
|
||
},
|
||
// grace 10:00, then six ~292s ramps + 70s pauses -> 55% at 45:00.
|
||
slow: {
|
||
graceSeconds: 600,
|
||
rampSeconds: [292, 292, 292, 292, 292, 290],
|
||
pauseSeconds: [70, 70, 70, 70, 70, 0],
|
||
levels: LEVELS,
|
||
},
|
||
// grace 10:00, then six 125s ramps + 30s pauses -> 4/9/16/26/40/55% at
|
||
// 12:05/14:40/17:15/19:50/22:25/25:00.
|
||
fast: {
|
||
graceSeconds: 600,
|
||
rampSeconds: [125, 125, 125, 125, 125, 125],
|
||
pauseSeconds: [30, 30, 30, 30, 30, 0],
|
||
levels: LEVELS,
|
||
},
|
||
// grace 10:00, then six 40s ramps + 12s pauses -> 55% at 15:00 (tight squeeze).
|
||
veryfast: {
|
||
graceSeconds: 600,
|
||
rampSeconds: [40, 40, 40, 40, 40, 40],
|
||
pauseSeconds: [12, 12, 12, 12, 12, 0],
|
||
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;
|
||
let t = elapsed - s.graceSeconds;
|
||
let prev = 0;
|
||
for (let i = 0; i < s.levels.length; i++) {
|
||
const ramp = s.rampSeconds[i];
|
||
const target = s.levels[i];
|
||
if (t < ramp) return prev + Math.floor(((target - prev) * t) / ramp); // ramping
|
||
t -= ramp;
|
||
if (t < s.pauseSeconds[i]) return target; // in the pause: hold
|
||
t -= s.pauseSeconds[i];
|
||
prev = target;
|
||
}
|
||
return s.levels[s.levels.length - 1];
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
/** Seconds until the current rise reaches its target level (0 unless growing). */
|
||
secondsToTarget: 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 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,
|
||
secondsToTarget: 0,
|
||
waveFlash: s.graceSeconds - elapsed <= 5,
|
||
done: false,
|
||
};
|
||
}
|
||
|
||
// Walk the per-wave ramp/pause segments to locate the current wave.
|
||
let t = elapsed - s.graceSeconds;
|
||
for (let i = 0; i < n; i++) {
|
||
const ramp = s.rampSeconds[i];
|
||
const pause = s.pauseSeconds[i];
|
||
const isLast = i === n - 1;
|
||
if (t < ramp) {
|
||
return {
|
||
currentPercent,
|
||
targetPercent: s.levels[i] / 100,
|
||
growing: true,
|
||
secondsToNextGrowth: 0,
|
||
secondsToTarget: ramp - t, // reaches this wave's level when the ramp ends
|
||
waveFlash: t <= 5, // just started ramping
|
||
done: false,
|
||
};
|
||
}
|
||
t -= ramp;
|
||
if (t < pause) {
|
||
return {
|
||
currentPercent,
|
||
targetPercent: (isLast ? s.levels[i] : s.levels[i + 1]) / 100,
|
||
growing: false,
|
||
secondsToNextGrowth: isLast ? 0 : pause - t,
|
||
secondsToTarget: 0,
|
||
waveFlash: !isLast && pause - t <= 5, // next ramp imminent
|
||
done: isLast,
|
||
};
|
||
}
|
||
t -= pause;
|
||
}
|
||
return {
|
||
currentPercent,
|
||
targetPercent: last,
|
||
growing: false,
|
||
secondsToNextGrowth: 0,
|
||
secondsToTarget: 0,
|
||
waveFlash: false,
|
||
done: true,
|
||
};
|
||
}
|
||
|
||
export interface DoomsdayClockDrainConfig {
|
||
drainStartPercent: number;
|
||
drainMaxPercent: number;
|
||
drainRampSeconds: number;
|
||
}
|
||
|
||
// Fixed-point scale for the convex drain curve. (t/r)^exponent is evaluated as a
|
||
// fraction of this via repeated integer multiply, so the ramp never touches a
|
||
// float and lands bit-identically on every client in the lockstep sim.
|
||
const DRAIN_CURVE_SCALE = 1_000_000;
|
||
|
||
/**
|
||
* (t/r)^exponent as a fraction of DRAIN_CURVE_SCALE, integer-only. Squares down
|
||
* from 1.0 with a floored multiply per step; t <= r and exponent >= 2, so the
|
||
* intermediates stay well inside Number.MAX_SAFE_INTEGER.
|
||
*/
|
||
function drainCurveFraction(t: number, r: number, exponent: number): number {
|
||
const ratio = Math.floor((t * DRAIN_CURVE_SCALE) / r); // in [0, SCALE]
|
||
let acc = DRAIN_CURVE_SCALE; // represents 1.0
|
||
for (let i = 0; i < exponent; i++) {
|
||
acc = Math.floor((acc * ratio) / DRAIN_CURVE_SCALE);
|
||
}
|
||
return acc;
|
||
}
|
||
|
||
/**
|
||
* Troops (or warship health) a skulled side loses this second: a ramp from
|
||
* drainStartPercent up to drainMaxPercent over drainRampSeconds, as a percentage
|
||
* of MAX capacity/health (not current), so it outpaces income from the first
|
||
* second. `curveExponent` shapes the ramp: 1 = LINEAR (troops → ~1:30 to zero
|
||
* from full); >1 = CONVEX (warships → stays near the gentle start for most of the
|
||
* ramp, then spikes hard, so a ship caught early lasts ~as long as troops but a
|
||
* side at full attrition loses ships in ~2s). The caller caps it at the side's
|
||
* current value. Integer-only and floored throughout — no floats — so the drain
|
||
* is deterministic across clients (required by the lockstep sim).
|
||
*/
|
||
export function doomsdayClockDrain(
|
||
maxTroops: number,
|
||
secondsPastWarn: number,
|
||
cfg: DoomsdayClockDrainConfig,
|
||
curveExponent = 1,
|
||
): number {
|
||
const t = Math.max(0, secondsPastWarn);
|
||
const r = cfg.drainRampSeconds;
|
||
const span = cfg.drainMaxPercent - cfg.drainStartPercent;
|
||
let pct = cfg.drainMaxPercent;
|
||
if (r > 0 && t < r) {
|
||
// Linear is the exact integer form the sim has always used; the convex case
|
||
// reshapes it through the fixed-point curve above (still integer-only).
|
||
const grown =
|
||
curveExponent <= 1
|
||
? Math.floor((span * t) / r)
|
||
: Math.floor(
|
||
(span * drainCurveFraction(t, r, curveExponent)) /
|
||
DRAIN_CURVE_SCALE,
|
||
);
|
||
pct = cfg.drainStartPercent + grown;
|
||
}
|
||
return Math.max(1, Math.floor((maxTroops * pct) / 100));
|
||
}
|