mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-08 21:40:29 +00:00
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:
@@ -404,9 +404,9 @@
|
||||
"collapsing": "Collapsing -{rate}/s",
|
||||
"decay_in": "Decay in {secs}s",
|
||||
"final": "Final zone, hold {pct}%",
|
||||
"growing": "Rising to {pct}%",
|
||||
"growing": "Will reach {pct}% in {time}",
|
||||
"hold": "Hold ≥ {pct}%",
|
||||
"next_wave": "Next {pct}% in {time}",
|
||||
"next_wave": "Starts rising to {pct}% in {time}",
|
||||
"stable": "Stable",
|
||||
"title": "Doomsday Clock",
|
||||
"unstable": "Unstable",
|
||||
|
||||
@@ -124,6 +124,7 @@ export class DoomsdayClockPanel extends LitElement {
|
||||
: wave.growing
|
||||
? translateText("doomsday_clock.growing", {
|
||||
pct: scalePct(wave.targetPercent),
|
||||
time: this.secondsToHms(wave.secondsToTarget),
|
||||
})
|
||||
: translateText("doomsday_clock.next_wave", {
|
||||
pct: scalePct(wave.targetPercent),
|
||||
|
||||
@@ -107,10 +107,20 @@ export function computePlayerStatus(
|
||||
const inDoomsdayClock = ps.inDoomsdayClock;
|
||||
// Past the warn grace the side is actively bleeding troops; the skull holds
|
||||
// steady then (vs blinking while merely in danger).
|
||||
const doomsdayClockUnderTicks =
|
||||
(opts.tick ?? 0) - ps.markedDoomsdayClockTick;
|
||||
const doomsdayClockWarnTicks = opts.doomsdayClockWarnTicks ?? 0;
|
||||
const doomsdayClockDraining =
|
||||
inDoomsdayClock &&
|
||||
(opts.tick ?? 0) - ps.markedDoomsdayClockTick >=
|
||||
(opts.doomsdayClockWarnTicks ?? 0);
|
||||
inDoomsdayClock && doomsdayClockUnderTicks >= doomsdayClockWarnTicks;
|
||||
// How far through the warn countdown (0->1); the danger skull blinks faster
|
||||
// as this nears 1, i.e. as the side approaches draining.
|
||||
const doomsdayClockWarnProgress =
|
||||
inDoomsdayClock && doomsdayClockWarnTicks > 0
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(1, doomsdayClockUnderTicks / doomsdayClockWarnTicks),
|
||||
)
|
||||
: 0;
|
||||
const traitorRemainingTicks = ps.traitorRemainingTicks;
|
||||
|
||||
// Relative flags
|
||||
@@ -179,6 +189,7 @@ export function computePlayerStatus(
|
||||
disconnected,
|
||||
inDoomsdayClock,
|
||||
doomsdayClockDraining,
|
||||
doomsdayClockWarnProgress,
|
||||
alliance,
|
||||
allianceReq,
|
||||
target,
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface PlayerSlot {
|
||||
nukeTargetsMe: boolean;
|
||||
inDoomsdayClock: boolean;
|
||||
doomsdayClockDraining: boolean;
|
||||
doomsdayClockWarnProgress: number;
|
||||
traitorRemainingTicks: number;
|
||||
allianceFraction: number;
|
||||
allianceRemainingTicks: number;
|
||||
|
||||
@@ -327,6 +327,7 @@ export class NamePass {
|
||||
nukeTargetsMe: false,
|
||||
inDoomsdayClock: false,
|
||||
doomsdayClockDraining: false,
|
||||
doomsdayClockWarnProgress: 0,
|
||||
traitorRemainingTicks: 0,
|
||||
allianceFraction: 0,
|
||||
allianceRemainingTicks: 0,
|
||||
@@ -459,6 +460,7 @@ export class NamePass {
|
||||
const disconnected = sd?.disconnected ?? false;
|
||||
const inDoomsdayClock = sd?.inDoomsdayClock ?? false;
|
||||
const doomsdayClockDraining = sd?.doomsdayClockDraining ?? false;
|
||||
const doomsdayClockWarnProgress = sd?.doomsdayClockWarnProgress ?? 0;
|
||||
const alliance = sd?.alliance ?? false;
|
||||
const allianceReq = sd?.allianceReq ?? false;
|
||||
const target = sd?.target ?? false;
|
||||
@@ -475,6 +477,7 @@ export class NamePass {
|
||||
disconnected !== slot.disconnected ||
|
||||
inDoomsdayClock !== slot.inDoomsdayClock ||
|
||||
doomsdayClockDraining !== slot.doomsdayClockDraining ||
|
||||
doomsdayClockWarnProgress !== slot.doomsdayClockWarnProgress ||
|
||||
alliance !== slot.alliance ||
|
||||
allianceReq !== slot.allianceReq ||
|
||||
target !== slot.target ||
|
||||
@@ -490,6 +493,7 @@ export class NamePass {
|
||||
slot.disconnected = disconnected;
|
||||
slot.inDoomsdayClock = inDoomsdayClock;
|
||||
slot.doomsdayClockDraining = doomsdayClockDraining;
|
||||
slot.doomsdayClockWarnProgress = doomsdayClockWarnProgress;
|
||||
slot.alliance = alliance;
|
||||
slot.allianceReq = allianceReq;
|
||||
slot.target = target;
|
||||
@@ -576,14 +580,17 @@ export class NamePass {
|
||||
d[off + 15] = slot.nameHalfWidth;
|
||||
|
||||
// Column 4: flagLayerIdx, emojiAtlasIdx, smallID, doomsdayClock state
|
||||
// (0 none, 1 danger -> blinking skull, 2 draining -> steady skull).
|
||||
// (0 none, 1.0-1.49 danger -> blinking skull, 2 draining -> steady skull).
|
||||
// The warn-countdown progress (0->1) is packed into the danger value's
|
||||
// fraction so the shader can blink faster as drain nears; it stays < 1.5 so
|
||||
// the draining threshold is untouched.
|
||||
d[off + 16] = slot.flagLayerIdx;
|
||||
d[off + 17] = slot.emojiAtlasIdx;
|
||||
d[off + 18] = slot.static.smallID;
|
||||
d[off + 19] = slot.doomsdayClockDraining
|
||||
? 2.0
|
||||
: slot.inDoomsdayClock
|
||||
? 1.0
|
||||
? 1.0 + slot.doomsdayClockWarnProgress * 0.49
|
||||
: 0.0;
|
||||
|
||||
// Column 5: crown, traitor, disconnected, alliance
|
||||
|
||||
@@ -268,12 +268,19 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Doomsday Clock skull: slot 8. Blinks ~2 Hz while in danger (flag 1.0); holds
|
||||
// steady once the side is draining (flag 2.0).
|
||||
// Doomsday Clock skull: slot 8. While in danger (flag 1.0-1.49) the skull
|
||||
// blinks and speeds up as the warn countdown runs out — the progress 0->1 is
|
||||
// packed into the flag's fraction. Base ~2 Hz plus a p^2 phase term so it
|
||||
// accelerates toward the drain (same trick as the alliance-renewal flash
|
||||
// above, which keeps the phase continuous). Holds steady once draining (>=1.5).
|
||||
if (iconSlot == 8) {
|
||||
vFlashAlpha = (statusFlag[8] < 1.5)
|
||||
? 0.35 + 0.65 * (0.5 + 0.5 * cos(uTime * 2.0 * 6.2832))
|
||||
: 1.0;
|
||||
if (statusFlag[8] < 1.5) {
|
||||
float p = clamp((statusFlag[8] - 1.0) / 0.49, 0.0, 1.0);
|
||||
float phase = uTime * 2.0 + p * p * 40.0;
|
||||
vFlashAlpha = 0.35 + 0.65 * (0.5 + 0.5 * cos(phase * 6.2832));
|
||||
} else {
|
||||
vFlashAlpha = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
vDiscard = 0;
|
||||
|
||||
@@ -193,6 +193,7 @@ export interface PlayerStatusData {
|
||||
nukeTargetsMe: boolean;
|
||||
inDoomsdayClock: boolean;
|
||||
doomsdayClockDraining: boolean;
|
||||
doomsdayClockWarnProgress: number;
|
||||
traitorRemainingTicks: number;
|
||||
allianceFraction: number;
|
||||
allianceRemainingTicks: number;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -23,12 +23,14 @@ import { playerInfo, setup } from "./util/Setup";
|
||||
// the pure-function tests further down; the end-to-end integration against the
|
||||
// real simulation is the final test.
|
||||
//
|
||||
// The exec reads the real "veryfast" waves. WAVE_TICK sits in the 20% hold
|
||||
// window (elapsed 750-780), so the bar is a stable 20% of the map (land 1000 ->
|
||||
// bar 200) while the drain/flag logic is exercised.
|
||||
// The exec reads the real "veryfast" waves. WAVE_TICK sits in the 26% hold
|
||||
// window (elapsed 796-808), so the bar is a stable 26% of the map (land 1000 ->
|
||||
// bar 260) while the drain/flag logic is exercised. b(100) stays below it and
|
||||
// a(400) above, so the flag/drain assertions (which depend on the drain config,
|
||||
// not the exact bar) are unchanged.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WAVE_TICK = 7600; // elapsed 760s -> veryfast 20% hold (bar 200 @ land 1000)
|
||||
const WAVE_TICK = 8000; // elapsed 800s -> veryfast 26% hold (bar 260 @ land 1000)
|
||||
|
||||
type SDConfig = ReturnType<ReturnType<Game["config"]>["doomsdayClockConfig"]>;
|
||||
|
||||
@@ -40,7 +42,9 @@ function sdConfig(over: Partial<SDConfig> = {}): SDConfig {
|
||||
drainStartPercent: 10,
|
||||
drainMaxPercent: 80,
|
||||
drainRampSeconds: 3,
|
||||
warshipDrainStartPercent: 10, // fixture: same start as troops (linear curve below)
|
||||
warshipDrainMaxPercent: 100, // ships ramp to a higher ceiling than troops
|
||||
warshipDrainCurveExponent: 1, // fixture uses a linear ramp for exact numbers
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -477,23 +481,23 @@ describe("doomsdayClockRequiredTiles (ramping waves)", () => {
|
||||
const land = 10000;
|
||||
|
||||
it("is 0 through the grace, ramps linearly, then holds during the pause", () => {
|
||||
// normal: grace 330s, then a 270s ramp 0->3%, then a 30s hold, ...
|
||||
expect(doomsdayClockRequiredTiles("normal", land, 200)).toBe(0); // in the grace
|
||||
expect(doomsdayClockRequiredTiles("normal", land, 330)).toBe(0); // grace ends
|
||||
expect(doomsdayClockRequiredTiles("normal", land, 465)).toBe(150); // halfway up -> 1.5%
|
||||
expect(doomsdayClockRequiredTiles("normal", land, 600)).toBe(300); // ramp done -> 3%
|
||||
expect(doomsdayClockRequiredTiles("normal", land, 615)).toBe(300); // pause holds 3%
|
||||
expect(doomsdayClockRequiredTiles("normal", land, 630)).toBe(300); // next ramp starts at 3%
|
||||
// normal: grace 600s, then a 208s ramp 0->4%, then a 50s hold, ...
|
||||
expect(doomsdayClockRequiredTiles("normal", land, 300)).toBe(0); // in the grace
|
||||
expect(doomsdayClockRequiredTiles("normal", land, 600)).toBe(0); // grace ends
|
||||
expect(doomsdayClockRequiredTiles("normal", land, 704)).toBe(200); // halfway up -> 2%
|
||||
expect(doomsdayClockRequiredTiles("normal", land, 808)).toBe(400); // ramp done -> 4%
|
||||
expect(doomsdayClockRequiredTiles("normal", land, 830)).toBe(400); // pause holds 4%
|
||||
expect(doomsdayClockRequiredTiles("normal", land, 858)).toBe(400); // next ramp starts at 4%
|
||||
expect(doomsdayClockRequiredTiles("normal", land, 9999)).toBe(5500); // final 55%
|
||||
});
|
||||
|
||||
it("passes 30% then reaches the final 55% squeeze per preset", () => {
|
||||
// 30% waypoint, then the 6th wave to 55% one cycle later.
|
||||
expect(doomsdayClockRequiredTiles("normal", land, 1800)).toBe(3000); // 30% @ 30:00
|
||||
expect(doomsdayClockRequiredTiles("normal", land, 2100)).toBe(5500); // 55% @ 35:00
|
||||
expect(doomsdayClockRequiredTiles("fast", land, 1440)).toBe(5500); // 55% @ 24:00
|
||||
expect(doomsdayClockRequiredTiles("veryfast", land, 1050)).toBe(5500); // 55% @ 17:30
|
||||
expect(doomsdayClockRequiredTiles("slow", land, 2520)).toBe(5500); // 55% @ 42:00
|
||||
it("reaches the 40% wave then the final 55% squeeze per preset", () => {
|
||||
// 40% waypoint (5th wave), then the 6th wave to 55% at the preset cap.
|
||||
expect(doomsdayClockRequiredTiles("normal", land, 1850)).toBe(4000); // 40% wave
|
||||
expect(doomsdayClockRequiredTiles("normal", land, 2110)).toBe(5500); // 55% @ 35:00
|
||||
expect(doomsdayClockRequiredTiles("fast", land, 1510)).toBe(5500); // 55% @ 25:00
|
||||
expect(doomsdayClockRequiredTiles("veryfast", land, 910)).toBe(5500); // 55% @ 15:00
|
||||
expect(doomsdayClockRequiredTiles("slow", land, 2710)).toBe(5500); // 55% @ 45:00
|
||||
});
|
||||
|
||||
it("never decreases, and is zero for no land", () => {
|
||||
@@ -511,51 +515,53 @@ describe("doomsdayClockSideRequiredTiles (headcount scaling)", () => {
|
||||
const land = 10000;
|
||||
|
||||
it("scales the base share by side size and caps at the whole map", () => {
|
||||
// veryfast at 900s is the final 30% wave -> base 3000 tiles.
|
||||
expect(doomsdayClockRequiredTiles("veryfast", land, 900)).toBe(3000);
|
||||
expect(doomsdayClockSideRequiredTiles("veryfast", land, 900, 1)).toBe(3000); // solo
|
||||
expect(doomsdayClockSideRequiredTiles("veryfast", land, 900, 2)).toBe(6000); // 2x
|
||||
expect(doomsdayClockSideRequiredTiles("veryfast", land, 900, 4)).toBe(
|
||||
// veryfast at 800s sits in the 26% hold -> base 2600 tiles.
|
||||
expect(doomsdayClockRequiredTiles("veryfast", land, 800)).toBe(2600);
|
||||
expect(doomsdayClockSideRequiredTiles("veryfast", land, 800, 1)).toBe(2600); // solo
|
||||
expect(doomsdayClockSideRequiredTiles("veryfast", land, 800, 2)).toBe(5200); // 2x
|
||||
expect(doomsdayClockSideRequiredTiles("veryfast", land, 800, 4)).toBe(
|
||||
10000,
|
||||
); // capped
|
||||
expect(doomsdayClockSideRequiredTiles("veryfast", land, 900, 0)).toBe(3000); // min size 1
|
||||
); // 10400 capped at the map
|
||||
expect(doomsdayClockSideRequiredTiles("veryfast", land, 800, 0)).toBe(2600); // min size 1
|
||||
});
|
||||
});
|
||||
|
||||
describe("doomsdayClockWaveState", () => {
|
||||
it("reports the live share and target while ramping", () => {
|
||||
const s = doomsdayClockWaveState("normal", 465); // mid the first ramp (0->3%)
|
||||
expect(s.currentPercent).toBe(1.5);
|
||||
expect(s.targetPercent).toBe(3);
|
||||
const s = doomsdayClockWaveState("normal", 704); // mid the first ramp (0->4%)
|
||||
expect(s.currentPercent).toBe(2);
|
||||
expect(s.targetPercent).toBe(4);
|
||||
expect(s.growing).toBe(true);
|
||||
expect(s.secondsToNextGrowth).toBe(0);
|
||||
expect(s.secondsToTarget).toBe(104); // 208s ramp, 104s elapsed into it
|
||||
expect(s.done).toBe(false);
|
||||
});
|
||||
|
||||
it("counts down to the next ramp during a pause", () => {
|
||||
const s = doomsdayClockWaveState("normal", 615); // in the first pause (600-630)
|
||||
const s = doomsdayClockWaveState("normal", 830); // in the first pause (808-858)
|
||||
expect(s.growing).toBe(false);
|
||||
expect(s.currentPercent).toBe(3); // held at the level just reached
|
||||
expect(s.targetPercent).toBe(5); // next ramp climbs to 5%
|
||||
expect(s.secondsToNextGrowth).toBe(15); // next ramp starts at 630
|
||||
expect(s.currentPercent).toBe(4); // held at the level just reached
|
||||
expect(s.targetPercent).toBe(9); // next ramp climbs to 9%
|
||||
expect(s.secondsToNextGrowth).toBe(28); // next ramp starts at 858
|
||||
expect(s.secondsToTarget).toBe(0); // not rising, so no rise countdown
|
||||
});
|
||||
|
||||
it("counts down through the grace", () => {
|
||||
const s = doomsdayClockWaveState("normal", 200);
|
||||
expect(s.currentPercent).toBe(0);
|
||||
expect(s.targetPercent).toBe(3);
|
||||
expect(s.secondsToNextGrowth).toBe(130); // first ramp at 330
|
||||
expect(s.targetPercent).toBe(4);
|
||||
expect(s.secondsToNextGrowth).toBe(400); // first ramp at 600
|
||||
});
|
||||
|
||||
it("flags the 10s window (5s each side) around a ramp starting", () => {
|
||||
// veryfast first ramp starts at 180s.
|
||||
expect(doomsdayClockWaveState("veryfast", 176).waveFlash).toBe(true); // 4s before
|
||||
expect(doomsdayClockWaveState("veryfast", 184).waveFlash).toBe(true); // 4s after
|
||||
expect(doomsdayClockWaveState("veryfast", 250).waveFlash).toBe(false); // mid-ramp
|
||||
// veryfast first ramp starts at 600s.
|
||||
expect(doomsdayClockWaveState("veryfast", 596).waveFlash).toBe(true); // 4s before
|
||||
expect(doomsdayClockWaveState("veryfast", 604).waveFlash).toBe(true); // 4s after
|
||||
expect(doomsdayClockWaveState("veryfast", 620).waveFlash).toBe(false); // mid-ramp
|
||||
});
|
||||
|
||||
it("marks done after the last ramp", () => {
|
||||
const s = doomsdayClockWaveState("veryfast", 1100); // past the final ramp (@1050) = 55%
|
||||
const s = doomsdayClockWaveState("veryfast", 1100); // past the final ramp (@900) = 55%
|
||||
expect(s.done).toBe(true);
|
||||
expect(s.currentPercent).toBe(55);
|
||||
expect(s.secondsToNextGrowth).toBe(0);
|
||||
@@ -589,6 +595,37 @@ describe("doomsdayClockDrain", () => {
|
||||
it("treats time before the warn window as zero", () => {
|
||||
expect(doomsdayClockDrain(1000, -5, cfg)).toBe(100); // clamped to start %
|
||||
});
|
||||
|
||||
it("shapes a convex curve (exponent > 1): gentle early, steep late, integer-only", () => {
|
||||
// Warship-style ramp: start 1%, max 50% over 90s, exponent 8. Integer-only
|
||||
// (no floats) so it's deterministic in the lockstep sim.
|
||||
const ship = {
|
||||
drainStartPercent: 1,
|
||||
drainMaxPercent: 50,
|
||||
drainRampSeconds: 90,
|
||||
};
|
||||
const at = (t: number) => doomsdayClockDrain(10000, t, ship, 8);
|
||||
|
||||
expect(at(0)).toBe(100); // 1% of 10000 at the very start
|
||||
expect(at(90)).toBe(5000); // 50% once fully ramped
|
||||
expect(at(200)).toBe(5000); // holds at the max past the ramp
|
||||
|
||||
// Convex: at the ramp midpoint it's still far below the linear midpoint
|
||||
// (which would be ~25%); the bulk of the attrition is back-loaded.
|
||||
expect(at(45)).toBeLessThan(at(90) / 4);
|
||||
|
||||
// Monotonic non-decreasing, and every value an integer.
|
||||
let prev = -1;
|
||||
for (let t = 0; t <= 90; t++) {
|
||||
const v = at(t);
|
||||
expect(Number.isInteger(v)).toBe(true);
|
||||
expect(v).toBeGreaterThanOrEqual(prev);
|
||||
prev = v;
|
||||
}
|
||||
|
||||
// Deterministic: identical inputs give identical output.
|
||||
expect(at(37)).toBe(at(37));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -612,13 +649,13 @@ function giveLandTiles(game: Game, player: Player, n: number): number {
|
||||
}
|
||||
|
||||
describe("DoomsdayClockExecution (integration)", () => {
|
||||
// Steepest preset; we run past its grace (180s) into the waves. Drain tuning
|
||||
// is internal now, so this exercises the default drain (warn 10s, 2%->6%/50s).
|
||||
// Steepest preset; we run past its grace (600s) into the waves. Drain tuning
|
||||
// is internal now, so this exercises the default drain (warn 30s, 2%->5%/90s).
|
||||
const SD = {
|
||||
enabled: true,
|
||||
speed: "veryfast" as const,
|
||||
};
|
||||
const TICKS = 3000; // 300s of game time -> veryfast holding its 3% wave
|
||||
const TICKS = 6500; // 650s of game time -> veryfast holding its 4% wave
|
||||
|
||||
async function buildGame(enabled: boolean) {
|
||||
const game = await setup(
|
||||
@@ -670,8 +707,8 @@ describe("DoomsdayClockExecution (integration)", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DoomsdayClockExecution (default drain, with income)", () => {
|
||||
it("wipes a full-troop side in ~1 minute (warn + linear drain)", async () => {
|
||||
// Only enabled + speed set -> drain uses the defaults (warn 10s, 2%->6% /50s).
|
||||
it("wipes a full-troop side in ~2 minutes (warn + linear drain)", async () => {
|
||||
// Only enabled + speed set -> drain uses the defaults (warn 30s, 2%->5% /90s).
|
||||
// veryfast is chosen purely so the bar rises fast enough to catch the sliver.
|
||||
const game = await setup(
|
||||
"plains",
|
||||
@@ -695,7 +732,8 @@ describe("DoomsdayClockExecution (default drain, with income)", () => {
|
||||
// Run until the rising bar catches the sliver, then fill it to a full stack
|
||||
// so we measure the worst-case (longest) wipe from that moment.
|
||||
let caughtTick = -1;
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
for (let i = 0; i < 8000; i++) {
|
||||
// grace is 600s now -> the bar only rises after ~6000 ticks
|
||||
game.executeNextTick();
|
||||
if (small.inDoomsdayClock()) {
|
||||
caughtTick = game.ticks();
|
||||
@@ -706,7 +744,7 @@ describe("DoomsdayClockExecution (default drain, with income)", () => {
|
||||
small.setTroops(game.config().maxTroops(small));
|
||||
|
||||
let zeroTick = -1;
|
||||
for (let i = 0; i < 1500; i++) {
|
||||
for (let i = 0; i < 2500; i++) {
|
||||
game.executeNextTick();
|
||||
if (small.troops() <= 0) {
|
||||
zeroTick = game.ticks();
|
||||
@@ -715,8 +753,8 @@ describe("DoomsdayClockExecution (default drain, with income)", () => {
|
||||
}
|
||||
expect(zeroTick).toBeGreaterThan(0);
|
||||
const seconds = (zeroTick - caughtTick) / 10;
|
||||
// ~10s warn + ~50s drain, income included: about a minute (NOT ~45s).
|
||||
expect(seconds).toBeGreaterThan(50);
|
||||
expect(seconds).toBeLessThan(85);
|
||||
// ~30s warn + the slower drain, income included: about two minutes.
|
||||
expect(seconds).toBeGreaterThan(90);
|
||||
expect(seconds).toBeLessThan(150);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user