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
-4
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user