feat(#50): make the four 0-100 stats consequential
CI / build + unit + e2e (pull_request) Successful in 2m8s
CI / build + unit + e2e (pull_request) Successful in 2m8s
- Add a hard military conquest gate (canConquer, CONQUEST_MILITARY_MARGIN=15) enforced in applyTurnResult: a contested invasion is repelled when the attacker's military is more than 15 below the defender's; re-claims of unowned regions are never gated. Emits a new 'repelled' event. - Document the soft levers in the system prompt: stability<30 + gathering marker -> non-military takeover; economy growth/decay drives other stats. - Emit bounded frontierMilitary (per-neighbour balance) and takeoverRisks hints in the user payload (only when present). - repelled outcome color (#9f1239); full unit test coverage; stub untouched. - Bump to v0.30.0. Fixes #50
This commit is contained in:
+111
-2
@@ -19,6 +19,7 @@ import type {
|
||||
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
||||
import {
|
||||
addDays,
|
||||
canConquer,
|
||||
clampStat,
|
||||
deriveNationStats,
|
||||
fnv1a32,
|
||||
@@ -201,6 +202,11 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
|
||||
'}',
|
||||
'',
|
||||
'HOW THE FOUR 0-100 STATS DRIVE OUTCOMES (military / economy / stability / population)',
|
||||
'- MILITARY (the engine ENFORCES this rule, so weigh it before proposing a conquest): a nation can take a region by force only when its military is at least the defender\'s military minus 15 — equal, or within 15, is enough to win, but an attacker MORE than 15 below the defender is REPELLED and the region stays with its owner (a "repelled" event is recorded automatically; you cannot capture such a region that turn). Re-claiming an unowned (no man\'s land) region is never blocked this way.',
|
||||
'- STABILITY (you drive this, no hard gate): a nation whose stability is below 30 AND that has a "gathering" marker on one of its regions can suffer a NON-military takeover — a coup, revolution or faction seizure of power. Narrate it with a political or social event (outcome "success"), a "gathering" marker on the region, and stateChanges that shock its stability and economy downward.',
|
||||
'- ECONOMY (you drive this, no hard gate): economy drives long-term development — a strong economy lets a nation\'s other stats trend UP over time (small per-turn gains via stateChanges / regionChanges), while a weak economy lets them DECAY. Reflect that trend in the stat changes you make and narrate it.',
|
||||
'',
|
||||
'HARD CONSTRAINTS',
|
||||
'- Use only nation ids and region keys that exist in the provided state.',
|
||||
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.',
|
||||
@@ -280,6 +286,63 @@ export function buildUserPrompt(
|
||||
}
|
||||
}
|
||||
|
||||
// (v0.30, #50) A per-neighbor military-balance hint for the player's frontier,
|
||||
// so the model — and, via the same data, the player — sees the hard gate
|
||||
// before it fires: for each frontier neighbor, compare the player's derived
|
||||
// military to the neighbor's and state whether a direct invasion would be
|
||||
// repelled (via canConquer). Computed here (pure) from `state` + `frontier`;
|
||||
// emitted only when a non-empty frontier is present, and the rest of the
|
||||
// payload is left untouched.
|
||||
let frontierMilitary: string[] | undefined;
|
||||
if (frontier !== undefined && frontier.length > 0) {
|
||||
const playerMil = player?.stats.military ?? 0;
|
||||
const ownerByKey = new Map<string, string>(); // region key -> owning nation id
|
||||
for (const n of Object.values(state.nations)) {
|
||||
for (const k of n.territory) ownerByKey.set(k, n.id);
|
||||
}
|
||||
const byNeighbor = new Map<string, number>(); // neighbor id -> its military (deduped)
|
||||
for (const k of frontier) {
|
||||
const ownerId = ownerByKey.get(k);
|
||||
if (!ownerId || ownerId === state.playerNationId) continue; // unowned / self: skip
|
||||
const owner = state.nations[ownerId];
|
||||
if (!owner) continue;
|
||||
if (!byNeighbor.has(ownerId)) byNeighbor.set(ownerId, owner.stats.military);
|
||||
}
|
||||
if (byNeighbor.size > 0) {
|
||||
frontierMilitary = [...byNeighbor.entries()].map(([ownerId, mil]) => {
|
||||
const o = state.nations[ownerId];
|
||||
const wins = canConquer(playerMil, mil);
|
||||
return `[Your military: ${playerMil}] vs [${o?.name ?? ownerId}: ${mil}] — ${
|
||||
wins ? 'a direct invasion could succeed' : 'a direct invasion would be repelled'
|
||||
}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// (v0.30, #50) Bounded soft-lever hint: nations whose stability is below 30
|
||||
// AND that currently have a "gathering" marker on one of their regions are
|
||||
// the ones ripe for a NON-military takeover (a coup / revolution / faction
|
||||
// seizure) — the model can act on them this turn. One line per matching
|
||||
// nation, emitted only when there is at least one; the rest of the payload
|
||||
// is left untouched. A soft nudge only — there is no hard gate behind it.
|
||||
let takeoverRisks: string[] | undefined;
|
||||
if (markers !== undefined && markers.length > 0) {
|
||||
const risks = new Set<string>();
|
||||
for (const m of markers) {
|
||||
if (m.type !== 'gathering') continue;
|
||||
const ownerId = m.owner;
|
||||
if (!ownerId) continue;
|
||||
const n = state.nations[ownerId];
|
||||
if (n && n.stats.stability < 30) risks.add(n.id);
|
||||
}
|
||||
if (risks.size > 0) {
|
||||
takeoverRisks = [...risks].map((id) => {
|
||||
const n = state.nations[id];
|
||||
return `${n?.name ?? id} (stability ${n?.stats.stability}, a gathering is in progress)`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
state: {
|
||||
turn: state.turn,
|
||||
@@ -305,6 +368,8 @@ export function buildUserPrompt(
|
||||
};
|
||||
if (regionReference) payload.regions = regionReference;
|
||||
if (frontier !== undefined) payload.frontier = frontier;
|
||||
if (frontierMilitary !== undefined) payload.frontierMilitary = frontierMilitary;
|
||||
if (takeoverRisks !== undefined) payload.takeoverRisks = takeoverRisks;
|
||||
if (noMansLand !== undefined) payload.unowned = noMansLand;
|
||||
if (markers !== undefined) payload.markers = markers;
|
||||
return JSON.stringify(payload, null, 2);
|
||||
@@ -695,6 +760,11 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
|
||||
* area×population-weighted median of its regions' stats. Without it the
|
||||
* recompute degrades to an unweighted median (every region weight 1) — still
|
||||
* region-consistent, just unweighted. The engine stays pure either way.
|
||||
*
|
||||
* `regionNameOf` (v0.30, #50) resolves a region key to its human display name
|
||||
* (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.
|
||||
*/
|
||||
export function applyTurnResult(
|
||||
state: GameState,
|
||||
@@ -702,6 +772,7 @@ export function applyTurnResult(
|
||||
validKeys?: ReadonlySet<string>,
|
||||
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
regionNameOf?: (key: string) => string | undefined,
|
||||
): GameState {
|
||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
||||
@@ -809,6 +880,34 @@ export function applyTurnResult(
|
||||
const owner = ownerOf.get(key); // undefined: no owner; '': 2+ owners (corrupt)
|
||||
if (owner === '') continue; // pre-existing double ownership: never touched
|
||||
if (owner !== undefined && owner === gainer) continue; // self-gain: no-op
|
||||
// (v0.30, #50) MILITARY HARD GATE: a CONTESTED conquest (the key has a
|
||||
// different sole owner) only proceeds when the attacker's derived military
|
||||
// is not MORE than CONQUEST_MILITARY_MARGIN below the defender's. A
|
||||
// re-claim of an unowned key (owner === undefined) is never gated. On
|
||||
// repel, the key stays with its owner, a "repelled" event is emitted (the
|
||||
// engine's own narrative — the model never sees it as an input), and the
|
||||
// gainer is untouched; the engine never throws.
|
||||
if (
|
||||
owner !== undefined &&
|
||||
!canConquer(next.nations[gainer].stats.military, next.nations[owner].stats.military)
|
||||
) {
|
||||
next.events.push(
|
||||
makeEvent(
|
||||
{
|
||||
nation: gainer,
|
||||
type: 'military',
|
||||
title: 'Invasion repelled',
|
||||
text: `${next.nations[gainer].name}'s invasion of ${
|
||||
regionNameOf?.(key) ?? key
|
||||
} was repelled by ${next.nations[owner].name}.`,
|
||||
outcome: 'repelled',
|
||||
},
|
||||
actingTurn,
|
||||
next.date,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const n of Object.values(next.nations)) {
|
||||
const i = n.territory.indexOf(key);
|
||||
if (i !== -1) n.territory.splice(i, 1);
|
||||
@@ -1325,8 +1424,15 @@ export async function runTurn(opts: {
|
||||
* applyTurnResult's regionStats lookup).
|
||||
*/
|
||||
regionKeyResolver?: (ref: string) => string | null;
|
||||
/**
|
||||
* (v0.30, #50) Region-key -> human display name resolver (the geo
|
||||
* `properties.name`, from server/regions.ts). Present, the engine's
|
||||
* "invasion repelled" event names the region by its display name; absent,
|
||||
* it falls back to the raw region key. Pure either way.
|
||||
*/
|
||||
regionNameOf?: (key: string) => string | undefined;
|
||||
}): Promise<{ state: GameState; result: TurnResult }> {
|
||||
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver } = opts;
|
||||
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver, regionNameOf } = opts;
|
||||
const chatWithTools = llm.chatWithTools;
|
||||
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
||||
const system = buildSystemPrompt(preset, useTools);
|
||||
@@ -1398,7 +1504,10 @@ export async function runTurn(opts: {
|
||||
};
|
||||
}
|
||||
|
||||
return { state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf), result };
|
||||
return {
|
||||
state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf, regionNameOf),
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Advisor (brainstorm) -----------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user