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
150 lines
6.0 KiB
JavaScript
150 lines
6.0 KiB
JavaScript
/**
|
||
* 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)`,
|
||
);
|