diff --git a/resources/lang/en.json b/resources/lang/en.json index eb1208cb4..61a85b655 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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", diff --git a/src/client/components/DoomsdayClockPanel.ts b/src/client/components/DoomsdayClockPanel.ts index dde997b1b..1d515d368 100644 --- a/src/client/components/DoomsdayClockPanel.ts +++ b/src/client/components/DoomsdayClockPanel.ts @@ -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), diff --git a/src/client/render/frame/derive/PlayerStatus.ts b/src/client/render/frame/derive/PlayerStatus.ts index 8c2689c85..4e90829a4 100644 --- a/src/client/render/frame/derive/PlayerStatus.ts +++ b/src/client/render/frame/derive/PlayerStatus.ts @@ -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, diff --git a/src/client/render/gl/passes/name-pass/Types.ts b/src/client/render/gl/passes/name-pass/Types.ts index 768d123b6..9c491eca9 100644 --- a/src/client/render/gl/passes/name-pass/Types.ts +++ b/src/client/render/gl/passes/name-pass/Types.ts @@ -77,6 +77,7 @@ export interface PlayerSlot { nukeTargetsMe: boolean; inDoomsdayClock: boolean; doomsdayClockDraining: boolean; + doomsdayClockWarnProgress: number; traitorRemainingTicks: number; allianceFraction: number; allianceRemainingTicks: number; diff --git a/src/client/render/gl/passes/name-pass/index.ts b/src/client/render/gl/passes/name-pass/index.ts index d66037be5..4403c7726 100644 --- a/src/client/render/gl/passes/name-pass/index.ts +++ b/src/client/render/gl/passes/name-pass/index.ts @@ -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 diff --git a/src/client/render/gl/shaders/name/status-icon.vert.glsl b/src/client/render/gl/shaders/name/status-icon.vert.glsl index 3b755ee8d..6bd73c088 100644 --- a/src/client/render/gl/shaders/name/status-icon.vert.glsl +++ b/src/client/render/gl/shaders/name/status-icon.vert.glsl @@ -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; diff --git a/src/client/render/types/Renderer.ts b/src/client/render/types/Renderer.ts index d19c4b69e..80a8b5cc0 100644 --- a/src/client/render/types/Renderer.ts +++ b/src/client/render/types/Renderer.ts @@ -193,6 +193,7 @@ export interface PlayerStatusData { nukeTargetsMe: boolean; inDoomsdayClock: boolean; doomsdayClockDraining: boolean; + doomsdayClockWarnProgress: number; traitorRemainingTicks: number; allianceFraction: number; allianceRemainingTicks: number; diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index f6fbe9a37..eae63105b 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -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 { diff --git a/src/core/execution/DoomsdayClockExecution.ts b/src/core/execution/DoomsdayClockExecution.ts index 8ebd9b599..b507fdc2e 100644 --- a/src/core/execution/DoomsdayClockExecution.ts +++ b/src/core/execution/DoomsdayClockExecution.ts @@ -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, ), ); } diff --git a/src/core/game/DoomsdayClock.ts b/src/core/game/DoomsdayClock.ts index 1d3575c57..1cc64a24b 100644 --- a/src/core/game/DoomsdayClock.ts +++ b/src/core/game/DoomsdayClock.ts @@ -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 = { - // 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)); } diff --git a/tests/DoomsdayClockExecution.test.ts b/tests/DoomsdayClockExecution.test.ts index b4900ff49..61da62b9e 100644 --- a/tests/DoomsdayClockExecution.test.ts +++ b/tests/DoomsdayClockExecution.test.ts @@ -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["doomsdayClockConfig"]>; @@ -40,7 +42,9 @@ function sdConfig(over: Partial = {}): 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); }); });