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
+77
View File
@@ -6,10 +6,12 @@ import type {
EventOutcome,
EventType,
GameState,
IndustryProject,
MarkerType,
Nation,
NationStats,
RelationStatus,
RegionResources,
RegionStats,
StatKey,
} from './types';
@@ -416,6 +418,21 @@ export interface EconomyOps {
embargo: EconomyEmbargoOp[];
}
/**
* (v0.36, #59) The turn's INDUSTRY operations, nested under
* `TurnResult.industry`. `projects` lists the industry projects the LLM builds
* this turn: each names a **region key** (the region must be valid, and — for
* the boost to take effect — will need a sole owner at apply time) and one of
* the four project {@link IndustryType}s. The engine applies each project to
* `GameState.industry` (a per-region SET — a region already hosting a project
* keeps the first one declared; a region key not in the valid manifest is
* dropped, never thrown). Required; default `{ projects: [] }`.
*/
export interface IndustryOps {
/** The industry projects built this turn (region key + type). */
projects: IndustryProject[];
}
/** The exact JSON object the world-simulator LLM must return for a turn. */
export interface TurnResult {
date: string;
@@ -506,6 +523,20 @@ export interface TurnResult {
* suspended automatically.
*/
economy: EconomyOps;
/**
* (v0.36, #59) Industry projects built this turn — the LLM's "industry"
* lever. `projects` lists `{ regionKey, type }` pairs: the model names a
* region it (believes it) controls and one of the four project types
* (`oil` / `iron` / `food` / `industry`). Applied directly by
* `applyTurnResult`: each project is added to `GameState.industry` (a
* per-region SET — re-declaring on a region that already has a project is a
* no-op), and on every committed turn each active project gives its region's
* SOLE owner a small, deterministic economy boost scaled by that region's
* `industry` resource value (a region with no owner, or 2+ owners, yields no
* boost but keeps its project). They never move borders — sole ownership is
* untouched. Required; default `{ projects: [] }`.
*/
industry: IndustryOps;
/**
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
* (the engine positions each at its region's centroid + a small
@@ -954,3 +985,49 @@ export function migrateRegionStats(
}
return next;
}
/**
* (v0.36, #59) Derive a nation's aggregate {@link RegionResources} from the
* resource profiles of the regions it owns: each of the four resource values is
* the **area×population-weighted AVERAGE** (an average — not a median — is the
* meaningful aggregation for an abundance profile) over the nation's owned
* region keys. A region's weight is the same
* `(area > 0 ? area : 1) × (population > 0 ? population : 1)` as
* {@link deriveNationStats} — both factors fall back to 1 when the region has
* no geo entry (`geoOf` returns `undefined` for it), so every region carries
* weight ≥ 1 and a missing geo degrades gracefully to a uniform (unweighted)
* average. A region key with no resource-profile entry (`resourceOf` returns
* `undefined` for it — the manifest is not loaded, or the key is absent) is
* skipped. Returns the zero profile when the nation owns no region with a
* profile. Pure + shared.
*/
export function deriveNationResources(
regionKeys: string[],
resourceOf?: (key: string) => RegionResources | undefined,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): RegionResources {
const zero: RegionResources = { oil: 0, iron: 0, food: 0, industry: 0 };
const sums: Record<keyof RegionResources, number> = { oil: 0, iron: 0, food: 0, industry: 0 };
let totalW = 0;
for (const key of regionKeys) {
const r = resourceOf?.(key);
if (!r) continue; // a key with no resource profile is skipped
const g = geoOf?.(key);
const a = g && g.area > 0 ? g.area : 1;
const p = g && g.population > 0 ? g.population : 1;
const w = a * p;
totalW += w;
sums.oil += r.oil * w;
sums.iron += r.iron * w;
sums.food += r.food * w;
sums.industry += r.industry * w;
}
if (totalW <= 0) return zero;
const round2 = (v: number) => Math.round(v * 100) / 100;
return {
oil: round2(sums.oil / totalW),
iron: round2(sums.iron / totalW),
food: round2(sums.food / totalW),
industry: round2(sums.industry / totalW),
};
}