Rebalance Doomsday Clock: late-game stalemate-breaker (10min grace + wave squeeze), slower troop drain, gentler-but-steeper warship attrition (#4518)

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>
This commit is contained in:
Zixer1
2026-07-07 22:38:36 -04:00
committed by GitHub
parent 1db65a1877
commit 4ce57efbe2
11 changed files with 275 additions and 146 deletions
+14 -8
View File
@@ -85,19 +85,23 @@ export const SAM_CONSTRUCTION_TICKS = 30 * 10;
// 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.
// warn (30s) + the linear drain (~90s from full troops, sooner with fewer troops
// or a shrinking territory) make ~2 minutes 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
warnSeconds: 30, // cooldown (the flashing danger cue) before decay begins
drainStartPercent: 2, // starts bleeding at once (already beats troop income)
drainMaxPercent: 6,
drainRampSeconds: 50, // ramps LINEARLY to the max over this long
// Warships bleed on the same start + ramp but to a much higher ceiling than
// troops, so a fleet at full attrition sinks in ~2s (50% of a ship's max
// health per second) instead of riding out the gentle troop rate. Ships only.
drainMaxPercent: 5,
drainRampSeconds: 90, // ramps LINEARLY to the max over this long (~1:30 to zero)
// Warships bleed on their OWN gentler start + a STEEP (convex) ramp to a much
// higher ceiling. A ship caught when its side is first doomed lasts about as
// long as troops (the low start + no income ≈ the troop net rate), but the rate
// curves up sharply (warshipDrainCurveExponent), so once a side has been under
// the clock the full ramp, ships sink in ~2s (50%/s). Ships only.
warshipDrainStartPercent: 1,
warshipDrainMaxPercent: 50,
warshipDrainCurveExponent: 8, // >1 = convex: stays gentle early, then spikes
};
export class Config {
@@ -134,7 +138,9 @@ export class Config {
drainStartPercent: d.drainStartPercent,
drainMaxPercent: d.drainMaxPercent,
drainRampSeconds: d.drainRampSeconds,
warshipDrainStartPercent: d.warshipDrainStartPercent,
warshipDrainMaxPercent: d.warshipDrainMaxPercent,
warshipDrainCurveExponent: d.warshipDrainCurveExponent,
};
}
spawnImmunityDuration(): Tick {
+6 -3
View File
@@ -44,11 +44,13 @@ export class DoomsdayClockExecution implements Execution {
const mg = this.mg;
const cfg = mg.config().doomsdayClockConfig();
if (!cfg.enabled) return;
// Warships bleed on the same start + ramp as troops but toward a much higher
// ceiling (warshipDrainMaxPercent), so a fleet at full attrition sinks in
// ~2s. Only the max differs from the troop drain.
// 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,
};
@@ -122,6 +124,7 @@ export class DoomsdayClockExecution implements Execution {
ws.maxHealth(),
secondsPastWarn,
warshipDrainCfg,
cfg.warshipDrainCurveExponent,
),
);
}
+128 -74
View File
@@ -22,53 +22,57 @@ export const DOOMSDAY_CLOCK_SPEEDS: DoomsdayClockSpeed[] = [
];
interface WaveSchedule {
/** Flat 0% for this long at the very start (the one grace period). */
/** 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;
/** Each wave grows its share up linearly over this long. */
rampSeconds: number;
/** Flat hold after each ramp before the next one starts. */
pauseSeconds: 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, 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%
// 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 5:30, 4:30 ramps + 30s pauses -> 3/5/10/20/30/55% at 10/15/20/25/30/35 min.
// grace 10:00, then six ~208s ramps + 50s pauses -> 55% at 35:00.
normal: {
graceSeconds: 330,
rampSeconds: 270,
pauseSeconds: 30,
graceSeconds: 600,
rampSeconds: [208, 208, 208, 208, 208, 210],
pauseSeconds: [50, 50, 50, 50, 50, 0],
levels: LEVELS,
},
// grace 6:30, 5:30 ramps -> reaches at 12/18/24/30/36/42 min.
// grace 10:00, then six ~292s ramps + 70s pauses -> 55% at 45:00.
slow: {
graceSeconds: 390,
rampSeconds: 330,
pauseSeconds: 30,
graceSeconds: 600,
rampSeconds: [292, 292, 292, 292, 292, 290],
pauseSeconds: [70, 70, 70, 70, 70, 0],
levels: LEVELS,
},
// grace 4:30, 2:50 ramps -> reaches at 7:20/10:40/14/17:20/20:40/24 min.
// 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: 270,
rampSeconds: 170,
pauseSeconds: 30,
graceSeconds: 600,
rampSeconds: [125, 125, 125, 125, 125, 125],
pauseSeconds: [30, 30, 30, 30, 30, 0],
levels: LEVELS,
},
// grace 3:00, 2:00 ramps -> reaches at 5/7:30/10/12:30/15/17:30 min.
// grace 10:00, then six 40s ramps + 12s pauses -> 55% at 15:00 (tight squeeze).
veryfast: {
graceSeconds: 180,
rampSeconds: 120,
pauseSeconds: 30,
graceSeconds: 600,
rampSeconds: [40, 40, 40, 40, 40, 40],
pauseSeconds: [12, 12, 12, 12, 12, 0],
levels: LEVELS,
},
};
@@ -88,15 +92,18 @@ function requiredBasisPoints(
): 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);
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];
}
/**
@@ -137,6 +144,8 @@ export interface DoomsdayClockWaveState {
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. */
@@ -153,7 +162,6 @@ export function doomsdayClockWaveState(
): 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;
@@ -164,36 +172,51 @@ export function doomsdayClockWaveState(
targetPercent: s.levels[0] / 100,
growing: false,
secondsToNextGrowth: s.graceSeconds - elapsed,
secondsToTarget: 0,
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,
};
// 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;
}
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,
targetPercent: last,
growing: false,
secondsToNextGrowth: 0,
secondsToTarget: 0,
waveFlash: false,
done: true,
};
}
@@ -203,26 +226,57 @@ export interface DoomsdayClockDrainConfig {
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;
/**
* 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.
* (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;
const pct =
r <= 0 || t >= r
? cfg.drainMaxPercent
: cfg.drainStartPercent + Math.floor((span * t) / r);
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));
}