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
107 lines
4.6 KiB
TypeScript
107 lines
4.6 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|