feat: Resources & Industry — per-region resource profiles + industry LLM lever (v0.36.0, #59)
CI / build + unit + e2e (pull_request) Successful in 2m11s
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:
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canConquer,
|
||||
CONQUEST_MILITARY_MARGIN,
|
||||
deriveNationResources,
|
||||
deriveNationStats,
|
||||
migrateRegionStats,
|
||||
seedRegionStats,
|
||||
@@ -44,6 +45,7 @@ function mkState(
|
||||
wars: [],
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
industry: [],
|
||||
};
|
||||
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
|
||||
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
|
||||
@@ -139,6 +141,81 @@ describe('deriveNationStats', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- deriveNationResources ---------------------------------------------------
|
||||
|
||||
describe('deriveNationResources (v0.36.0 #59)', () => {
|
||||
const prof: Record<string, { oil: number; iron: number; food: number; industry: number }> = {
|
||||
a: { oil: 0.2, iron: 0.2, food: 0.2, industry: 0.2 },
|
||||
b: { oil: 0.8, iron: 0.8, food: 0.8, industry: 0.8 },
|
||||
};
|
||||
const geo: Record<string, { area: number; population: number }> = {
|
||||
a: { area: 100, population: 1000 },
|
||||
b: { area: 1000, population: 1000 },
|
||||
};
|
||||
|
||||
it('weights by area×population: the larger region dominates the average', () => {
|
||||
// a: weight 100×1000=100000, b: weight 1000×1000=1000000
|
||||
// → (0.2×1e5 + 0.8×1e6)/1.1e6 = 82/110 = 0.7454… → 0.75 on every axis
|
||||
expect(deriveNationResources(['a', 'b'], (k) => prof[k], (k) => geo[k])).toEqual({
|
||||
oil: 0.75, iron: 0.75, food: 0.75, industry: 0.75,
|
||||
});
|
||||
});
|
||||
|
||||
it('degrades to a uniform (unweighted) average without geo: equal weights', () => {
|
||||
// equal weights 1,1 → the true mean 0.5 on every axis
|
||||
expect(deriveNationResources(['a', 'b'], (k) => prof[k])).toEqual({
|
||||
oil: 0.5, iron: 0.5, food: 0.5, industry: 0.5,
|
||||
});
|
||||
// same when geoOf is provided but returns undefined for the keys (missing geo)
|
||||
expect(deriveNationResources(['a', 'b'], (k) => prof[k], () => undefined)).toEqual({
|
||||
oil: 0.5, iron: 0.5, food: 0.5, industry: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips a region key that has no resource-profile entry', () => {
|
||||
// b has no profile → only a is counted → a's values
|
||||
const out = deriveNationResources(['a', 'b'], (k) => (k === 'a' ? prof.a : undefined), (k) => geo[k]);
|
||||
expect(out).toEqual(prof.a);
|
||||
});
|
||||
|
||||
it('returns the zero profile when none of the owned regions has a profile', () => {
|
||||
expect(deriveNationResources(['a', 'b'], () => undefined)).toEqual({
|
||||
oil: 0, iron: 0, food: 0, industry: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('a single region yields exactly that region\'s profile', () => {
|
||||
expect(deriveNationResources(['a'], (k) => prof[k], (k) => geo[k])).toEqual(prof.a);
|
||||
});
|
||||
|
||||
it('treats zero/negative area or population as a fallback weight of 1', () => {
|
||||
// a: area 0 → weight 1×1000=1000; b: full weight 1e6 → b dominates
|
||||
const g: Record<string, { area: number; population: number }> = {
|
||||
a: { area: 0, population: 1000 },
|
||||
b: { area: 1000, population: 1000 },
|
||||
};
|
||||
expect(deriveNationResources(['a', 'b'], (k) => prof[k], (k) => g[k])).toEqual({
|
||||
oil: 0.8, iron: 0.8, food: 0.8, industry: 0.8,
|
||||
});
|
||||
});
|
||||
|
||||
it('rounds each axis to 2 decimal places', () => {
|
||||
// a=0.2 (w=1×1=1) + b=0.4 (w=2×1=2) → (0.2×1 + 0.4×2)/(1+2) = 1.0/3 = 0.3333… → 0.33
|
||||
const g: Record<string, { area: number; population: number }> = {
|
||||
a: { area: 1, population: 1 },
|
||||
b: { area: 2, population: 1 },
|
||||
};
|
||||
const out = deriveNationResources(
|
||||
['a', 'b'],
|
||||
(k) => k === 'a'
|
||||
? { oil: 0.2, iron: 0.2, food: 0.2, industry: 0.2 }
|
||||
: { oil: 0.4, iron: 0.4, food: 0.4, industry: 0.4 },
|
||||
(k) => g[k],
|
||||
);
|
||||
expect(out).toEqual({ oil: 0.33, iron: 0.33, food: 0.33, industry: 0.33 });
|
||||
});
|
||||
});
|
||||
|
||||
// --- seedRegionStats ---------------------------------------------------------
|
||||
|
||||
describe('seedRegionStats', () => {
|
||||
|
||||
Reference in New Issue
Block a user