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
+106
View File
@@ -0,0 +1,106 @@
/**
* scripts/build-region-resources.test.ts — validation for the committed
* `public/region-resources.json` (built by `scripts/build-region-resources.mjs`).
*
* The manifest maps each region key to a synthesized four-value resource
* profile `{ oil, iron, food, industry }` — each a finite number in [0, 1]
* (2 decimal places). It is the v0.36 (#59) "Resources & Industry" data
* source: the engine scales each region's per-turn industry boost by its
* `industry` value, and the LLM prompt carries each nation's aggregate
* profile (the area×population-weighted mean of the owned regions' profiles,
* `deriveNationResources` in shared/protocol.ts).
*
* Like the region-geo manifest, this is a load-bearing data artifact — the
* test pins its contract. It is dependency-free: it reads the JSON with plain
* `fs`/`JSON.parse` (no `@turf`/geojson types, which would pull `@types/geojson`
* into the tsc program and break `WorldMap.tsx` — see the v0.17 note). The
* determinism test re-runs the (turf-free) `.mjs` builder and compares bytes,
* matching that the builder is idempotent.
*/
import { readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
function readJson(rel: string): Record<string, unknown> {
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
}
interface ResourceEntry {
oil: number;
iron: number;
food: number;
industry: number;
}
const RESOURCES = ['oil', 'iron', 'food', 'industry'] as const;
// feature id -> region key
const manifest = readJson('public/region-keys.json') as Record<string, string>;
const resources = readJson('public/region-resources.json') as Record<string, ResourceEntry>;
const keys = Object.values(manifest);
describe('region-resources.json', () => {
it('has exactly one entry per region key (same key set as region-keys.json)', () => {
expect(new Set(Object.keys(resources))).toEqual(new Set(keys));
expect(Object.keys(resources)).toHaveLength(1800);
});
it('is keyed in sorted (deterministic) order', () => {
const ks = Object.keys(resources);
expect(ks).toEqual([...ks].sort());
});
it('every entry is a finite { oil, iron, food, industry } in [0,1] with ≤ 2 dp', () => {
for (const [k, v] of Object.entries(resources)) {
for (const r of RESOURCES) {
const val = v[r];
expect(typeof val, `non-number ${r} for ${k}`).toBe('number');
expect(Number.isFinite(val), `non-finite ${r} for ${k}`).toBe(true);
expect(val >= 0, `negative ${r} for ${k}: ${val}`).toBe(true);
expect(val <= 1, `${r} above 1 for ${k}: ${val}`).toBe(true);
expect(
Math.round(val * 100) / 100 === val,
`${r} over 2dp for ${k}: ${val}`,
).toBe(true);
}
}
});
it('spot-checks well-known regions for exact profiles', () => {
const is = (k: string, oil: number, iron: number, food: number, industry: number) => {
const v = resources[k];
expect(v, `missing resources for ${k}`).toBeDefined();
expect(v!.oil, `oil for ${k}`).toBe(oil);
expect(v!.iron, `iron for ${k}`).toBe(iron);
expect(v!.food, `food for ${k}`).toBe(food);
expect(v!.industry, `industry for ${k}`).toBe(industry);
};
is('california-kikh', 0.31, 0.1, 0.82, 0.97); // US-CA
is('arkhangel-sk-lhbk', 0.49, 0.52, 0.98, 0.82); // RU-ARK mainland core
is('pechora-qwhs', 0.11, 0.16, 1, 0.04); // RU-PEO (Arctic-coast band, #41)
is('yamal-gosw', 0.64, 1, 1, 0.49); // RU-YAM (Arctic-coast band, #41)
is('new-earth-eikt', 0.6, 0.56, 0.91, 0.66); // RU-ARK-NE (island group, #31)
is('severnaya-zemlya-awqp', 0.1, 0.48, 1, 0.3); // RU-ARK-SZ (island group, #31)
is('ain-lqtt', 0.94, 0.92, 0.51, 0.4); // FR-01 (Ain)
});
it('is deterministic: re-running the builder rewrites a byte-identical file', () => {
const file = path.join(root, 'public', 'region-resources.json');
const before = readFileSync(file, 'utf8');
// The builder is cwd-independent (it resolves the repo root from its own
// location) and idempotent: same input files (region-keys.json + the
// committed region-geo.json when present) + pure string hashing + fixed
// 2-dp rounding + stable (sorted) key order → identical bytes.
execFileSync(process.execPath, [path.join(root, 'scripts', 'build-region-resources.mjs')], {
cwd: root,
stdio: 'pipe',
});
const after = readFileSync(file, 'utf8');
expect(after).toBe(before);
});
});