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
This commit is contained in:
2026-10-15 18:23:05 +00:00
parent dd9fd6a09d
commit b237fe8dd4
19 changed files with 11990 additions and 46 deletions
+35
View File
@@ -67,11 +67,13 @@ history/
│ ├── region-adjacency.json # region key -> bordering region keys, 1,800 entries (the getNeighbours data, #16 P2)
│ ├── region-centroids.json # region key -> {lat,lon,spread} anchor for map markers, 1,800 entries (#44)
│ ├── region-geo.json # region key -> {area (km²), population}, 1,800 entries (the area×population weighting for per-region stats, #46)
│ ├── region-resources.json # region key -> synthesized {oil, iron, food, industry} (each 0–1, 2dp), 1,800 entries (the resource profiles that scale industry boosts + feed the prompt aggregate, #59)
│ └── ne-admin0-pop.json # ISO_A3 -> admin-0 POP_EST, 249 codes (the committed population SOURCE for region-geo.json — a small derived table, the 13 MB Natural Earth source is NOT committed, #46)
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
│ │ # → build-region-adjacency → build-region-centroids → build-region-geo
│ │ # → build-region-resources
│ │ # (build-region-geo reads the COMMITTED ne-admin0-pop.json; that small
│ │ # table is distilled once from the 13 MB Natural Earth admin-0 source by
│ │ # extract-admin0-pop.mjs — the raw source is never committed, #46)
@@ -131,6 +133,7 @@ history/
| `npm run split:regions:band` | (re)apply the #41 Arctic-coast-band split (`RU-ARK` mainland's 28-part lat ≥ 68 band → `RU-PEO` + `RU-YAM`) — idempotent no-op once applied |
| `npm run build:region-keys` / `build:region-adjacency` / `build:region-centroids` | (re)generate the `public/region-keys.json` / `region-adjacency.json` / `region-centroids.json` manifests — deterministic + idempotent (byte-identical re-run) |
| `npm run build:region-geo` | (re)generate `public/region-geo.json` (region key → area km² + population) from `regions.geo.json` + `region-keys.json` + the committed `ne-admin0-pop.json` — deterministic + idempotent (byte-identical re-run; #46) |
| `npm run build:region-resources` | (re)generate `public/region-resources.json` (region key → synthesized `{ oil, iron, food, industry }` 0–1 profile) from `region-keys.json` + `region-geo.json` (area factor) — deterministic + idempotent (byte-identical re-run; #59) |
| `npm run extract:admin0-pop` | one-time distillation of the 13 MB Natural Earth admin-0 source → the committed `public/ne-admin0-pop.json` (ISO_A3 → `POP_EST`, 249 codes); the raw source is never committed (#46) |
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
@@ -407,6 +410,38 @@ history/
**stub never** declares or lifts a trade/embargo (`economy: { trade: [], embargo: [] }`),
so e2e (CI, `LLM_STUB=1`) is unaffected and the path is unit-tested only
(`server/engine.test.ts`).
- **Resources & Industry — per-region resource profiles + the `industry` lever (v0.36, #59).**
Since v0.36 each of the 1,800 sub-regions carries a **synthesized 4-value resource profile**
(`{ oil, iron, food, industry }`, each 0–1, 2dp) from the committed `public/region-resources.json`
— built by the deterministic, idempotent `scripts/build-region-resources.mjs` (an FNV-1a hash of
`` `${key}\u0000${resource}` `` scaled by an area factor from `region-geo.json`, clamped to 1,
byte-identical re-run) and loaded by `server/regions.ts` via the same optional-manifest pattern as
the other manifests (`hasResources()` / `resourceOf()`, probe `public/` then `dist/`; a missing or
malformed file degrades — boosts use the unscaled base value and the aggregate is omitted from the
prompt, never a crash). `TurnResult.industry = { projects: IndustryProject[] }` (required, default
`{ projects: [] }`; `IndustryProject = { regionKey, type }`, `type ∈ INDUSTRY_TYPES =
oil | iron | food | industry`) declares a project on a region. `applyTurnResult` maintains
`GameState.industry` (required, `[]` at creation; a legacy SQLite row without the field loads as
`[]` — `db.ts`, additive, never wiped) as a **per-region SET**: a region key not in the valid-keys
manifest is dropped; a region that already hosts a project is untouched (the first declared type
wins — re-declaring is a no-op, the type is never replaced). Every committed turn,
`applyIndustryEffects` (placed after the economy block, before markers; the owner is resolved from
the FINAL post-claim ownership) grants each active project's region's **SOLE owner** a small,
deterministic economy boost — `INDUSTRY_ECONOMY_BONUS = 1` plus one extra point when the region's
`industry` resource value is ≥ 0.5 (without the manifest, just the base) — clamped to 0–100,
mirrored to every region the owner holds (`regionStats`), and each affected nation's derived `stats`
re-derived afterwards (same 0-region / no-entry guards as the economy/hybrid dips). A project on a
region with 0 or 2+ owners yields no boost but is **kept** in the list (never removed, never
thrown); sole ownership is untouched (the layer moves stats, never territory). `isMeaningful`
counts non-empty `industry.projects`; the system prompt documents the field + constraints (a region
holds at most one project — never re-declare on a region that already has one; at most 1–2 per
turn, each narrated in `events`), and the user payload carries the active projects (`industry`)
plus each nation's aggregate resource profile (`resources` — `deriveNationResources` in
`shared/protocol.ts`, the area×population-weighted **average** of the owned regions' profiles;
omitted entirely when the manifest is not loaded). The **stub never** declares a project
(`industry: { projects: [] }`) — e2e (CI, `LLM_STUB=1`) is unaffected and the path is unit-tested
only (`server/engine.test.ts`, `server/protocol.test.ts`, `server/regions.test.ts`,
`server/db.test.ts`, `scripts/build-region-resources.test.ts`).
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was