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 30and 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).
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.
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
- 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
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.
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 = 15below the defender's — equal or within 15 still wins. Enforced inapplyTurnResult; emits a newrepelledevent (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
gatheringmarker can suffer a non-military takeover (coup / revolution / faction seizure) — narrated with a political/socialsuccessevent, 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.
buildUserPromptemits 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) andtakeoverRisks(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: newCONQUEST_MILITARY_MARGIN = 15+ purecanConquer(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;regionNameOfparam; system-prompt "HOW THE FOUR 0-100 STATS DRIVE OUTCOMES" section (both modes);frontierMilitary+takeoverRisksin the user payload; threaded throughrunTurn.server/index.ts:/jumpwiresregionNameOf.canConqueredges; 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