Commit Graph
19 Commits
Author SHA1 Message Date
kamilkrzywda 5477336537 feat: #66 contested regions / frontlines — 12th ability (v0.44.0)
CI / build + unit + e2e (pull_request) Successful in 2m10s
A third region state (contested frontline): a region in no nation's territory and not in unowned, opened/resolved by the LLM, tied to an active war. Sole-ownership invariant preserved. Durable GameState.contested; contested-inert stub; map legend stays at 7.
2026-10-16 15:21:45 +00:00
kamilkrzywda c77e81c45b feat: #65 sea control — 11th ability (v0.43.0)
CI / build + unit + e2e (pull_request) Successful in 2m33s
Adds the sea ability: a fixed set of 12 named sea zones with sole-control
semantics, contest-by-higher-military, and a deterministic per-turn
+1 military/+1 economy bonus for each claimed zone. Durable (owns
GameState.seaZones), additive migration, stub-inert (e2e unaffected).

Fixes #65

Design note: Gitea #65 sketched a free-form 'naval' layer; the implemented
approved design is this richer 'sea' layer with a fixed zone set.
2026-10-16 12:50:22 +00:00
kamilkrzywda c90e9ae753 feat(abilities): add disasters & pandemics lever (v0.41.0, #63)
CI / build + unit + e2e (pull_request) Successful in 1m55s
Add the 9th modular LLM ability: a turn-transient `TurnResult.disasters`
lever letting the LLM declare natural/man-made disasters (earthquake /
flood / volcanic / wildfire / famine / plague / epidemic) on a nation or
a region, applying a DETERMINISTIC (type, severity) stat dip (no hidden
randomness). Like the hybrid layer it owns no GameState field, needs no
db.ts change, and leaves no durable list — an ongoing plague/epidemic is
simply re-declared each turn it persists.

- shared/protocol.ts: DisasterType / DisasterSeverity / DisasterEvent /
  DisastersOps, and `TurnResult.disasters` (between `ideology` and
  `markers`; required, default `{ events: [] }`).
- server/abilities/disasters.ts: the ability module (validate / apply /
  schemaLine / constraint / isMeaningful / inert stub / empty payload; no
  stateFields). `DISASTER_EFFECTS` is the deterministic (type, severity)
  dip table (population never touched). Target resolution mirrors hybrid
  (a region key hits its SOLE owner; 0/2+ owners dropped) but has NO
  `from` field, so there is no self-target drop. Dips clamp to 0-100,
  mirror to the target's regions (regionStats), and re-derive the target
  nation's stats (same 0-region / no-entry guards).
- Registered last in ABILITIES (after ideology). System-prompt goldens
  regenerated (one schema line + one constraint bullet each); the
  user.txt golden stays byte-identical (payload() is {}).
- New server/abilities/disasters.test.ts (33 tests); registry.test.ts
  updated to the 9-ability contract.
- Secondary cleanups from the v0.40 refactor: AGENTS.md (abilities/ in
  the Layout tree + a "how to add a new ability" bullet + a disasters
  bullet), delete the orphaned validateTakeovers JSDoc, fold both inline
  `as unknown` casts into the top-level ./abilities import, and de-dup
  `str` (now imported from ./abilities/shared).

Version bumped 0.40.0 -> 0.41.0.
2026-10-16 09:15:55 +00:00
kamilkrzywda 15a1642eec refactor(#74) P4: wire engine/stub/db through the ABILITIES registry
Behavior-preserving rewire: every per-ability code path now iterates the
ordered ABILITIES registry instead of hand-enumerating the eight fields.

- engine.ts: buildSystemPrompt (schema lines + constraint bullets),
  buildUserPrompt (payload keys at their historical positions),
  validateTurnResult (per-field validate), applyTurnResult (ordered apply
  block), isMeaningful, and the runTurn "unclear" fallback all drive off
  ABILITIES. The eight moved ability bodies (~1,249 lines) are deleted;
  the names the engine and its tests import are re-exported from
  ./abilities.
- abilities/types.ts: Ability.stub is a zero-arg method (the stub has no
  GameState/AbilityCtx to pass; it needs none) — all eight implementations
  were already arg-less.
- stub.ts: deterministicTurn fills the eight ability fields from the
  registry (each contributes its inert default), between the core fields
  and markers — JSON output stays byte-identical (verified against
  origin/master).
- db.ts: the legacy-row migration for ability-owned GameState fields
  (wars/trades/embargoes/industry) iterates the registry's stateFields,
  only-if-undefined, instead of a hardcoded field list.

No client changes, no new deps, no shared/ runtime deps. typecheck + lint
+ 511 unit tests green; stub JSON byte-identical to origin/master.

Fixes #74
2026-10-16 07:23:11 +00:00
kamilkrzywda 7866a1987d feat(#62): ideology / government-type change (v0.39.0)
CI / build + unit + e2e (pull_request) Successful in 2m33s
Add a per-nation free-form ideology (form of government) field and a
TurnResult.ideology lever (coups / revolutions / reforms / constitutional
changes) with a deterministic -5 stability dip and political narration.

- shared/types.ts: Nation.ideology?: string (optional/additive)
- shared/protocol.ts: IdeologyChange / IdeologyOps, IDEOLOGY_STABILITY_EFFECT
  = -5, TurnResult.ideology (required, between leaders and markers),
  nationForPrompt now carries ideology
- server/engine.ts: validateIdeologyOps (exported, never throws),
  applyIdeologyChange (resolve -> set field -> -5 dip mirrored to regions +
  re-derived -> political event), ideology block in applyTurnResult after
  leaders / before markers, isMeaningful + validateTurnResult + system prompt
  + runTurn unclear fallback
- server/stub.ts: StubPayload gains ideology; deterministicTurn returns
  ideology: { changes: [] } (never changes one, e2e unaffected)
- Tests: engine.test.ts (validate / isMeaningful / applyTurnResult /
  buildUserPrompt), protocol.test.ts (constant + nationForPrompt),
  db.test.ts (legacy load + round-trip); every TurnResult fixture updated
- package.json -> 0.39.0; CHANGELOG v0.39.0 entry
2026-10-15 20:54:18 +00:00
kamilkrzywda 8730a56170 feat(#61): named leaders — assassination / death / appointment with succession (v0.38.0)
CI / build + unit + e2e (pull_request) Successful in 2m33s
Per-nation named leaders (Nation.leader) and a required TurnResult.leaders
lever ({ changes: [{ target, name, cause }] }). Each change sets the target
nation's leader and applies a deterministic stability dip by cause
(assassination -15, death -3, appointed 0) mirrored to its regions and
re-derived into its stats, plus a narration event (military for an
assassination, political for a death/appointment). An assassination/death
requires the target to already have a leader (a leaderless target is
dropped); an appointment always creates/sets one. Unknown/ambiguous targets
are dropped, never thrown.

- shared/types.ts: optional Nation.leader
- shared/protocol.ts: LeaderCause/LeaderChange/LeaderOps, LEADER_STABILITY_EFFECT,
  TurnResult.leaders (required, between nuclear and markers), leader in nationForPrompt
- server/engine.ts: validateLeaderOps, applyLeaderChange, the applyTurnResult
  leader block (after nuclear, before markers), isMeaningful, system-prompt
  contract + constraint, validateTurnResult + runTurn fallback
- server/stub.ts: leaders always { changes: [] } (e2e unaffected)
- tests: 15 engine.test.ts leader tests + 2 db.test.ts round-trips
- version 0.37.0 -> 0.38.0, CHANGELOG entry
2026-10-15 20:09:19 +00:00
kamilkrzywda 31182c8730 feat(#60): nuclear/WMD — capability flag + strikes (region → no-man's-land, nation collapse + war capitulation, MAD retaliation) (v0.37.0)
CI / build + unit + e2e (pull_request) Successful in 2m33s
2026-10-15 19:17:26 +00:00
kamilkrzywda b237fe8dd4 feat: Resources & Industry — per-region resource profiles + industry LLM lever (v0.36.0, #59)
CI / build + unit + e2e (pull_request) Successful in 2m11s
- scripts/build-region-resources.mjs: deterministic, idempotent builder →
  public/region-resources.json (1,800 entries; FNV-1a hash × area factor, 0–1, 2dp)
- server/regions.ts: hasResources()/resourceOf() optional-manifest loader
- shared/types.ts: RegionResources, IndustryType/INDUSTRY_TYPES, IndustryProject,
  GameState.industry
- shared/protocol.ts: IndustryOps, TurnResult.industry, deriveNationResources
  (area×population-weighted average, 2dp)
- server/engine.ts: isIndustryType, validateIndustryOps, applyIndustryOps
  (per-region SET, first-declared-type wins), applyIndustryEffects
  (INDUSTRY_ECONOMY_BONUS=1, +1 when region industry≥0.5; sole-owner only;
  0/2+ owner regions kept but no boost; re-derive after), system+user prompt
  contract lines, runTurn resourceOf opt, nationResources in user payload
- server/db.ts: legacy-row industry default migration
- server/stub.ts: industry:{projects:[]} in stub result; resources/industry
  in StubPayload (never read)
- server/index.ts: resourceOf wired to jump route
- All test fixtures updated; new describe blocks in engine.test.ts,
  protocol.test.ts, regions.test.ts, db.test.ts
- CHANGELOG.md v0.36.0, AGENTS.md docs, package.json 0.36.0

Fixes #59
2026-10-15 18:23:05 +00:00
kamilkrzywda b5ea8ba5a4 feat(#58): trade & embargoes — TurnResult.economy ops (trade/embargo, declare/lift) with a deterministic per-turn effect and war auto-suspension
CI / build + unit + e2e (pull_request) Successful in 2m3s
2026-10-15 17:26:24 +00:00
kamilkrzywda 8eeb6df284 feat(#57): hybrid war — TurnResult.hybrid ops (spy/sabotage/assassinate/cyber/economic/information/proxy) with exposure backfire
CI / build + unit + e2e (pull_request) Successful in 2m7s
2026-10-15 16:42:27 +00:00
kamilkrzywda b5e2229ee2 feat(#56): War as a first-class state — declare/end ops, mutual-defense drag-in, relation sync
CI / build + unit + e2e (pull_request) Successful in 2m32s
Promote the bare 'war' relation to a named War entity in GameState.wars:
- War: { id, sides[], declaredTurn, declaredDate?, aims?, status, front? }
- TurnResult.wars: { declare: WarDeclareOp[], end: WarEndOp[] }
- syncWarRelations: derive 'war' from active wars, fallback to 'hostile' on end
- Mutual-defense drag-in: belligerent allies added to sides (one level)
- Bare 'war' back-compat: never downgraded, never force-created
- Stub: empty wars (e2e unaffected); ~30 new unit tests

v0.33.0
2026-10-15 15:47:06 +00:00
kamilkrzywda 3c187c6901 feat(#54): non-military takeovers — automatic regime rename (TurnResult.takeovers) 2026-10-15 10:58:23 +00:00
kamilkrzywda cdabf16052 feat(#46 PR 2): per-region stat lever (regionChanges) + region-stat hover
CI / build + unit + e2e (pull_request) Successful in 2m31s
v0.29.0 — the LLM can now target individual regions' stats via
TurnResult.regionChanges (set/add per stat, clamped 0-100), applied
after nation-wide stateChanges and before the weighted-median
recompute. The map hover tooltip shows each region's own four stats.
The stub stays deterministic (regionChanges: []).

Fixes #46 (PR 2 of 2)
2026-10-15 01:06:53 +00:00
kamilkrzywda a129c2ab81 v0.27.0 (#44): LLM-placed named map point markers
CI / build + unit + e2e (pull_request) Successful in 2m36s
- shared: 8 MarkerTypes + MARKER_TYPE_INFO (label/color/glyph); MapMarker;
  GameState.markers (required, [] at init); TurnResult.markers {add,remove};
  MarkerSpec/MarkerInfo; FNV-1a 4-char markerId; MAX_MARKERS=400.
- data: new public/region-centroids.json (1,800 anchors = largest-part centroid
  + spread ~0.1x min-bbox) built by new idempotent scripts/build-region-centroids.mjs.
- server: regions.ts centroid loader (public/ then dist/); engine applyTurnResult
  marker ops (remove -> owner-recompute -> add, jitter, cap, never throws);
  prompt documents the contract; buildUserPrompt carries markers; stub marker-free.
- client: WorldMap symbol layer rendered as canvas per-type icons (glyph CDN dead);
  hover tooltip shows name; click focuses owning nation.
- tests: 168 -> 189 (centroids script, regions centroids, db legacy, engine markers).
2026-10-14 22:37:29 +00:00
kamilkrzywda e9fa267fd0 Nation-op proposals (P4a, #16): protocol types, pendingNationOps state, engine propose/resolve (stage 1)
- shared/protocol.ts: RenameOp/SplitOp/MergeOp + NationOp union; TurnResult.nationOps (required); ConfirmNationOp/RejectNationOp request+response types
- shared/types.ts: GameState.pendingNationOps (type-only import of NationOp — no runtime cycle)
- server/engine.ts: validateNationOps parser wired into validateTurnResult; isMeaningful counts proposals; applyTurnResult appends to the surviving pending list (never applies); runTurn fallback literal; system prompt gains the nationOps JSON field + rename proposal rule (both modes); new resolveNationRef/applyNationOp/confirmNationOp/rejectNationOp (id-first then unique case-insensitive name; rename changes Nation.name only; split/merge no-op until P4b; index validation returns null for 400)
- server/db.ts: legacy rows load with pendingNationOps defaulted to []
- server/index.ts: fresh games start with pendingNationOps: []
- server/stub.ts: deterministicTurn returns nationOps: [] (the stub never proposes — unit-only coverage, matching P3)
- tests: fixture updates (engine/db/map/regions) + new suites for resolveNationRef, validateNationOps, proposal lifecycle, confirm/reject (151 tests total)
2026-10-14 12:47:58 +00:00
kamilkrzywda 764f24fc55 No man's land: TurnResult.unowned + release/re-claim in applyTurnResult + distinct grey map rendering (#16 P3)
- shared/protocol.ts: TurnResult.unowned (keys released this turn) + the map
  presentation contract (regionFillStyle / unownedTooltipLabel, hue-less grey)
- shared/types.ts: required GameState.unowned (cumulative mid-game releases;
  [] at init, distinct from preset-unassigned regions)
- server/engine.ts: applyTurnResult applies releases first (sole-owner only,
  never fabricating no man's land from unassigned keys), then claims against
  the post-release snapshot (gain on an ownerless key = re-claim); lone
  release is isMeaningful; prompts + user payload carry the unowned field
- server/stub.ts: unchanged behavior (never releases; e2e stays stable)
- server/db.ts: legacy rows load with unowned defaulted to []
- server/index.ts: create-game sets []; jump reference table includes
  released keys so the model can name them for re-claim
- WorldMap/App: unowned prop -> visible grey + 'No man's land' tooltip
- unit tests: 105 -> 127 (release/re-claim suites, presentation contract,
  legacy-row normalization)
2026-10-14 12:04:03 +00:00
kamilkrzywda 5abbebc58f LLM getNeighbours tool + contiguous region transfers (Stage 2 of #16 P2)
- llm.ts: LlmClient.chatWithTools — bounded OpenAI function-calling loop
  (default 6 tool rounds, then one forced no-tools request); shares the
  retry/backoff/reasoning-effort machinery with chat(); tools are only
  added to the request body when non-empty, so plain chat() bodies and
  their tests are unchanged; a tool-call response is a success (never a
  truncation); malformed tool arguments become an error tool-message.
- engine.ts: GET_NEIGHBOURS_TOOL + makeNeighboursExecutor (degrades
  unknown tools/args/keys without throwing); playerFrontier (sorted,
  deduped, other-nation-owned only); buildSystemPrompt gains a tool
  variant (contiguity rules, frontier hint) used only when the tool is
  actually sent; buildUserPrompt emits a frontier field whenever a
  resolver is given; runTurn takes getNeighbours and uses
  chatWithTools when both the resolver and the client's capability are
  present, falling back to plain chat() otherwise.
- applyTurnResult: snapshot-first sole-ownership algorithm — a region
  key transfers only with exactly one claimant and exactly one previous
  owner (different from the gainer); double claims and pre-existing
  double ownership are left untouched; lone losses are no-ops; every
  key has 0 or 2+ -> 1 owner post-turn, never orphaned or duplicated.
- stub.ts: deterministic one-region contiguous conquest per turn from
  the prompt's frontier field (bypasses the tool loop; empty frontier
  -> no transfer, all existing assertions stay stable).
- regions.ts: optional region-adjacency.json load; hasAdjacency() and
  neighboursOf(keys) (union, deduped, queried excluded, sorted).
- index.ts: jump route passes the adjacency resolver when loaded.
- tests: chatWithTools protocol (echo, executor args, bounded rounds,
  malformed args, retry), tool-path runTurn wiring + fallback,
  playerFrontier, executor, applyTurnResult invariants, neighboursOf/
  hasAdjacency (105 unit tests, 14 e2e — all green).
2026-10-14 10:37:25 +00:00
kamilkrzywda 9bce14254d batched round: queue actions/diplomacy, jump forward commits one turn (v0.2.0)
CI / build + unit + e2e (pull_request) Successful in 1m51s
2026-10-11 14:36:35 +00:00
kamilkrzywdaandOpenCode e2936e85bb History: AI-driven alternate-history simulator
Pax Historia-style sim where the LLM is the world engine.

- React 19 + Vite 6 + Tailwind v4 + shadcn/ui frontend
- Express single-port backend (/api + /sse + built client + SPA fallback)
- SQLite (better-sqlite3) persistence, one row per game
- MapLibre choropleth map (OpenFreeMap dark + ISO_A3 regions)
- Defensive turn loop: strip fences, balanced-{} extract, parse, clamp 0-100,
  retry-once nudge, generic 'unclear' fallback; never crashes on bad output
- 5 seed presets (wwii-1939, coldwar-1955, modern-2025, no-wwii-1940, aegis-2400)
- LLM client over native fetch w/ injectable transport (mesh-bot pattern);
  LLM_STUB=1 deterministic transport for tests/e2e
- Unit (vitest, 19) + deterministic Playwright e2e
- Multi-stage node:22-slim Docker + Traefik (history.dev.kkhost.pl:4321)

Co-Authored-By: OpenCode <opencode@users.noreply.github.com>
2026-10-10 21:20:23 +00:00