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
+149
View File
@@ -0,0 +1,149 @@
/**
* scripts/build-region-resources.mjs — build the region **resources** manifest:
* `public/region-resources.json` = `{ "<regionKey>": { "oil": <0-1>, "iron": <0-1>, "food": <0-1>, "industry": <0-1> }, ... }`.
*
* Every sub-region (region key, see `public/region-keys.json`) gets a
* synthesized four-value resource profile. For each resource dimension a
* value in [0, 1] (2 decimal places) is derived from the FNV-1a 32-bit hash
* of the region key (salted per resource — the same tiny, stable, pure
* string hash the region keys themselves use), then scaled MODESTLY by the
* region's area (from the committed `public/region-geo.json`, when present):
* larger regions carry a slightly higher value (a log-scaled factor of at
* most ~1.2), so a big region reads as holding more of each resource. The
* geo file is OPTIONAL — without it (or for a zero-area region) the factor
* is exactly 1 and the profile is pure hash.
*
* This 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, see
* `deriveNationResources` in shared/protocol.ts). The profiles are
* synthesized (there is no real per-sub-region resource census) — they exist
* to give the model a stable, deterministic world fact to reason about.
*
* Output contract (asserted by scripts/build-region-resources.test.ts):
* - one entry for **every** region key, keyed identically to
* `public/region-keys.json` (same key set, 1,800 entries);
* - each entry is `{ oil, iron, food, industry }`, every value a finite
* number in [0, 1] with at most 2 decimal places;
* - keys are in sorted (deterministic) order.
*
* Deterministic + idempotent: fixed inputs (the key manifest, and the
* committed geo file when present), pure string hashing, fixed 2-dp
* rounding, and stable sorted key order → a re-run rewrites a byte-identical
* file (the test re-runs this script and compares bytes).
*
* Usage: `node scripts/build-region-resources.mjs` (or `npm run build:region-resources`).
*/
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
const round2 = (v) => Math.round(v * 100) / 100;
/** FNV-1a 32-bit — the same tiny, stable, pure hash the region keys use. */
function fnv1a32(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h >>> 0;
}
const RESOURCES = ['oil', 'iron', 'food', 'industry'];
// --- load the inputs (plain parse) ------------------------------------------
const keysPath = path.join(root, 'public', 'region-keys.json');
const geoPath = path.join(root, 'public', 'region-geo.json');
let idToKey;
try {
idToKey = JSON.parse(readFileSync(keysPath, 'utf8'));
} catch (err) {
console.error(`[build-region-resources] could not load ${keysPath}.`);
throw err;
}
// region-geo is OPTIONAL: a missing/broken file means "no area scaling"
// (factor 1 for every region) — the profile is then pure hash.
let areaByKey = {};
try {
const parsed = JSON.parse(readFileSync(geoPath, 'utf8'));
for (const [k, v] of Object.entries(parsed)) {
if (v && typeof v.area === 'number' && Number.isFinite(v.area) && v.area >= 0) {
areaByKey[k] = v.area;
}
}
} catch {
areaByKey = {};
}
/**
* The base value in [0, 1) for one (region key, resource): the low 14 bits of
* the salted FNV-1a hash, normalized. Stable across runs and platforms; a
* uniform-enough spread for a synthesized profile.
*/
const base = (key, resource) => (fnv1a32(`${key}\u0000${resource}`) & 0x3fff) / 0x4000;
/**
* The area scaling factor: 1.0 for a region with no geo entry (or zero area),
* rising on a log scale (clamped) to at most ~1.2 for the largest regions —
* bigger regions hold proportionally more of each resource.
*/
const areaScale = (key) => {
const a = areaByKey[key];
if (!a || a <= 0) return 1;
// log10(1 + area) runs ~0.004 (tiny slivers) to ~5.5 (a 300k km² region);
// /8 + clamp keeps the factor in [1, 1.2].
const t = Math.min(1, Math.log10(1 + a) / 8);
return 1 + 0.2 * t;
};
// --- assemble the manifest (sorted keys) -------------------------------------
const expectedKeys = Object.values(idToKey);
const manifest = {};
for (const k of [...expectedKeys].sort()) {
const scale = areaScale(k);
manifest[k] = {
oil: round2(Math.min(1, base(k, 'oil') * scale)),
iron: round2(Math.min(1, base(k, 'iron') * scale)),
food: round2(Math.min(1, base(k, 'food') * scale)),
industry: round2(Math.min(1, base(k, 'industry') * scale)),
};
}
// --- hard assertions (the test re-checks these against the committed file) ----
if (Object.keys(manifest).length !== expectedKeys.length) {
throw new Error(
`expected ${expectedKeys.length} region-resources entries (one per region key), got ${Object.keys(manifest).length}`,
);
}
let scaled = 0;
for (const [k, v] of Object.entries(manifest)) {
if (areaByKey[k]) scaled += 1;
for (const r of RESOURCES) {
const val = v[r];
if (typeof val !== 'number' || !Number.isFinite(val) || val < 0 || val > 1) {
throw new Error(`bad ${r} value for ${k}: ${JSON.stringify(val)}`);
}
if (Math.round(val * 100) / 100 !== val) {
throw new Error(`${r} over 2dp for ${k}: ${val}`);
}
}
}
// --- write idempotently (sorted keys, 2-space indent, trailing newline) ------
writeFileSync(
path.join(root, 'public', 'region-resources.json'),
JSON.stringify(manifest, null, 2) + '\n',
);
// --- summary ------------------------------------------------------------------
console.log(
`[build-region-resources] wrote public/region-resources.json — ${Object.keys(manifest).length} region keys` +
` (${scaled} area-scaled, ${Object.keys(manifest).length - scaled} pure-hash)`,
);
+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);
});
});