feat(#50): make the four 0-100 stats consequential (v0.30.0) #51

Merged
kamilkrzywda merged 1 commits from feat/50-stats-consequential into master 2026-10-15 10:26:46 +02:00
Owner

Makes the four 0–100 stats actually drive outcomes instead of being decorative.

Military — hard gate (engine-enforced). A contested invasion (a region's sole owner changing hands) is repelled whenever the attacker's military is more than CONQUEST_MILITARY_MARGIN = 15 below the defender's — equal or within 15 still wins. Enforced in applyTurnResult; emits a new repelled event (a muted rose tone in the feed). Re-claims of unowned (no man's land) regions are never gated (no defender). The attacker's military is untouched by a repel.

Stability — soft lever (LLM-driven). The system prompt documents that a nation whose stability is below 30 and that hosts a gathering marker can suffer a non-military takeover (coup / revolution / faction seizure) — narrated with a political/social success event, a gathering marker, and a downward stability/economy shock. No hard gate.

Economy — soft lever (LLM-driven). A strong economy trends the other stats up (growth), a collapsing economy drags them down (decay), via stateChanges / regionChanges.

Payload hints. buildUserPrompt emits two bounded fields, each only when present: frontierMilitary (one line per frontier neighbour: [Your military: X] vs [Name: Y] — a direct invasion could succeed | would be repelled) and takeoverRisks (one line per low-stability + gathering nation). So the model — and the player, via the same data — sees the levers before acting.

  • shared/protocol.ts: new CONQUEST_MILITARY_MARGIN = 15 + pure canConquer(attackerMil, defenderMil, margin = 15).
  • shared/types.ts: EventOutcome += 'repelled' (engine-only).
  • src/lib/colors.ts: OUTCOME_COLORS.repelled = '#9f1239'.
  • server/engine.ts: gate in the snapshot-first claims loop; regionNameOf param; system-prompt "HOW THE FOUR 0-100 STATS DRIVE OUTCOMES" section (both modes); frontierMilitary + takeoverRisks in the user payload; threaded through runTurn.
  • server/index.ts: /jump wires regionNameOf.
  • Stub is 100% unchanged — the e2e (UK/wwii-1939, empty frontier) keeps its exact one-conquest behavior, so the gate is vacuous there; the new paths are unit-tested.
  • Tests: canConquer edges; the hard gate (proceed / repel + event / re-claim ungated / boundary at margin and +1); system + user prompt contracts. 273 tests green, typecheck/lint/build green.

Fixes #50

Makes the four 0–100 stats actually drive outcomes instead of being decorative. **Military — hard gate (engine-enforced).** A *contested* invasion (a region's sole owner changing hands) is **repelled** whenever the attacker's military is **more than `CONQUEST_MILITARY_MARGIN = 15`** below the defender's — equal or within 15 still wins. Enforced in `applyTurnResult`; emits a new `repelled` event (a muted rose tone in the feed). **Re-claims of unowned (no man's land) regions are never gated** (no defender). The attacker's military is untouched by a repel. **Stability — soft lever (LLM-driven).** The system prompt documents that a nation whose stability is **below 30** *and* that hosts a `gathering` marker can suffer a **non-military takeover** (coup / revolution / faction seizure) — narrated with a political/social `success` event, a gathering marker, and a downward stability/economy shock. No hard gate. **Economy — soft lever (LLM-driven).** A strong economy trends the other stats **up** (growth), a collapsing economy drags them **down** (decay), via `stateChanges` / `regionChanges`. **Payload hints.** `buildUserPrompt` emits two bounded fields, each only when present: `frontierMilitary` (one line per frontier neighbour: `[Your military: X] vs [Name: Y] — a direct invasion could succeed | would be repelled`) and `takeoverRisks` (one line per low-stability + gathering nation). So the model — and the player, via the same data — sees the levers before acting. - `shared/protocol.ts`: new `CONQUEST_MILITARY_MARGIN = 15` + pure `canConquer(attackerMil, defenderMil, margin = 15)`. - `shared/types.ts`: `EventOutcome` += `'repelled'` (engine-only). - `src/lib/colors.ts`: `OUTCOME_COLORS.repelled = '#9f1239'`. - `server/engine.ts`: gate in the snapshot-first claims loop; `regionNameOf` param; system-prompt "HOW THE FOUR 0-100 STATS DRIVE OUTCOMES" section (both modes); `frontierMilitary` + `takeoverRisks` in the user payload; threaded through `runTurn`. - `server/index.ts`: `/jump` wires `regionNameOf`. - **Stub is 100% unchanged** — the e2e (UK/wwii-1939, empty frontier) keeps its exact one-conquest behavior, so the gate is vacuous there; the new paths are unit-tested. - Tests: `canConquer` edges; the hard gate (proceed / repel + event / re-claim ungated / boundary at margin and +1); system + user prompt contracts. **273 tests green, typecheck/lint/build green.** Fixes #50
kamilkrzywda added 1 commit 2026-10-15 10:24:35 +02:00
feat(#50): make the four 0-100 stats consequential
CI / build + unit + e2e (pull_request) Successful in 2m8s
a93ece9474
- Add a hard military conquest gate (canConquer, CONQUEST_MILITARY_MARGIN=15)
  enforced in applyTurnResult: a contested invasion is repelled when the
  attacker's military is more than 15 below the defender's; re-claims of
  unowned regions are never gated. Emits a new 'repelled' event.
- Document the soft levers in the system prompt: stability<30 + gathering
  marker -> non-military takeover; economy growth/decay drives other stats.
- Emit bounded frontierMilitary (per-neighbour balance) and takeoverRisks
  hints in the user payload (only when present).
- repelled outcome color (#9f1239); full unit test coverage; stub untouched.
- Bump to v0.30.0.

Fixes #50
kamilkrzywda scheduled this pull request to auto merge when all checks succeed 2026-10-15 10:24:45 +02:00
kamilkrzywda merged commit 5d194636d0 into master 2026-10-15 10:26:46 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: OpenCode/History#51