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 -4
View File
@@ -8,22 +8,26 @@ import type {
EventOutcome,
EventType,
GameState,
IndustryProject,
IndustryType,
MapMarker,
Nation,
NationStats,
Preset,
RelationStatus,
RegionResources,
RegionStats,
RoundItem,
StatKey,
TradeAgreement,
War,
} from '../shared/types';
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
import { EVENT_TYPES, INDUSTRY_TYPES, RELATION_STATUSES } from '../shared/types';
import {
addDays,
canConquer,
clampStat,
deriveNationResources,
deriveNationStats,
fnv1a32,
HYBRID_OP_TYPES,
@@ -38,6 +42,7 @@ import {
type EconomyTradeOp,
type HybridOp,
type HybridOpType,
type IndustryOps,
type MarkerInfo,
type MarkerSpec,
type NationOp,
@@ -70,6 +75,13 @@ export function isHybridOpType(t: unknown): t is HybridOpType {
return typeof t === 'string' && VALID_HYBRID_TYPES.has(t);
}
const VALID_INDUSTRY_TYPES = new Set<string>(INDUSTRY_TYPES);
/** True when `t` is one of the four known industry project types (v0.36, #59). */
export function isIndustryType(t: unknown): t is IndustryType {
return typeof t === 'string' && VALID_INDUSTRY_TYPES.has(t);
}
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
/**
@@ -224,6 +236,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
' "wars": {"declare":[{"sides":["<nationId>","<nationId>"],"aims":["<short aim>"]}],"end":[{"id":"<war id>","outcome":"<armistice|capitulation>","terms":"<short terms>"}]},',
' "hybrid": {"ops":[{"type":"<spy|sabotage|assassinate|cyber|economic|information|proxy>","from":"<nation id or name>","target":"<nation id or name, or region key>","detail":"<short detail>","outcome":"<success|exposed>"}]},',
' "economy": {"trade":[{"a":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},',
' "industry": {"projects":[{"regionKey":"<region key>","type":"<oil|iron|food|industry>"}]},',
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
'}',
'',
@@ -245,6 +258,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
'- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["<id>","<id>"],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair\'s relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"<war id from the user message\'s "wars" field>","outcome":"<armistice|capitulation>"}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".',
'- Covert / hybrid ops ("hybrid.ops") are non-kinetic operations — spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory they NEVER move borders: a successful op applies a small, deterministic effect (a small dip in the target\'s economy / stability / military for sabotage / assassinate / cyber / economic, or a relation nudge toward hostile for information / proxy), while an "exposed" op backfires (no effect, and the actor\'s relation with the target sours by one step toward hostile). "target" may be a nation id / name or a region key (a region key hits that region\'s sole owner). Keep each detail short, propose at most a few per turn, and narrate each op in "events".',
'- Economy ("economy") is the trade & embargo layer: a bilateral trade (trade: {"a":"..","b":".."}) gives BOTH parties a small, deterministic economy gain every turn, while a unilateral embargo (embargo: {"from":"..","target":".."}) lowers only the TARGET\'s economy every turn. Omit "lift" to declare / impose, and set "lift":true to end or lift an existing one (the user message\'s "trades"/"embargoes" fields list what is currently active). Propose at most 1-2 operations per turn and narrate each one in "events". Trades and embargoes between nations at war are suspended automatically by the engine.',
'- Industry ("industry") is the per-resource development lever: each project is a building in one region you (or a nation) controls — {"regionKey":"<region key>","type":"<oil|iron|food|industry>"} (an oil facility, an iron mine, a food operation, or a factory). Every committed turn each active project gives its region\'s owner a small, deterministic economy gain, the bigger the region\'s industry profile (see the "resources" field). A region holds at most one project — do not re-declare one on a region that already has one (the user message\'s "industry" field lists what is already built). Propose at most 1-2 new projects per turn and narrate each one in "events".',
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
'- Keep every stat value between 0 and 100.',
@@ -321,6 +335,22 @@ export function buildUserPrompt(
* with `economy.embargo` + `lift: true`. Omitted entirely when undefined.
*/
embargoes?: Embargo[],
/**
* (v0.36, #59) Each nation's AGGREGATE resource profile (nation id -> the
* area×population-weighted mean of the resource profiles of the regions it
* owns, via `deriveNationResources`). Emitted verbatim (even when empty) so
* the model can reason about which nations are rich in which resource and
* where to build industry projects. Omitted entirely when undefined (no
* resource manifest loaded).
*/
nationResources?: Record<string, RegionResources>,
/**
* (v0.36, #59) The game's currently ACTIVE industry projects (each a region
* key + project type). Emitted verbatim (even when empty) so the model knows
* which regions already host a project (and should not be re-declared) and
* can reference them. Omitted entirely when undefined.
*/
industryProjects?: IndustryProject[],
): string {
const player = state.nations[state.playerNationId];
const playerName = player?.name ?? state.playerNationId;
@@ -437,6 +467,8 @@ export function buildUserPrompt(
if (relations !== undefined) payload.relations = relations;
if (trades !== undefined) payload.trades = trades;
if (embargoes !== undefined) payload.embargoes = embargoes;
if (nationResources !== undefined) payload.resources = nationResources;
if (industryProjects !== undefined) payload.industry = industryProjects;
return JSON.stringify(payload, null, 2);
}
@@ -578,6 +610,7 @@ export function validateTurnResult(
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
industry: { projects: [] },
markers: { add: [], remove: [] },
};
@@ -688,6 +721,14 @@ export function validateTurnResult(
// ambiguous / self-pairing ops are dropped there, never thrown.
result.economy = validateEconomyOps(o.economy);
// (v0.36, #59) Industry projects: the LLM names a region key + one of the
// four project types. Each project becomes an active entry in
// `GameState.industry` (a per-region SET). The region key is validated as a
// non-empty string here; whether the key is real and owned is checked at
// apply time (a project on a region with no sole owner yields no boost but
// is kept). Refs stay raw and the effect is deterministic at apply time.
result.industry = validateIndustryOps(o.industry);
// (v0.27, #44) Map-marker ops: new placements (validated here) and
// removals by stable id. Applied by applyTurnResult after the territory
// moves (a new marker's owner is the region's post-turn owner).
@@ -962,6 +1003,34 @@ export function validateEconomyOps(raw: unknown): EconomyOps {
return out;
}
/**
* (v0.36, #59) Validate the raw `industry` object of a turn into a safe
* {@link IndustryOps}. Defensive like the rest of the parser — never throws.
* `projects`: each op needs a non-empty `regionKey` (capped at 120 chars) and a
* known project {@link IndustryType} (one of INDUSTRY_TYPES); ops with a
* missing/malformed field are dropped. The region key is kept raw here (like
* the hybrid / economy refs) — whether it is a *valid* region key, and whether
* its region ends the turn with a sole owner, is checked at apply time against
* the manifest and the final ownership (a project on a region with no sole
* owner yields no boost but is kept). A missing / malformed top-level object
* yields `{ projects: [] }`.
*/
export function validateIndustryOps(raw: unknown): IndustryOps {
const out: IndustryOps = { projects: [] };
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
const o = raw as Record<string, unknown>;
if (Array.isArray(o.projects)) {
for (const e of o.projects) {
if (!e || typeof e !== 'object') continue;
const p = e as Record<string, unknown>;
const regionKey = str(p.regionKey, 120);
if (!regionKey || !isIndustryType(p.type)) continue;
out.projects.push({ regionKey, type: p.type });
}
}
return out;
}
/**
* (v0.33.0, #56) The UNORDERED pairs of belligerents of a set of ACTIVE
* wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c},
@@ -1368,8 +1437,127 @@ function suspendEconomyOnWar(next: GameState): void {
next.embargoes = next.embargoes.filter((e) => !atWarNow(e.from, e.target));
}
// --- Industry projects: the per-region economy-boost lever (v0.36, #59) ------
/**
* A turn is "meaningful" if it produced at least an event, a stat change, a
* (v0.36, #59) The BASE per-turn economy boost one industry project grants its
* region's sole owner. A project's actual boost is this base plus one extra
* point when the region's `industry` resource value is at least 0.5 (a region
* with a strong industry profile yields a bigger return). The base value is
* deliberately the same magnitude as {@link TRADE_ECONOMY_BONUS} /
* {@link EMBARGO_ECONOMY_PENALTY}.
*/
const INDUSTRY_ECONOMY_BONUS = 1;
/**
* (v0.36, #59) The per-turn economy boost for ONE industry project sitting in
* `regionKey`: {@link INDUSTRY_ECONOMY_BONUS} plus one extra point when the
* region's `industry` resource value (from `resourceOf`) is at least 0.5.
* Without the resource manifest (or for a region with no profile) the boost is
* the base value. Pure + deterministic.
*/
function industryBoost(
regionKey: string,
resourceOf?: (key: string) => RegionResources | undefined,
): number {
const r = resourceOf?.(regionKey);
return INDUSTRY_ECONOMY_BONUS + (r && r.industry >= 0.5 ? 1 : 0);
}
/**
* (v0.36, #59) Apply the turn's industry ops to `GameState.industry` as a
* PER-REGION SET: each op names a region key + a project type. A region key
* that is not a valid manifest key (`validKeys`, when supplied) is dropped; a
* region that already hosts a project is untouched (the first declared type
* wins — re-declaring on the same region is a no-op, the type is never
* replaced). Otherwise the project is appended. Mutates `next` in place.
* Nothing here throws.
*/
function applyIndustryOps(
next: GameState,
ops: IndustryOps,
validKeys?: ReadonlySet<string>,
): void {
if (ops.projects.length === 0) return;
const has = (key: string) => next.industry.some((p) => p.regionKey === key);
for (const p of ops.projects) {
if (validKeys && !validKeys.has(p.regionKey)) continue; // unknown region key: drop
if (has(p.regionKey)) continue; // a region hosts at most one project
next.industry.push({ regionKey: p.regionKey, type: p.type });
}
}
/**
* (v0.36, #59) The per-turn economic effect of the industry layer, applied
* EVERY committed turn (not just to this turn's adds): each active project
* grants its region's SOLE owner a small, deterministic economy boost (see
* {@link industryBoost}) — clamped to 0–100, mirrored to every region the
* owner holds (`regionStats`), and each affected nation's derived stats are
* re-derived afterwards (the same 0-region / no-entry guards as
* `applyEconomyEffects`). A project whose region ends the turn with NO owner,
* or 2+ owners (released to no man's land / corrupt), yields no boost but is
* kept in the list. Sole ownership is untouched. No-op when there are no
* active projects. Never throws.
*/
export function applyIndustryEffects(
next: GameState,
resourceOf?: (key: string) => RegionResources | undefined,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): void {
if (next.industry.length === 0) return;
// Map each region key to its SOLE owner (a key with 0 or 2+ owners maps to
// nothing and yields no boost).
const ownerOf = new Map<string, string>();
{
const owners = new Map<string, string[]>();
for (const n of Object.values(next.nations)) {
for (const key of n.territory) {
const list = owners.get(key);
if (list) list.push(n.id);
else owners.set(key, [n.id]);
}
}
for (const [key, list] of owners) {
if (list.length === 1) ownerOf.set(key, list[0]);
}
}
// nation id -> accumulated economy boost (a nation owning several project
// regions gets each of their boosts).
const delta = new Map<string, number>();
for (const p of next.industry) {
const owner = ownerOf.get(p.regionKey);
if (!owner) continue; // 0 or 2+ owners: no boost (the project is kept)
const n = next.nations[owner];
if (!n) continue; // a nation removed this turn (a merge): the boost is dropped
delta.set(owner, (delta.get(owner) ?? 0) + industryBoost(p.regionKey, resourceOf));
}
const affected = new Set<string>();
for (const [id, d] of delta) {
const n = next.nations[id];
if (!n) continue;
n.stats.economy = clampStat(n.stats.economy + d);
for (const key of n.territory) {
const rs = next.regionStats[key];
if (rs) rs.economy = clampStat(rs.economy + d);
}
affected.add(id);
}
if (affected.size > 0) {
for (const id of affected) {
const n = next.nations[id];
if (n && n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
}
}
}
/**
* A turn is "meaningful" if it produced at least an event, a stat change, a stat change, a
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
* when the model under-narrates it), a nation-op proposal (v0.23 #16 P4a:
* a proposal is a real pending-world change the player will resolve, so it
@@ -1397,6 +1585,9 @@ export function isMeaningful(result: TurnResult): boolean {
// turn is a real world change (the active economy lists moved).
result.economy.trade.length > 0 ||
result.economy.embargo.length > 0 ||
// (v0.36, #59) an industry project built this turn is a real world change
// (the active project list moved).
result.industry.projects.length > 0 ||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
result.markers.add.length > 0 ||
result.markers.remove.length > 0
@@ -1441,6 +1632,13 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
* (from the geo `properties.name`, via server/regions.ts) for the text of the
* engine-emitted "invasion repelled" event; without it the event falls back to
* the raw key. Pure either way.
*
* `resourceOf` (v0.36, #59) resolves a region key to its synthesized resource
* profile (`{ oil, iron, food, industry }`, from public/region-resources.json
* via server/regions.ts). It is used to scale each industry project's per-turn
* economy boost by the region's `industry` value (a region with a strong
* industry profile yields a bigger return). Without it the boost is the
* unscaled base value — the engine stays pure and deterministic either way.
*/
export function applyTurnResult(
state: GameState,
@@ -1449,12 +1647,16 @@ export function applyTurnResult(
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
geoOf?: (key: string) => { area: number; population: number } | undefined,
regionNameOf?: (key: string) => string | undefined,
resourceOf?: (key: string) => RegionResources | undefined,
): GameState {
const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
// (v0.28, #46) per-region stats table; a legacy/corrupt clone without the
// field starts empty (the turn then re-inits claimed regions as it goes).
next.regionStats = next.regionStats ?? {};
// (v0.36, #59) active industry projects; a legacy/corrupt clone without the
// field starts empty (the turn then only ever adds to it).
next.industry = next.industry ?? [];
const actingTurn = state.turn; // this action resolves the current turn
next.turn = state.turn + 1; // then we advance to the next turn to play
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
@@ -1779,6 +1981,27 @@ export function applyTurnResult(
suspendEconomyOnWar(next);
applyEconomyEffects(next, geoOf);
// --- Industry projects (v0.36, #59): per-region economy-boost lever ---
//
// Placed AFTER the economy block (both move stats, and the industry boost
// should compose with the trade/embargo effects of the same turn) and
// before markers / nationOps. Order:
// (1) APPLY OPS: this turn's `industry.projects` are added to
// `GameState.industry` as a per-region SET — a region key that is not
// a valid manifest key is dropped, and a region that already hosts a
// project is untouched (the first declared type wins; re-declaring is
// a no-op).
// (2) PER-TURN EFFECT: the FULL active project list (not just this
// turn's adds) each grants their region's SOLE owner a small,
// deterministic economy boost scaled by the region's `industry`
// resource value — clamped, mirrored to every region the owner holds,
// and re-derived like the economy/hybrid effects. A project whose
// region ends the turn with no owner (or 2+ owners) yields no boost
// but is KEPT (the region may be re-claimed later). Sole ownership is
// untouched (the layer moves stats, never territory).
applyIndustryOps(next, result.industry, validKeys);
applyIndustryEffects(next, resourceOf, geoOf);
// --- Map markers (v0.27, #44) ---
//
// Markers are anchored to a region key and are processed against the FINAL
@@ -2250,8 +2473,17 @@ export async function runTurn(opts: {
* it falls back to the raw region key. Pure either way.
*/
regionNameOf?: (key: string) => string | undefined;
/**
* (v0.36, #59) Region-key -> synthesized resource profile resolver (from
* public/region-resources.json via server/regions.ts). Present, each
* industry project's per-turn economy boost is scaled by its region's
* `industry` value, and each nation's aggregate resource profile is carried
* in the user prompt; absent, industry boosts use the unscaled base value
* and the profile is omitted.
*/
resourceOf?: (key: string) => RegionResources | undefined;
}): Promise<{ state: GameState; result: TurnResult }> {
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver, regionNameOf } = opts;
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver, regionNameOf, resourceOf } = opts;
const chatWithTools = llm.chatWithTools;
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
const system = buildSystemPrompt(preset, useTools);
@@ -2282,6 +2514,20 @@ export async function runTurn(opts: {
// starting point before an `exposed` backfire). Always emitted ({} when the
// player has no relations yet).
const relations = state.nations[state.playerNationId]?.relations ?? {};
// (v0.36, #59) each nation's AGGREGATE resource profile (the area×population-
// weighted mean of the resource profiles of the regions it owns). Carried in
// the user prompt ONLY when the resource manifest is loaded (resourceOf
// present); otherwise omitted (undefined) so the prompt — and the stub —
// stay identical to the no-manifest case.
const nationResources =
resourceOf === undefined
? undefined
: Object.fromEntries(
Object.values(state.nations).map((n) => [
n.id,
deriveNationResources(n.territory, resourceOf, geoOf),
]),
);
const user = buildUserPrompt(
state,
items,
@@ -2295,6 +2541,12 @@ export async function runTurn(opts: {
// reference — and `lift` — what is currently in force.
state.trades,
state.embargoes,
// (v0.36, #59) the aggregate resource profiles (undefined without the
// manifest) + the active industry projects, so the model knows which
// regions already host a project and which nations are rich in which
// resource.
nationResources,
state.industry ?? [],
);
const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
@@ -2351,12 +2603,13 @@ export async function runTurn(opts: {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
industry: { projects: [] },
markers: { add: [], remove: [] },
};
}
return {
state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf, regionNameOf),
state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf, regionNameOf, resourceOf),
result,
};
}