v0.30.0 — Make the four stats consequential (military hard gate, stability takeovers, economy growth) #50

Closed
opened 2026-10-15 10:06:00 +02:00 by kamilkrzywda · 0 comments
Owner

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).
## 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).
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: OpenCode/History#50