Skip to content

Doomsday Clock: territory rot, so a doomed side actually dies - #4874

Closed
Zixer1 wants to merge 3 commits into
mainfrom
feat/doomsday-territory-rot
Closed

Doomsday Clock: territory rot, so a doomed side actually dies#4874
Zixer1 wants to merge 3 commits into
mainfrom
feat/doomsday-territory-rot

Conversation

@Zixer1

@Zixer1 Zixer1 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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 (rotDeathSeconds from the skull appearing), each second taking ceil(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 (maxTroops is 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

from skull skull
0s warn countdown blinking white
30s troops draining steady white
120s territory rotting steady red, "Decaying"
180s eliminated

Verified in-sim at 150 / 1,200 / 2,000 tiles: dead at 179s with zero tiles in every case.

Notes

  • Opt-in per lobby — doomsdayClock.enabled still defaults to false. rotDeathSeconds: 0 disables rot alone.
  • Deterministic: integer-only, rot's choices via PseudoRandom seeded per (tick, player).
  • The red skull is a shader tint of the existing asset, no new image. isDecaying comes from the sim rather than being re-derived client-side, because the phase test is a knife-edge equality that flickers when recomputed.
  • Spreading is O(quota) per second, not a per-tick territory scan — 10k ticks of a 3,000-tile player in 48ms.
  • tests/GameUpdateUtils.test.ts now asserts every scalar PlayerUpdate field survives diffPlayerUpdate, which transmits only changed fields and is easy to half-wire.

2642 tests pass (291 server), tsc/oxlint/eslint clean.

Caveat: on a 25-minute fast round 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.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The 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.

Changes

Doomsday Clock decay

Layer / File(s) Summary
Clock schedule, floor, and territory rot
src/core/game/DoomsdayClock.ts, src/core/configuration/Config.ts, src/core/execution/DoomsdayClockExecution.ts, tests/DoomsdayClockExecution.test.ts
The clock uses seven waves ending at 35%. The troop floor decays over time. Territory rot removes tiles through deterministic speckling and frontier spreading, removes structures, and clears state during recovery.
Authoritative decay state propagation
src/core/game/Game.ts, src/core/game/GameUpdates.ts, src/core/game/GameUpdateUtils.ts, src/core/game/PlayerImpl.ts, tests/GameUpdateUtils.test.ts, tests/util/viewStubs.ts
Players record recent rot events and expose isDecaying(). The state is serialized, diffed, applied, and preserved across partial updates.
Decaying status rendering
src/client/render/types/Renderer.ts, src/client/view/PlayerView.ts, src/client/render/frame/derive/PlayerStatus.ts, src/client/render/gl/passes/name-pass/*, src/client/components/DoomsdayClockPanel.ts, resources/lang/en.json, tests/client/render/*
The client derives the decaying status, displays the Decaying phase, uses the shared troop-floor calculation, and packs decaying status as 3.0 with precedence over draining and warning states.

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
Loading

Possibly related PRs

Suggested reviewers: evanpelle

Poem

The clock waves end at thirty-five,
The fading floor keeps troops alive.
Rot spreads tiles by measured thread,
Red skulls mark the players led.
Updates carry decay through,
Recovery clears the state anew.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.94% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: adding territory rot so doomed players can be eliminated.
Description check ✅ Passed The description directly explains the territory rot, troop-floor decay, wave schedule, client changes, and validation results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Zixer1 Zixer1 added this to the v34 milestone Aug 5, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Stale 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 value

Optional: skip the second speckle() scan on the first rot second.

On the first rot second front is empty, so spread() 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 full O(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

📥 Commits

Reviewing files that changed from the base of the PR and between d77e4a0 and 37380a1.

⛔ Files ignored due to path filters (2)
  • src/client/render/gl/shaders/name/status-icon.frag.glsl is excluded by !**/*.glsl
  • src/client/render/gl/shaders/name/status-icon.vert.glsl is excluded by !**/*.glsl
📒 Files selected for processing (19)
  • resources/lang/en.json
  • src/client/components/DoomsdayClockPanel.ts
  • src/client/render/frame/derive/PlayerStatus.ts
  • src/client/render/gl/passes/name-pass/Types.ts
  • src/client/render/gl/passes/name-pass/index.ts
  • src/client/render/types/Renderer.ts
  • src/client/view/PlayerView.ts
  • src/core/configuration/Config.ts
  • src/core/execution/DoomsdayClockExecution.ts
  • src/core/game/DoomsdayClock.ts
  • src/core/game/Game.ts
  • src/core/game/GameUpdateUtils.ts
  • src/core/game/GameUpdates.ts
  • src/core/game/PlayerImpl.ts
  • tests/DoomsdayClockExecution.test.ts
  • tests/GameUpdateUtils.test.ts
  • tests/client/render/frame/derive/nuke-telegraphs.test.ts
  • tests/client/render/frame/derive/player-status.test.ts
  • tests/util/viewStubs.ts

coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 5, 2026
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
tests/DoomsdayClockExecution.test.ts (2)

1007-1013: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case for rotGrainSeconds: 0.

rot() computes the speck count with (state.held * cfg.rotSpecklePercent) / 100 / cfg.rotGrainSeconds. With rotGrainSeconds: 0 that division yields Infinity, and the budget becomes the whole territory. Today the grainy guard (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: 0 and 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 win

Derive the wave levels from DoomsdayClock so the test catches schedule changes.

The current levels array is hard-coded and asserts against itself, so changing the schedule in src/core/game/DoomsdayClock.ts can pass this test. Read the intended level milestones from the shared schedule instead, for example by sampling doomsdayClockRequiredTiles at 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

📥 Commits

Reviewing files that changed from the base of the PR and between d77e4a0 and ed9d701.

⛔ Files ignored due to path filters (2)
  • src/client/render/gl/shaders/name/status-icon.frag.glsl is excluded by !**/*.glsl
  • src/client/render/gl/shaders/name/status-icon.vert.glsl is excluded by !**/*.glsl
📒 Files selected for processing (19)
  • resources/lang/en.json
  • src/client/components/DoomsdayClockPanel.ts
  • src/client/render/frame/derive/PlayerStatus.ts
  • src/client/render/gl/passes/name-pass/Types.ts
  • src/client/render/gl/passes/name-pass/index.ts
  • src/client/render/types/Renderer.ts
  • src/client/view/PlayerView.ts
  • src/core/configuration/Config.ts
  • src/core/execution/DoomsdayClockExecution.ts
  • src/core/game/DoomsdayClock.ts
  • src/core/game/Game.ts
  • src/core/game/GameUpdateUtils.ts
  • src/core/game/GameUpdates.ts
  • src/core/game/PlayerImpl.ts
  • tests/DoomsdayClockExecution.test.ts
  • tests/GameUpdateUtils.test.ts
  • tests/client/render/frame/derive/nuke-telegraphs.test.ts
  • tests/client/render/frame/derive/player-status.test.ts
  • tests/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

Comment thread tests/DoomsdayClockExecution.test.ts
Comment thread tests/DoomsdayClockExecution.test.ts
@github-project-automation github-project-automation Bot moved this from Triage to Development in OpenFront Release Management Aug 5, 2026
@Zixer1
Zixer1 force-pushed the feat/doomsday-territory-rot branch from ed9d701 to 8011979 Compare August 5, 2026 03:30
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between d77e4a0 and 8011979.

⛔ Files ignored due to path filters (2)
  • src/client/render/gl/shaders/name/status-icon.frag.glsl is excluded by !**/*.glsl
  • src/client/render/gl/shaders/name/status-icon.vert.glsl is excluded by !**/*.glsl
📒 Files selected for processing (19)
  • resources/lang/en.json
  • src/client/components/DoomsdayClockPanel.ts
  • src/client/render/frame/derive/PlayerStatus.ts
  • src/client/render/gl/passes/name-pass/Types.ts
  • src/client/render/gl/passes/name-pass/index.ts
  • src/client/render/types/Renderer.ts
  • src/client/view/PlayerView.ts
  • src/core/configuration/Config.ts
  • src/core/execution/DoomsdayClockExecution.ts
  • src/core/game/DoomsdayClock.ts
  • src/core/game/Game.ts
  • src/core/game/GameUpdateUtils.ts
  • src/core/game/GameUpdates.ts
  • src/core/game/PlayerImpl.ts
  • tests/DoomsdayClockExecution.test.ts
  • tests/GameUpdateUtils.test.ts
  • tests/client/render/frame/derive/nuke-telegraphs.test.ts
  • tests/client/render/frame/derive/player-status.test.ts
  • tests/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

Comment thread src/core/execution/DoomsdayClockExecution.ts
@Zixer1 Zixer1 modified the milestones: v34, Backlog Aug 5, 2026
@Zixer1
Zixer1 force-pushed the feat/doomsday-territory-rot branch from 8011979 to 0a6a72e Compare August 5, 2026 03:42
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 5, 2026
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.
@Zixer1

Zixer1 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Continued in #4893.

Same commits, same head SHA 1be7633, nothing rebased. The branch was living on the main repo, which auto-deploys every branch to staging, so it has moved to the fork (Zixer1:feat/doomsday-territory-rot) and the branch here is being deleted. GitHub does not allow changing a PR's head repository, hence the new number.

Review history stays here for reference.

@Zixer1 Zixer1 closed this Aug 6, 2026
@Zixer1
Zixer1 deleted the feat/doomsday-territory-rot branch August 6, 2026 03:30
@github-project-automation github-project-automation Bot moved this from Development to Complete in OpenFront Release Management Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

1 participant