Doomsday Clock: territory rot, so a doomed side actually dies - #4874
Doomsday Clock: territory rot, so a doomed side actually dies#4874Zixer1 wants to merge 3 commits into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThe Doomsday Clock now uses seven waves ending at 35%, a decaying troop floor, and deterministic territory rot. Players expose decay state through updates. Client rendering shows a dedicated Decaying status and encodes it as a steady red skull indicator. ChangesDoomsday Clock decay
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant DoomsdayClockExecution
participant PlayerImpl
participant GameUpdateUtils
participant PlayerView
participant PlayerStatus
participant NamePass
DoomsdayClockExecution->>PlayerImpl: Mark player after territory rot
PlayerImpl->>GameUpdateUtils: Include isDecaying in PlayerUpdate
GameUpdateUtils->>PlayerView: Apply decay state
PlayerView->>PlayerStatus: Expose isDecaying()
PlayerStatus->>NamePass: Emit doomsdayClockDecaying
NamePass->>NamePass: Encode status as 3.0
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
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.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
tests/DoomsdayClockExecution.test.ts (1)
41-41: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winStale comment: the veryfast preset now tops out at 35%, not 30%.
The test at line 634 asserts
doomsdayClockRequiredTiles("veryfast", land, 900)is 3500, which is 35%. Update the inline note so it matches the shipped schedule.📝 Proposed fix
- speed: "veryfast", // waves rise to 30% by 15:00 + speed: "veryfast", // waves rise to 35% by 15:00🤖 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/DoomsdayClockExecution.test.ts` at line 41, Update the inline comment next to the veryfast speed setting in the DoomsdayClock test to state that waves rise to 35% by 15:00, matching the existing 3500 assertion and shipped schedule.
🧹 Nitpick comments (1)
src/core/execution/DoomsdayClockExecution.ts (1)
300-311: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueOptional: skip the second
speckle()scan on the first rot second.On the first rot second
frontis empty, sospread()returns 0 and the fallback at line 311 always runs.speckle()scans every tile of the player, so that second is guaranteed to do two fullO(tiles)passes. The cost is bounded and happens once per doom spell, so this is only a tidiness point.If you want to avoid the repeat scan, let the first
speckle()call take the full budget when the front is empty.♻️ Proposed refactor
- budget -= this.speckle( - mg, - player, - random, - Math.min(specks, budget), - front, - built, - ); + // With no front to grow, the whole budget has to be speckled anyway, so + // take it in one scan instead of two. + const seedCount = front.length === 0 ? budget : Math.min(specks, budget); + budget -= this.speckle(mg, player, random, seedCount, front, built);🤖 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 300 - 311, Update the budget allocation around the first speckle/spread sequence in the execution method so an empty front passes the full remaining budget to the initial speckle call, avoiding the guaranteed fallback scan on the first rot second. Preserve the existing spread and fallback behavior for non-empty fronts.
🤖 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.
Outside diff comments:
In `@tests/DoomsdayClockExecution.test.ts`:
- Line 41: Update the inline comment next to the veryfast speed setting in the
DoomsdayClock test to state that waves rise to 35% by 15:00, matching the
existing 3500 assertion and shipped schedule.
---
Nitpick comments:
In `@src/core/execution/DoomsdayClockExecution.ts`:
- Around line 300-311: Update the budget allocation around the first
speckle/spread sequence in the execution method so an empty front passes the
full remaining budget to the initial speckle call, avoiding the guaranteed
fallback scan on the first rot second. Preserve the existing spread and fallback
behavior for non-empty fronts.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 873cedd8-6dbd-4996-9285-bab63122784e
⛔ 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
37380a1 to
ed9d701
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
tests/DoomsdayClockExecution.test.ts (2)
1007-1013: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a case for
rotGrainSeconds: 0.
rot()computes the speck count with(state.held * cfg.rotSpecklePercent) / 100 / cfg.rotGrainSeconds. WithrotGrainSeconds: 0that division yieldsInfinity, and the budget becomes the whole territory. Today thegrainyguard (mg.ticks() - state.since < cfg.rotGrainSeconds * 10) is false at the first rot second, so the division never runs. That safety depends on the guard, not on the arithmetic.Add a test that sets
rotGrainSeconds: 0and asserts the territory is not removed in one second. This locks the guard against a future edit.💚 Proposed test
it("does not wipe everything at once when the grainy phase is disabled", () => { const g = rotGame({ rotGrainSeconds: 0 }); rotFor(g, 1); // No grainy phase, so the even quota alone applies: a small first bite. expect(g.doomed.numTilesOwned()).toBeGreaterThan(90); expect(g.doomed.relinquished.length).toBeGreaterThan(0); });🤖 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/DoomsdayClockExecution.test.ts` around lines 1007 - 1013, Add a test alongside the existing rotDeathSeconds coverage that creates a game with rotGrainSeconds set to 0, advances it by one second via rotFor, and verifies the territory is not wiped out while some tiles are relinquished. Name the test to reflect that disabling the grainy phase still applies only the small initial quota.
616-627: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDerive the wave levels from
DoomsdayClockso the test catches schedule changes.The current
levelsarray is hard-coded and asserts against itself, so changing the schedule insrc/core/game/DoomsdayClock.tscan pass this test. Read the intended level milestones from the shared schedule instead, for example by samplingdoomsdayClockRequiredTilesat the normal ramp/pause caps.🤖 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/DoomsdayClockExecution.test.ts` around lines 616 - 627, Update the test around the “steps gently and never jumps more than 10 points at once” case to derive levels from DoomsdayClock’s shared schedule instead of hard-coded values. Sample doomsdayClockRequiredTiles at the normal ramp/pause caps, then retain the existing step-size and dense-bottom assertions so schedule changes are detected.
🤖 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.
Inline comments:
In `@tests/DoomsdayClockExecution.test.ts`:
- Around line 1259-1264: In the test setup around inner and the three
small.buildUnit calls, assert that inner contains at least 9 tiles before
indexing it. Keep the existing city placement and length assertion unchanged, so
insufficient interior capacity fails at the explicit precondition rather than
passing undefined to buildUnit.
- Around line 483-486: Update the stale wave-percentage comment in teamGame to
say 25% instead of 26%, and revise the rotGame giveTiles comment to describe the
250 bar instead of 260. Change only these comment values so they match the
current schedule and bar size.
---
Nitpick comments:
In `@tests/DoomsdayClockExecution.test.ts`:
- Around line 1007-1013: Add a test alongside the existing rotDeathSeconds
coverage that creates a game with rotGrainSeconds set to 0, advances it by one
second via rotFor, and verifies the territory is not wiped out while some tiles
are relinquished. Name the test to reflect that disabling the grainy phase still
applies only the small initial quota.
- Around line 616-627: Update the test around the “steps gently and never jumps
more than 10 points at once” case to derive levels from DoomsdayClock’s shared
schedule instead of hard-coded values. Sample doomsdayClockRequiredTiles at the
normal ramp/pause caps, then retain the existing step-size and dense-bottom
assertions so schedule changes are detected.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: df90860d-e1a3-40d7-805a-78cc257b2d2d
⛔ 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
🚧 Files skipped from review as they are similar to previous changes (17)
- src/client/render/gl/passes/name-pass/Types.ts
- src/client/render/types/Renderer.ts
- src/core/game/Game.ts
- src/core/game/GameUpdates.ts
- resources/lang/en.json
- src/client/view/PlayerView.ts
- src/client/components/DoomsdayClockPanel.ts
- src/client/render/frame/derive/PlayerStatus.ts
- tests/GameUpdateUtils.test.ts
- tests/util/viewStubs.ts
- tests/client/render/frame/derive/player-status.test.ts
- src/core/game/GameUpdateUtils.ts
- tests/client/render/frame/derive/nuke-telegraphs.test.ts
- src/client/render/gl/passes/name-pass/index.ts
- src/core/game/PlayerImpl.ts
- src/core/game/DoomsdayClock.ts
- src/core/configuration/Config.ts
ed9d701 to
8011979
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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.
Inline comments:
In `@src/core/execution/DoomsdayClockExecution.ts`:
- Around line 32-54: Update PseudoRandom with an integer-only bounded-selection
API and use it in DoomsdayClockExecution for rot tile selection instead of
nextInt’s fractional path. Replace the quota and speckle calculations in the rot
logic with integer ceiling-division arithmetic, removing division-based
floating-point operations and Math.ceil while preserving deterministic seeded
behavior.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 9ce1fca3-88b9-40ee-b631-8e650210ce01
⛔ 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
🚧 Files skipped from review as they are similar to previous changes (18)
- src/client/render/types/Renderer.ts
- src/core/game/GameUpdateUtils.ts
- tests/GameUpdateUtils.test.ts
- src/core/game/GameUpdates.ts
- src/client/render/frame/derive/PlayerStatus.ts
- src/core/game/Game.ts
- src/client/components/DoomsdayClockPanel.ts
- tests/client/render/frame/derive/nuke-telegraphs.test.ts
- tests/util/viewStubs.ts
- src/core/configuration/Config.ts
- resources/lang/en.json
- src/core/game/PlayerImpl.ts
- src/core/game/DoomsdayClock.ts
- src/client/render/gl/passes/name-pass/Types.ts
- tests/client/render/frame/derive/player-status.test.ts
- src/client/view/PlayerView.ts
- tests/DoomsdayClockExecution.test.ts
- src/client/render/gl/passes/name-pass/index.ts
8011979 to
0a6a72e
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
61213d4 to
5308c34
Compare
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.
5308c34 to
f5052be
Compare
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.
|
Continued in #4893. Same commits, same head SHA Review history stays here for reference. |
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.