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
+257 -38
View File
@@ -16,6 +16,7 @@ import type { LlmClient, ToolDefinition, WireMessage } from './llm';
import { deterministicTurn, makeStubLlmClient } from './stub';
import {
activeWarPairs,
applyIndustryEffects,
applyNationOp,
applyTurnResult,
atWar,
@@ -42,6 +43,7 @@ import {
syncWarRelations,
validateEconomyOps,
validateHybridOps,
validateIndustryOps,
validateMarkerOps,
validateNationOps,
validateRegionChanges,
@@ -122,6 +124,8 @@ function baseState(): GameState {
// (v0.35.0, #58) no active trades or embargoes on a fresh state.
trades: [],
embargoes: [],
// (v0.36.0, #59) no industry projects on a fresh state.
industry: [],
};
}
@@ -559,7 +563,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
});
expect(withOps).not.toBeNull();
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
@@ -581,7 +585,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
});
expect(next.nations.de.name).toBe('DE'); // still the old name
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
@@ -602,7 +606,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, // new proposal this turn
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] }, // new proposal this turn
});
expect(next.pendingNationOps).toEqual([
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
@@ -621,7 +625,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
});
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
});
@@ -1078,7 +1082,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
};
applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot);
@@ -1095,7 +1099,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]);
@@ -1118,7 +1122,7 @@ describe('applyTurnResult', () => {
// is not a real key at all. Both are proposed as a gain by DE.
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
@@ -1144,7 +1148,7 @@ describe('applyTurnResult', () => {
// released (not in state.unowned), so it does not enter GameState.unowned.
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('wales-bbbb');
@@ -1162,7 +1166,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
regionChanges: [],
relations: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
};
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
@@ -1252,7 +1256,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
};
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
@@ -1367,7 +1371,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
expect(
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] } }),
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] } }),
).toBe(true);
});
@@ -1674,9 +1678,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
describe('isMeaningful', () => {
it('is true with an event, false when everything is empty', () => {
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] } })).toBe(false);
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] } })).toBe(false);
});
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
@@ -1689,7 +1693,7 @@ describe('isMeaningful', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -1708,7 +1712,7 @@ describe('isMeaningful', () => {
takeovers: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
}),
).toBe(true);
@@ -1728,7 +1732,7 @@ describe('isMeaningful', () => {
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
markers: { add: [], remove: [] },
}),
).toBe(true);
@@ -1750,7 +1754,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1782,7 +1786,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
takeovers: [{ nationRef: 'uk', newRegimeName: 'The Commonwealth' }],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1805,7 +1809,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
takeovers: [{ nationRef: 'nonexistent', newRegimeName: 'New Name' }],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1831,7 +1835,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
takeovers: [{ nationRef: 'UK', newRegimeName: 'The Empire' }],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1856,7 +1860,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
takeovers: [{ nationRef: 'de', newRegimeName: ' ' }],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
markers: { add: [], remove: [] },
});
expect(validated!.takeovers).toEqual([]);
@@ -1881,7 +1885,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1904,7 +1908,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1925,7 +1929,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
takeovers: [{ nationRef: 'De', newRegimeName: 'The Reich' }], // 'De' → resolves to 'de' by name
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1958,7 +1962,7 @@ describe('markers (point placement, v0.27 #44)', () => {
takeovers: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
};
const next = applyTurnResult(state, result, undefined, centroidOf);
@@ -1983,13 +1987,13 @@ describe('markers (point placement, v0.27 #44)', () => {
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
const first = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add, remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add, remove: [] },
}, undefined, centroidOf);
expect(first.markers).toHaveLength(1);
// a second turn proposes the exact same marker again
const second = applyTurnResult(first, {
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add, remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add, remove: [] },
}, undefined, centroidOf);
expect(second.markers).toHaveLength(1);
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
@@ -2014,7 +2018,7 @@ describe('markers (point placement, v0.27 #44)', () => {
takeovers: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
markers: {
add: [
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
@@ -2060,7 +2064,7 @@ describe('markers (point placement, v0.27 #44)', () => {
takeovers: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
markers: {
add: [
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
@@ -2086,7 +2090,7 @@ describe('markers (point placement, v0.27 #44)', () => {
];
const next = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
}, undefined, centroidOf);
expect(next.markers).toHaveLength(1);
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op
@@ -2102,14 +2106,14 @@ describe('markers (point placement, v0.27 #44)', () => {
// 1) uk releases the region → the marker becomes ownerless
const released = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(released.markers[0].ownerNationId).toBeNull();
// 2) de re-claims the region → the marker's owner follows to de
const reclaimed = applyTurnResult(released, {
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(reclaimed.markers[0].ownerNationId).toBe('de');
});
@@ -2177,7 +2181,7 @@ describe('markers (point placement, v0.27 #44)', () => {
nationOps: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
});
const { state } = await runTurn({
@@ -2251,6 +2255,8 @@ function mkTurnState(
// (v0.35.0, #58) no active trades or embargoes on a fresh state.
trades: [],
embargoes: [],
// (v0.36.0, #59) no industry projects on a fresh state.
industry: [],
};
}
@@ -2268,6 +2274,7 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
wars: partial.wars ?? { declare: [], end: [] },
hybrid: partial.hybrid ?? { ops: [] },
economy: partial.economy ?? { trade: [], embargo: [] },
industry: partial.industry ?? { projects: [] },
markers: partial.markers ?? { add: [], remove: [] },
};
}
@@ -2991,7 +2998,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }, { nationRef: 'bad', newRegimeName: '' }],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
markers: { add: [], remove: [] },
});
expect(withTakeovers).not.toBeNull();
@@ -3378,7 +3385,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
takeovers: [],
wars: { declare: [{ sides: ['a', 'b'] }], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
markers: { add: [], remove: [] },
}),
).toBe(true);
@@ -3398,7 +3405,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
takeovers: [],
wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
markers: { add: [], remove: [] },
}),
).toBe(true);
@@ -3418,7 +3425,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
takeovers: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
markers: { add: [], remove: [] },
}),
).toBe(false);
@@ -4128,5 +4135,217 @@ describe('stub — economy (v0.35.0 #58)', () => {
};
const result = deterministicTurn(payload);
expect(result.economy).toEqual({ trade: [], embargo: [] });
expect(result.industry).toEqual({ projects: [] });
});
});
// --- Industry projects (v0.36.0, #59) ----------------------------------------
describe('validateIndustryOps (v0.36.0 #59)', () => {
it('accepts well-formed project ops', () => {
const out = validateIndustryOps({ projects: [{ regionKey: 'moselle-rygy', type: 'oil' }] });
expect(out).toEqual({ projects: [{ regionKey: 'moselle-rygy', type: 'oil' }] });
});
it('drops ops with a missing regionKey, an unknown type, or a non-object entry', () => {
const out = validateIndustryOps({
projects: [
{ type: 'oil' }, // no regionKey
{ regionKey: 'k1', type: 'coal' }, // unknown type
{ regionKey: '', type: 'iron' }, // empty key
'not-an-object',
null,
{ regionKey: 'k2', type: 'food' }, // valid
],
});
expect(out).toEqual({ projects: [{ regionKey: 'k2', type: 'food' }] });
});
it('a missing / malformed top-level object yields { projects: [] }', () => {
expect(validateIndustryOps(undefined)).toEqual({ projects: [] });
expect(validateIndustryOps(null)).toEqual({ projects: [] });
expect(validateIndustryOps('nope')).toEqual({ projects: [] });
expect(validateIndustryOps({})).toEqual({ projects: [] });
expect(validateIndustryOps({ projects: 'x' })).toEqual({ projects: [] });
});
});
describe('applyTurnResult — industry ops (v0.36.0 #59)', () => {
const state = () =>
mkTurnState(
{
uk: mkNation('uk', NS(50, 50, 50, 50), ['g1', 'g2']),
de: mkNation('de', NS(50, 50, 50, 50), ['d1']),
},
{ g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) },
);
it('declares a project on each valid region (per-region set)', () => {
const next = applyTurnResult(state(), mkTurnResult({
industry: { projects: [{ regionKey: 'g1', type: 'oil' }, { regionKey: 'd1', type: 'industry' }] },
}));
expect(next.industry).toEqual([
{ regionKey: 'g1', type: 'oil' },
{ regionKey: 'd1', type: 'industry' },
]);
});
it('re-declaring on a region that already hosts a project is a no-op (first type wins)', () => {
const s = state();
s.industry = [{ regionKey: 'g1', type: 'oil' }];
const next = applyTurnResult(s, mkTurnResult({
industry: { projects: [{ regionKey: 'g1', type: 'iron' }, { regionKey: 'g2', type: 'food' }] },
}));
expect(next.industry).toEqual([
{ regionKey: 'g1', type: 'oil' }, // the existing type is never replaced
{ regionKey: 'g2', type: 'food' },
]);
});
it('drops a project whose region key is not in the valid-keys manifest', () => {
const next = applyTurnResult(
state(),
mkTurnResult({ industry: { projects: [{ regionKey: 'not-a-real-key-xyz', type: 'oil' }] } }),
new Set(['g1', 'g2', 'd1']),
);
expect(next.industry).toEqual([]);
});
it('active projects persist across turns (the set is never cleared on commit)', () => {
const s = state();
s.industry = [{ regionKey: 'g1', type: 'oil' }];
const next = applyTurnResult(s, mkTurnResult({}));
expect(next.industry).toEqual([{ regionKey: 'g1', type: 'oil' }]);
});
});
describe('applyIndustryEffects (v0.36.0 #59)', () => {
const mk = () =>
mkTurnState(
{
uk: mkNation('uk', NS(50, 50, 50, 50), ['g1']),
de: mkNation('de', NS(50, 50, 50, 50), ['d1']),
},
{ g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) },
);
it('boosts the region\'s SOLE owner by the base value (no resource manifest)', () => {
const s = mk();
s.industry = [{ regionKey: 'g1', type: 'oil' }];
applyIndustryEffects(s);
expect(s.nations.uk.stats.economy).toBe(51); // 50 + 1
expect(s.nations.de.stats.economy).toBe(50); // d1 hosts no project
// the boost is mirrored to every region the owner holds
expect(s.regionStats.g1.economy).toBe(51);
expect(s.regionStats.d1.economy).toBe(50);
});
it('adds one extra point when the region\'s industry value is >= 0.5 (and not below)', () => {
const s = mk();
s.industry = [{ regionKey: 'g1', type: 'oil' }, { regionKey: 'd1', type: 'iron' }];
applyIndustryEffects(
s,
(k) => (k === 'g1'
? { oil: 0, iron: 0, food: 0, industry: 0.5 } // 0.5 >= 0.5 → boosted
: { oil: 0, iron: 0, food: 0, industry: 0.4 }), // 0.4 < 0.5 → base
);
expect(s.nations.uk.stats.economy).toBe(52); // 1 + 1
expect(s.nations.de.stats.economy).toBe(51); // 1 + 0
});
it('a nation owning several project regions gets each of their boosts', () => {
const s = mk();
s.nations.uk.territory = ['g1', 'd1'];
s.nations.de.territory = [];
s.industry = [{ regionKey: 'g1', type: 'oil' }, { regionKey: 'd1', type: 'iron' }];
applyIndustryEffects(s);
expect(s.nations.uk.stats.economy).toBe(52); // 2 projects × base 1
});
it('a project on an ownerless (released) region yields no boost but is kept', () => {
const s = mk();
s.nations.uk.territory = []; // g1 is no man's land
s.industry = [{ regionKey: 'g1', type: 'oil' }];
applyIndustryEffects(s);
expect(s.industry).toEqual([{ regionKey: 'g1', type: 'oil' }]); // kept, not removed
expect(s.nations.de.stats.economy).toBe(50); // nobody was boosted
});
it('a project on a region with 2+ owners (corrupt) yields no boost but is kept', () => {
const s = mk();
s.nations.de.territory = ['d1', 'g1']; // corrupt: g1 has two owners
s.industry = [{ regionKey: 'g1', type: 'oil' }];
applyIndustryEffects(s);
expect(s.industry).toEqual([{ regionKey: 'g1', type: 'oil' }]); // kept
expect(s.nations.uk.stats.economy).toBe(50);
expect(s.nations.de.stats.economy).toBe(50);
});
it('is a no-op when there are no active projects', () => {
const s = mk();
applyIndustryEffects(s);
expect(s.nations.uk.stats.economy).toBe(50);
expect(s.nations.de.stats.economy).toBe(50);
});
});
describe('isMeaningful — industry (v0.36.0 #59)', () => {
it('a project-only turn is meaningful; an empty industry is not', () => {
expect(isMeaningful(mkTurnResult({ industry: { projects: [{ regionKey: 'k', type: 'oil' }] } }))).toBe(true);
expect(isMeaningful(mkTurnResult({}))).toBe(false);
});
});
describe('buildSystemPrompt — industry contract (v0.36.0 #59)', () => {
it('documents the industry field and its constraints in both modes', () => {
for (const withTools of [false, true]) {
const p = buildSystemPrompt(preset, withTools);
expect(p).toContain('"industry": {"projects":');
expect(p).toContain('Industry ("industry") is the per-resource development lever');
expect(p).toContain('do not re-declare one on a region that already has one');
}
});
});
describe('buildUserPrompt — industry (v0.36.0 #59)', () => {
const state = mkTurnState(
{ a: mkNation('a', NS(50, 50, 50, 50), ['ra1']), b: mkNation('b', NS(50, 50, 50, 50), ['rb1']) },
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50) },
);
it('carries the resource aggregate and active projects when provided', () => {
const out = JSON.parse(
buildUserPrompt(
state,
[],
undefined,
undefined,
[],
[],
undefined,
undefined,
undefined,
undefined,
{ a: { oil: 0.9, iron: 0.1, food: 0.5, industry: 0.2 } },
[{ regionKey: 'ra1', type: 'oil' }],
),
);
expect(out.resources).toEqual({ a: { oil: 0.9, iron: 0.1, food: 0.5, industry: 0.2 } });
expect(out.industry).toEqual([{ regionKey: 'ra1', type: 'oil' }]);
});
it('omits the resources/industry fields entirely when undefined', () => {
const out = JSON.parse(buildUserPrompt(state, [], undefined, undefined, [], [], undefined, undefined));
expect(out).not.toHaveProperty('resources');
expect(out).not.toHaveProperty('industry');
});
});
describe('runTurn — industry (v0.36.0 #59)', () => {
it('a result without an industry field leaves state.industry = [] (defensive init)', async () => {
const s = baseState();
delete (s as { industry?: unknown[] }).industry; // simulate a pre-v0.36 row
const { state } = await runTurn({ llm: fakeLlm([validResult]), preset, state: s, items: [] });
expect(state.industry).toEqual([]);
});
});