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:
+257
-38
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user