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 19:38:36 -07:00
committed by GitHub
parent 1db65a1877
commit 4ce57efbe2
11 changed files with 275 additions and 146 deletions
@@ -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),
+14 -3
View File
@@ -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;
+1
View File
@@ -193,6 +193,7 @@ export interface PlayerStatusData {
nukeTargetsMe: boolean;
inDoomsdayClock: boolean;
doomsdayClockDraining: boolean;
doomsdayClockWarnProgress: number;
traitorRemainingTicks: number;
allianceFraction: number;
allianceRemainingTicks: number;