Currently the four 0–100 stats (military / economy / stability / population) are largely cosmetic: they move on the bars but do not mechanically drive what can happen. This issue makes them consequential with a deliberate hard / soft split so the LLM stays the world engine while the engine enforces the one rule that must not be faked.
1. Military — HARD gate (engine-enforced)
New pure helper canConquer(attackerMil, defenderMil, margin = CONQUEST_MILITARY_MARGIN) in shared/protocol.ts, with CONQUEST_MILITARY_MARGIN = 15.
It returns true (the invasion proceeds) unless the attacker is more than margin below the defender — equal or within 15 is enough to win; more than 15 below → repelled.
In applyTurnResult territory step, for each contested key (a gain currently owned by a different sole owner): attacker = gainer.stats.military, defender = currentOwner.stats.military (the derived nation-level values). If !canConquer → repel: the key stays with the defender, a repelled event is emitted, the attacker is unchanged, and it never throws.
Re-claims of unowned (no-man's-land) keys are NOT gated — they proceed as before.
2. Stability — SOFT (LLM)
buildSystemPrompt documents that a nation with stability < 30 that has a gathering marker on one of its regions can undergo a non-military takeover (coup / revolution / faction seizure), narrated via the existing levers: a political/social event (outcome success) + a gathering marker + stateChanges that shock its stability and economy. No hard gate.
3. Economy — SOFT (LLM)
buildSystemPrompt instructs that economy drives development: a strong economy lets the other stats trend up over time (small per-turn growth); a weak economy lets them decay — reflected via stateChanges / regionChanges and narrated. No hard gate.
4. Prompt / payload
A frontier military-balance hint is added to the user payload for each player frontier neighbor: "[Your military: X] vs [Neighbor: Y] — a direct invasion would be repelled" (win side computed via canConquer), so the model and the player see the gate before it fires.
All three mechanics are documented in the system prompt (concise).
5. Event outcome
New EventOutcome value "repelled" (a distinct muted red failure tone in the client OUTCOME_COLORS). Engine-emitted only; the LLM contract stays success|failure|partial|neutral.
Text: "[Attacker]'s invasion of [region name] was repelled by [Defender]." (region display name from geo properties.name, else the key).
Constraints
server/stub.ts stays 100% unchanged (deterministic). E2e uses the UK in wwii-1939 with an empty frontier, so the gate is vacuous in e2e — CI stays green for any CONQUEST_MILITARY_MARGIN.
Version bump to 0.30.0 in package.json; CHANGELOG.md entry.
Acceptance
Unit tests: canConquer edge cases (within / over / equal / gap==margin / gap==margin+1 / margin=0); applyTurnResult territory gate (strong attacker proceeds, severely outmatched repelled — key stays + repelled event + attacker unchanged + no throw; re-claim of unowned NOT gated); repelled valid in the Event type and has a color; takeover apply path (event + stability/economy shock); economy growth/decay apply path; buildSystemPrompt documents all three; user payload includes the frontier balance hint. Existing stub tests still pass.
npm test + typecheck + lint + build green; e2e verified by CI. Deploy and verify a live qwen3.8-27b jump (no 502).
## Summary
Currently the four 0–100 stats (military / economy / stability / population) are largely cosmetic: they move on the bars but do not mechanically drive what can happen. This issue makes them consequential with a deliberate **hard / soft split** so the LLM stays the world engine while the engine enforces the one rule that must not be faked.
### 1. Military — HARD gate (engine-enforced)
- New pure helper `canConquer(attackerMil, defenderMil, margin = CONQUEST_MILITARY_MARGIN)` in `shared/protocol.ts`, with `CONQUEST_MILITARY_MARGIN = 15`.
- It returns `true` (the invasion proceeds) **unless the attacker is more than `margin` below the defender** — equal or within 15 is enough to win; more than 15 below → repelled.
- In `applyTurnResult` territory step, for each **contested** key (a `gain` currently owned by a *different* sole owner): `attacker = gainer.stats.military`, `defender = currentOwner.stats.military` (the derived nation-level values). If `!canConquer` → **repel**: the key stays with the defender, a `repelled` event is emitted, the attacker is unchanged, and it never throws.
- **Re-claims of unowned (no-man's-land) keys are NOT gated** — they proceed as before.
### 2. Stability — SOFT (LLM)
- `buildSystemPrompt` documents that a nation with **stability < 30** that has a `gathering` marker on one of its regions can undergo a **non-military takeover** (coup / revolution / faction seizure), narrated via the existing levers: a political/social event (outcome `success`) + a `gathering` marker + `stateChanges` that shock its stability and economy. No hard gate.
### 3. Economy — SOFT (LLM)
- `buildSystemPrompt` instructs that economy drives development: a strong economy lets the other stats trend up over time (small per-turn growth); a weak economy lets them decay — reflected via `stateChanges` / `regionChanges` and narrated. No hard gate.
### 4. Prompt / payload
- A **frontier military-balance hint** is added to the user payload for each player frontier neighbor: `"[Your military: X] vs [Neighbor: Y] — a direct invasion would be repelled"` (win side computed via `canConquer`), so the model and the player see the gate before it fires.
- All three mechanics are documented in the system prompt (concise).
### 5. Event outcome
- New `EventOutcome` value `"repelled"` (a distinct muted red failure tone in the client `OUTCOME_COLORS`). Engine-emitted only; the LLM contract stays `success|failure|partial|neutral`.
- Text: `"[Attacker]'s invasion of [region name] was repelled by [Defender]."` (region display name from geo `properties.name`, else the key).
### Constraints
- `server/stub.ts` stays **100% unchanged** (deterministic). E2e uses the UK in `wwii-1939` with an empty frontier, so the gate is vacuous in e2e — CI stays green for any `CONQUEST_MILITARY_MARGIN`.
- Version bump to **0.30.0** in `package.json`; `CHANGELOG.md` entry.
### Acceptance
- Unit tests: `canConquer` edge cases (within / over / equal / gap==margin / gap==margin+1 / margin=0); `applyTurnResult` territory gate (strong attacker proceeds, severely outmatched repelled — key stays + `repelled` event + attacker unchanged + no throw; re-claim of unowned NOT gated); `repelled` valid in the Event type and has a color; takeover apply path (event + stability/economy shock); economy growth/decay apply path; `buildSystemPrompt` documents all three; user payload includes the frontier balance hint. Existing stub tests still pass.
- `npm test` + `typecheck` + `lint` + `build` green; e2e verified by CI. Deploy and verify a live `qwen3.8-27b` jump (no 502).
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Currently the four 0–100 stats (military / economy / stability / population) are largely cosmetic: they move on the bars but do not mechanically drive what can happen. This issue makes them consequential with a deliberate hard / soft split so the LLM stays the world engine while the engine enforces the one rule that must not be faked.
1. Military — HARD gate (engine-enforced)
canConquer(attackerMil, defenderMil, margin = CONQUEST_MILITARY_MARGIN)inshared/protocol.ts, withCONQUEST_MILITARY_MARGIN = 15.true(the invasion proceeds) unless the attacker is more thanmarginbelow the defender — equal or within 15 is enough to win; more than 15 below → repelled.applyTurnResultterritory step, for each contested key (againcurrently owned by a different sole owner):attacker = gainer.stats.military,defender = currentOwner.stats.military(the derived nation-level values). If!canConquer→ repel: the key stays with the defender, arepelledevent is emitted, the attacker is unchanged, and it never throws.2. Stability — SOFT (LLM)
buildSystemPromptdocuments that a nation with stability < 30 that has agatheringmarker on one of its regions can undergo a non-military takeover (coup / revolution / faction seizure), narrated via the existing levers: a political/social event (outcomesuccess) + agatheringmarker +stateChangesthat shock its stability and economy. No hard gate.3. Economy — SOFT (LLM)
buildSystemPromptinstructs that economy drives development: a strong economy lets the other stats trend up over time (small per-turn growth); a weak economy lets them decay — reflected viastateChanges/regionChangesand narrated. No hard gate.4. Prompt / payload
"[Your military: X] vs [Neighbor: Y] — a direct invasion would be repelled"(win side computed viacanConquer), so the model and the player see the gate before it fires.5. Event outcome
EventOutcomevalue"repelled"(a distinct muted red failure tone in the clientOUTCOME_COLORS). Engine-emitted only; the LLM contract stayssuccess|failure|partial|neutral."[Attacker]'s invasion of [region name] was repelled by [Defender]."(region display name from geoproperties.name, else the key).Constraints
server/stub.tsstays 100% unchanged (deterministic). E2e uses the UK inwwii-1939with an empty frontier, so the gate is vacuous in e2e — CI stays green for anyCONQUEST_MILITARY_MARGIN.package.json;CHANGELOG.mdentry.Acceptance
canConqueredge cases (within / over / equal / gap==margin / gap==margin+1 / margin=0);applyTurnResultterritory gate (strong attacker proceeds, severely outmatched repelled — key stays +repelledevent + attacker unchanged + no throw; re-claim of unowned NOT gated);repelledvalid in the Event type and has a color; takeover apply path (event + stability/economy shock); economy growth/decay apply path;buildSystemPromptdocuments all three; user payload includes the frontier balance hint. Existing stub tests still pass.npm test+typecheck+lint+buildgreen; e2e verified by CI. Deploy and verify a liveqwen3.8-27bjump (no 502).