Doomsday Clock: territory rot, so a doomed side actually dies - #4893
Doomsday Clock: territory rot, so a doomed side actually dies#4893Zixer1 wants to merge 3 commits into
Conversation
The Doomsday Clock cannot currently end a game. The troop drain stops at
drainFloorPercent of max, which cripples a doomed side without ever eliminating
it, and relinquish is the only thing that removes territory -- nothing calls it.
So a stalemate can outlive even the final wave with every challenger parked at
the floor, crippled but unkillable. From the last Major: R4 ended on the clock
with 16 players alive and the leader on 15.5% of the map, three of them holding
4, 22 and 64 tiles.
TERRITORY ROT is the finisher, and it is paced to a DEADLINE rather than a rate.
rotDeathSeconds is measured from the moment the skull appears, and each second
takes ceil(tilesLeft / secondsLeft). That quota is self-correcting, which is what
makes the guarantee hold:
- size stops mattering. Measured skull-to-dead at 200, 1,200 and 3,000 tiles:
199s in all three, on a 200s deadline.
- the dead time stops mattering. The warn, the comeback window and the drain
grinding to the floor all eat into the budget, and the quota inflates to
cover it. A slow start means a faster finish.
- there is no tail. On the deadline tick secondsLeft is 1, so the quota is
everything left. Nobody survives on 4 tiles.
It spreads rather than picking scattered tiles: a share of the territory is
peppered with pinholes over rotGrainSeconds (density from the territory, not the
second's quota -- quota-sized speckling is one or two holes a second on anything
but a huge empire), then the holes grow outward through their neighbours. Seeds
prefer the INTERIOR so decay starts inside rather than nibbling the frontier as if
someone were pushing on it. A blob walled in by an island edge falls back to
speckling, so islands still die on time.
WHAT IT LOOKS LIKE got as much attention as the timing, since a mechanic people
like and one they merely tolerate differ mostly in how they read.
Selecting perimeter tiles uniformly is precisely the Eden growth model, which is
provably compact -- fractal dimension 2, "roughly circular with a rough
circumference". Measured on an 80x80 field it filled 48% of its bounding box with
only 35% of its tiles on the boundary: a solid disc with a fuzzy rim.
Two changes fix that, neither of which touches the deadline:
- the front is ranked by how many of a tile's neighbours have already rotted,
fewest first, so TIPS outrun flat edges. Dielectric-breakdown growth makes the
rate proportional to the field gradient, which needs a Laplace solve; a tile's
rotted-neighbour count is a local stand-in for the same quantity, and it is
tallied incrementally as rot advances rather than recomputed, so ranking on it
costs nothing.
- ties break on an integer hash of the tile, and pinholes are placed from a
separate R2 low-discrepancy field.
Together those take the same field to 19% fill with 98% of tiles on the boundary --
lacy tendrils instead of a disc, confirmed at 99% through the real simulation. The
number of tiles removed each second is unchanged, so this alters WHICH tiles go,
never how many or how fast.
The two noise fields are not interchangeable, and each is wrong for the other's
job, which is worth knowing before merging them:
- the R2 lattice spaces picks near-perfectly -- 0% of picks touch a neighbour
against 32% for a hash -- because its low values form a CRYSTAL. Avalanching
its output destroys that and measures worse than white noise (36% touching).
- that same crystal is useless for growth: every pick's nearest neighbour sits at
one of six fixed offsets, so a front that always eats its lowest-valued tile
marches along them and grows a straight 80x20 filament. The front therefore
ranks on a hash, which has 41 distinct offsets and no preferred axis.
Both properties are pinned by tests, each having been broken once in development.
Dropping the shuffle removed the last PRNG from rot: tile choice is now pure
integer hashing, so no floating-point arithmetic remains in this path.
Only seeding scans a player's tiles; spreading is local, O(quota) per second
however large the empire. Measured 10,000 ticks of a 3,000-tile player in 48ms.
Nothing is credited: no conqueror is passed to relinquish, so there is no kill,
no killedBy and no captured gold, and the land simply becomes unowned. Structures
left standing on rotted ground need no handling here -- PlayerExecution already
deletes structures whose tile has no owner. Death falls out of the existing machinery (isAlive() is tiles > 0, and
PlayerExecution already stamps a death position for non-conquest deaths).
A DECAYING TROOP FLOOR makes it fair. Today's flat 5% leaves a doomed side no
army to fight back with, so rot would be a death sentence handed out slowly.
Raising the floor permanently is worse: maxTroops is sublinear in territory with
a ~100k floor at a single tile, so a fixed 40% would leave ~40k troops standing
on one tile and make a doomed side near-unconquerable. So the floor decays from
floorStartPercent to drainFloorPercent over floorDecaySeconds -- one genuine
window with a usable army, then it closes. Rot only begins after that window, so
the two stages do not overlap. Climbing back above the bar clears everything;
holes already rotted are not restored.
THE WAVE SCHEDULE is re-cut from 85 tournament games: seven small steps to a 35%
ceiling (2/4/7/11/17/25/35) instead of six accelerating ones to 55%.
- the ceiling. The runner-up's share at game end is median 8% and has never
once exceeded 21.6%, so any bar above ~16% catches exactly the same players.
A higher ceiling only climbed past the LEADER's own share (median 41%; 55%
exceeded everyone in 79% of games), handing the game to the crown exemption
instead of to territory.
- the step size. The field is bottom-heavy -- half the players alive at the end
hold under 0.4% of the map -- so a big jump sweeps that whole cluster into
the debuff at once, which is how games reached the state where 8 of 10
survivors were crippled simultaneously and nobody could do anything. Small
steps catch them a few at a time and each pause is a real chance to recover.
Note this changes what the last wave promises: floor(100/35) is two, so the bar
narrows the field but no longer forces a single winner by arithmetic alone. Rot
is what closes out the last pair.
ON THE CLIENT the decaying phase gets its own cue: a steady RED skull and a
"Decaying" caption, alongside the existing blinking (warn) and steady white
(draining) states. The sim stamps the tick it takes land and exposes isDecaying
over the same wire fields the doomsday mark already uses, with a short grace so
the cue cannot strobe.
Adding that field means wiring it into diffPlayerUpdate, which transmits only
CHANGED fields and keeps two independent lists of them: a fast-path early return
that ANDs every field and bails out with null, and the setIfDifferent block that
builds the payload. A field missing from either is silently never transmitted --
the client keeps its initial value forever -- and a field missing from the fast
path is worse, because a tick where only that field changed is judged "nothing
changed" and emits no update at all. tests/GameUpdateUtils.test.ts now walks every
scalar field on a PlayerUpdate, flips it, and asserts the diff carries it, so the
next field cannot ship half-wired. (tilesOwned/gold/troops are skipped there by
design -- they ride the packed transferable channel.) It is deliberately NOT re-derived on the client: the test
is a knife-edge equality (the drain lands exactly ON the floor) and the floor
moves as rot shrinks the troop cap, so a client-side copy flickers. The skull is
tinted in the shader from the existing white asset -- no new image. The danger
value the status shader reads was already an encoding (0 clear, 1.0-1.49 blinking
with the warn progress in the fraction, 2.0 steady), so decaying is 3.0; icon
visibility is a > 0.5 test and nothing compares it for equality.
TIMINGS, measured from a side's skull first appearing:
0s skull starts blinking, warn countdown
30s skull holds steady, troops start draining (warnSeconds 30)
120s floor bottoms out at 5%, territory starts rotting, skull turns RED
180s nothing left, side eliminated (rotDeathSeconds 180)
Verified in the real simulation at 150, 1,200 and 2,000 tiles: steady at 30s, red
at 120-131s (a sliver is a few seconds later, because maxTroops bottoms out near
100k however small the territory, so its drain takes longer to reach the floor),
dead at 179s with zero tiles left in every case. A test pins these numbers so
retuning them has to be deliberate rather than a side effect of touching the
drain or the floor.
The Doomsday Clock as a whole is still opt-in per lobby (GameConfig.doomsdayClock
.enabled defaults to false), so this only changes games that asked for it.
rotDeathSeconds 0 switches rot off while leaving the rest of the clock alone.
Tests: 63 in the doomsday suite. The floor's exact ramp, clamping, monotonicity
and inertness at the defaults; rot's gates, dying completely and on time, the
same deadline across sizes, finishing on time when rot starts late, not dumping
everything in one second, many scattered holes during the grainy phase, spreading
after it, structures deleted without credit, islands dying via the speckle
fallback, no tile taken twice, the leader never rotted, recovery restoring a full
budget, and determinism both ways (same seed identical, different player
different). A real-simulation test rots a live player off the map and asserts the
land ends unowned, the leader gains nothing, and isDecaying was true while it
happened. Four more cover the three skull phases through the status deriver.
180s from the skull was too long a death. The rot window (deadline minus warn minus floor decay) drops from 60s to 30s, so a doomed side is gone two and a half minutes after it is first marked instead of three. rotGrainSeconds comes down 20 -> 10 with it. The grainy opening is meant to be the first third of the rot window; left at 20s it would have been two thirds of a 30s window, so the speckle would read as the whole death rather than its opening. The rot quota is a self-correcting ceil(tilesLeft / secondsLeft), so the shorter window needs no other change: it simply takes more tiles per second. The timeline test now derives the window instead of hardcoding it, and pins the grain to a minority of it.
The panel told you the rate while troops were draining ("Collapsing
-{rate}/s") but went silent about the rate once territory started rotting,
which is the phase that actually kills you. It just said "Decaying".
Now it reads "Decaying -{rate} tiles/s", using the same quota the sim
applies. Because rot is a deadline rather than a rate, that number climbs
as the deadline nears, which is the honest thing to show.
The quota moves into doomsdayClockRotQuota() in DoomsdayClock.ts and both
the execution and the panel call it, so the number on screen cannot drift
from the number being applied. Keyed on the player's own tile count, since
rot is per player while the percentage above it is the team's share.
Note for translators: doomsday_clock.decaying takes a {rate} param now.
WalkthroughThe Doomsday Clock now uses dynamic troop-floor decay and deterministic territory rot. Player decay state propagates through updates to rendering and the HUD. Wave schedules, configuration, quotas, noise fields, and tests were updated. ChangesDoomsday Clock decay
Estimated code review effort: 5 (Critical) | ~90+ minutes Sequence Diagram(s)sequenceDiagram
participant DoomsdayClockExecution
participant PlayerImpl
participant ClientRenderer
DoomsdayClockExecution->>PlayerImpl: markRotted()
DoomsdayClockExecution->>PlayerImpl: relinquish rotted tiles
PlayerImpl-->>ClientRenderer: isDecaying in player update
ClientRenderer->>ClientRenderer: pack decaying red-skull status
Possibly related PRs
Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
src/core/execution/DoomsdayClockExecution.ts (1)
350-353: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winAvoid allocating a neighbor array for every consumed tile.
consume()callsmg.neighbors(tile)once per removed tile, andneighbors()returns a newTileRef[]each time. Usemg.forEachNeighbor()in this hot path to preserve the existing ownership check without the allocation.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/core/execution/DoomsdayClockExecution.ts` around lines 350 - 353, Update the neighbor traversal in consume() to use mg.forEachNeighbor() instead of mg.neighbors(tile), preserving the existing tiles.has(neighbour) ownership check and front count increment for each neighbor while avoiding per-tile array allocation.tests/GameUpdateUtils.test.ts (1)
460-469: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winThe walk only checks fields that
makePlayerUpdatesets.
scalarsis derived fromObject.keys(base), so a field thatmakePlayerUpdateomits is invisible to this test.teamandspawnTileare optional onPlayerUpdateand absent from the stub today.clanTagis present butnull, so the!== nullfilter drops it too. A new field added toPlayerUpdateand todiffPlayerUpdatebut not to the stub would still slip through the exact hole this block exists to close.The round-trip guard is still valuable. Please state the dependency in the comment so the next person updates both files together.
♻️ Proposed comment clarifying the stub dependency
const base = makePlayerUpdate({ id: "p1" }); + // NOTE: this walk can only see keys that makePlayerUpdate() actually sets. + // Add every new scalar PlayerUpdate field to that stub, or this guard skips it. + // Fields left null in the stub (clanTag) are also skipped and need their own test. const asRecord = base as unknown as Record<string, unknown>;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/GameUpdateUtils.test.ts` around lines 460 - 469, Update the comment for the scalar-field round-trip guard near scalars and the “covers a meaningful number of fields” test to explicitly state that the check only covers fields populated by makePlayerUpdate. Document that optional or null fields such as team, spawnTile, and clanTag are excluded, so changes to PlayerUpdate or diffPlayerUpdate require updating the stub together.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/core/execution/DoomsdayClockExecution.ts`:
- Around line 350-353: Update the neighbor traversal in consume() to use
mg.forEachNeighbor() instead of mg.neighbors(tile), preserving the existing
tiles.has(neighbour) ownership check and front count increment for each neighbor
while avoiding per-tile array allocation.
In `@tests/GameUpdateUtils.test.ts`:
- Around line 460-469: Update the comment for the scalar-field round-trip guard
near scalars and the “covers a meaningful number of fields” test to explicitly
state that the check only covers fields populated by makePlayerUpdate. Document
that optional or null fields such as team, spawnTile, and clanTag are excluded,
so changes to PlayerUpdate or diffPlayerUpdate require updating the stub
together.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 3bb919dd-ef3f-4b84-9850-cd19d20cf962
⛔ Files ignored due to path filters (2)
src/client/render/gl/shaders/name/status-icon.frag.glslis excluded by!**/*.glslsrc/client/render/gl/shaders/name/status-icon.vert.glslis excluded by!**/*.glsl
📒 Files selected for processing (19)
resources/lang/en.jsonsrc/client/components/DoomsdayClockPanel.tssrc/client/render/frame/derive/PlayerStatus.tssrc/client/render/gl/passes/name-pass/Types.tssrc/client/render/gl/passes/name-pass/index.tssrc/client/render/types/Renderer.tssrc/client/view/PlayerView.tssrc/core/configuration/Config.tssrc/core/execution/DoomsdayClockExecution.tssrc/core/game/DoomsdayClock.tssrc/core/game/Game.tssrc/core/game/GameUpdateUtils.tssrc/core/game/GameUpdates.tssrc/core/game/PlayerImpl.tstests/DoomsdayClockExecution.test.tstests/GameUpdateUtils.test.tstests/client/render/frame/derive/nuke-telegraphs.test.tstests/client/render/frame/derive/player-status.test.tstests/util/viewStubs.ts
Problem
The Doomsday Clock can't end a game. The drain stops at the troop floor, so a doomed side is crippled but never eliminated, and nothing ever removes territory. R4 of the last Major ended on the clock with 16 players alive, three of them holding 4, 22 and 64 tiles.
Change
Territory rot. A doomed side loses land until it holds none. Paced to a deadline (
rotDeathSecondsfrom the skull appearing), each second takingceil(tilesLeft / secondsLeft)— self-correcting, so the finish time is the same for a sliver and an empire. Rot spreads from interior seeds rather than picking scattered tiles, and takes any structure with the ground. Nothing is credited: no conqueror is passed, so no kill and no captured gold.Decaying troop floor. The floor starts high and decays to
drainFloorPercent, giving one window with a usable army to climb back above the bar. A permanently high floor would make a doomed side unconquerable (maxTroopsis sublinear, ~100k at a single tile).Wave schedule re-cut from 85 tournament games: seven small steps to 35% (
2/4/7/11/17/25/35) instead of six accelerating ones to 55%. The runner-up's share at game end has never exceeded 21.6%, so any bar above ~16% catches the same players — a higher ceiling only climbed past the leader's own share (median 41%) and handed the game to the crown exemption. Small steps matter because half the players alive at the end hold under 0.4% of the map, so a big jump debuffed most of the field at once.Timings
Verified in-sim at 150 / 1,200 / 2,000 tiles: dead at 179s with zero tiles in every case.
Notes
doomsdayClock.enabledstill defaults tofalse.rotDeathSeconds: 0disables rot alone.PseudoRandomseeded per(tick, player).isDecayingcomes from the sim rather than being re-derived client-side, because the phase test is a knife-edge equality that flickers when recomputed.O(quota)per second, not a per-tick territory scan — 10k ticks of a 3,000-tile player in 48ms.tests/GameUpdateUtils.test.tsnow asserts every scalarPlayerUpdatefield survivesdiffPlayerUpdate, which transmits only changed fields and is easy to half-wire.2642 tests pass (291 server),
tsc/oxlint/eslintclean.Caveat: on a 25-minute
fastround the 35% bar lands at the buzzer, so a player caught in the last two minutes won't finish dying before the cap. Fix is pulling the schedule earlier, not shortening the 180s.Moved from #4874 (same commits, same head SHA
1be7633). The branch lived on the main repo, which auto-deploys every branch to staging, so it now sits on the fork instead. Review history is on #4874.