feat(abilities): add disasters & pandemics lever (v0.41.0, #63)
CI / build + unit + e2e (pull_request) Successful in 1m55s
CI / build + unit + e2e (pull_request) Successful in 1m55s
Add the 9th modular LLM ability: a turn-transient `TurnResult.disasters`
lever letting the LLM declare natural/man-made disasters (earthquake /
flood / volcanic / wildfire / famine / plague / epidemic) on a nation or
a region, applying a DETERMINISTIC (type, severity) stat dip (no hidden
randomness). Like the hybrid layer it owns no GameState field, needs no
db.ts change, and leaves no durable list — an ongoing plague/epidemic is
simply re-declared each turn it persists.
- shared/protocol.ts: DisasterType / DisasterSeverity / DisasterEvent /
DisastersOps, and `TurnResult.disasters` (between `ideology` and
`markers`; required, default `{ events: [] }`).
- server/abilities/disasters.ts: the ability module (validate / apply /
schemaLine / constraint / isMeaningful / inert stub / empty payload; no
stateFields). `DISASTER_EFFECTS` is the deterministic (type, severity)
dip table (population never touched). Target resolution mirrors hybrid
(a region key hits its SOLE owner; 0/2+ owners dropped) but has NO
`from` field, so there is no self-target drop. Dips clamp to 0-100,
mirror to the target's regions (regionStats), and re-derive the target
nation's stats (same 0-region / no-entry guards).
- Registered last in ABILITIES (after ideology). System-prompt goldens
regenerated (one schema line + one constraint bullet each); the
user.txt golden stays byte-identical (payload() is {}).
- New server/abilities/disasters.test.ts (33 tests); registry.test.ts
updated to the 9-ability contract.
- Secondary cleanups from the v0.40 refactor: AGENTS.md (abilities/ in
the Layout tree + a "how to add a new ability" bullet + a disasters
bullet), delete the orphaned validateTakeovers JSDoc, fold both inline
`as unknown` casts into the top-level ./abilities import, and de-dup
`str` (now imported from ./abilities/shared).
Version bumped 0.40.0 -> 0.41.0.
This commit is contained in:
+23
-36
@@ -57,8 +57,9 @@ import {
|
||||
NEW_NATION_STATS,
|
||||
resolveNationRef,
|
||||
resolveRegionKey,
|
||||
str,
|
||||
} from './abilities/shared';
|
||||
import { ABILITIES } from './abilities';
|
||||
import { ABILITIES, type AbilityCtx, type UserPromptCtx } from './abilities';
|
||||
// Re-exported so the existing callers (server/index.ts, the unit tests) keep
|
||||
// importing these shared primitives from the engine unchanged.
|
||||
export { freshNationId, freshWarId, NEW_NATION_STATS, resolveNationRef, resolveRegionKey };
|
||||
@@ -86,6 +87,13 @@ export {
|
||||
validateTakeovers,
|
||||
validateWarOps,
|
||||
applyIndustryEffects,
|
||||
validateDisasterEvents,
|
||||
applyDisasterEvent,
|
||||
isDisasterType,
|
||||
isDisasterSeverity,
|
||||
DISASTER_TYPES,
|
||||
DISASTER_SEVERITIES,
|
||||
DISASTER_EFFECTS,
|
||||
} from './abilities';
|
||||
|
||||
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
|
||||
@@ -458,7 +466,7 @@ export function buildUserPrompt(
|
||||
// (v0.40.0, #67) Build the UserPromptCtx once and merge each ability's
|
||||
// payload() at its exact position (the key order is load-bearing for the
|
||||
// golden test).
|
||||
const upCtx = {
|
||||
const upCtx: UserPromptCtx = {
|
||||
state,
|
||||
markers,
|
||||
wars,
|
||||
@@ -468,14 +476,13 @@ export function buildUserPrompt(
|
||||
nationResources,
|
||||
industryProjects,
|
||||
};
|
||||
const upCtxAny = upCtx as unknown as import('./abilities').UserPromptCtx;
|
||||
Object.assign(payload, ABILITIES[0].payload(upCtxAny)); // takeovers → takeoverRisks
|
||||
Object.assign(payload, ABILITIES[0].payload(upCtx)); // takeovers → takeoverRisks
|
||||
if (noMansLand !== undefined) payload.unowned = noMansLand;
|
||||
if (markers !== undefined) payload.markers = markers;
|
||||
Object.assign(payload, ABILITIES[1].payload(upCtxAny)); // wars → wars
|
||||
Object.assign(payload, ABILITIES[2].payload(upCtxAny)); // hybrid → relations
|
||||
Object.assign(payload, ABILITIES[3].payload(upCtxAny)); // economy → trades, embargoes
|
||||
Object.assign(payload, ABILITIES[4].payload(upCtxAny)); // industry → resources, industry
|
||||
Object.assign(payload, ABILITIES[1].payload(upCtx)); // wars → wars
|
||||
Object.assign(payload, ABILITIES[2].payload(upCtx)); // hybrid → relations
|
||||
Object.assign(payload, ABILITIES[3].payload(upCtx)); // economy → trades, embargoes
|
||||
Object.assign(payload, ABILITIES[4].payload(upCtx)); // industry → resources, industry
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
@@ -534,13 +541,6 @@ export function parseTurnResult(
|
||||
return validateTurnResult(obj, regionKeyResolver);
|
||||
}
|
||||
|
||||
function str(s: unknown, max = 0): string | null {
|
||||
if (typeof s !== 'string') return null;
|
||||
const t = s.trim();
|
||||
if (!t) return null;
|
||||
return max ? t.slice(0, max) : t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a raw (LLM) `nationOps` array into safe {@link NationOp} values
|
||||
* (v0.23, #16 P4a). Defensive like the rest of the parser: entries with an
|
||||
@@ -621,6 +621,7 @@ export function validateTurnResult(
|
||||
nuclear: { arm: [], strikes: [] },
|
||||
leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
disasters: { events: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
@@ -711,7 +712,7 @@ export function validateTurnResult(
|
||||
// list for the player to confirm/reject.
|
||||
result.nationOps = validateNationOps(o.nationOps);
|
||||
|
||||
// The 8 per-feature LLM abilities (v0.40.0, #67): each one's validate call
|
||||
// The 9 per-feature LLM abilities (v0.40.0, #67): each one's validate call
|
||||
// is owned by its Ability module in the ABILITIES registry. The field's raw
|
||||
// value is passed through a.validate() which returns the validated field shape.
|
||||
for (const a of ABILITIES) {
|
||||
@@ -824,19 +825,6 @@ export function validateRegionChanges(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.32.0, #54) Validate the raw `takeovers` array of a turn into a safe
|
||||
* {@link Takeover} list — the non-military regime-change lever (a coup or
|
||||
* revolution that renames a nation to its new regime name). Defensive like
|
||||
* the rest of the parser — never throws. Each entry needs a non-empty
|
||||
* `nationRef` (the target nation's id or name — capped at 200 chars) and a
|
||||
* non-empty `newRegimeName` (capped at 200 chars). Resolution of `nationRef`
|
||||
* to a concrete nation id happens at apply time (`resolveNationRef`), not
|
||||
* here. Entries with either field missing or whitespace-only are dropped.
|
||||
*/
|
||||
// (v0.40.0, #67) The 8 per-feature ability validators, appliers, and helpers
|
||||
// have been moved to server/abilities/ — re-exported below for the tests.
|
||||
|
||||
export function isMeaningful(result: TurnResult): boolean {
|
||||
return (
|
||||
result.events.length > 0 ||
|
||||
@@ -845,7 +833,7 @@ export function isMeaningful(result: TurnResult): boolean {
|
||||
result.regionChanges.length > 0 ||
|
||||
result.unowned.length > 0 ||
|
||||
result.nationOps.length > 0 ||
|
||||
// (v0.40.0, #67) any of the 8 per-feature abilities having a non-empty
|
||||
// (v0.40.0, #67) any of the 9 per-feature abilities having a non-empty
|
||||
// payload is a real world change (each Ability's isMeaningful predicate
|
||||
// is the per-ability half of the old 18-sub-field check).
|
||||
ABILITIES.some((a) => a.isMeaningful(result[a.field])) ||
|
||||
@@ -1112,12 +1100,12 @@ export function applyTurnResult(
|
||||
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||
}
|
||||
|
||||
// (v0.40.0, #67) The 8 per-feature LLM abilities, applied in ABILITIES
|
||||
// (v0.40.0, #67) The 9 per-feature LLM abilities, applied in ABILITIES
|
||||
// order (takeovers → wars → hybrid → economy → industry → nuclear →
|
||||
// leaders → ideology). Each Ability's apply method handles its own
|
||||
// field's validated data against the post-territory/post-recompute
|
||||
// leaders → ideology → disasters). Each Ability's apply method handles its
|
||||
// own field's validated data against the post-territory/post-recompute
|
||||
// `next` state. The AbilityCtx carries the shared resolvers.
|
||||
const abilityCtx = {
|
||||
const abilityCtx: AbilityCtx = {
|
||||
previous: state,
|
||||
actingTurn,
|
||||
validRegionKeys: validKeys,
|
||||
@@ -1126,9 +1114,8 @@ export function applyTurnResult(
|
||||
resourceOf,
|
||||
centroidOf,
|
||||
};
|
||||
const abilityCtxAny = abilityCtx as unknown as import('./abilities').AbilityCtx;
|
||||
for (const a of ABILITIES) {
|
||||
a.apply(next, result[a.field], abilityCtxAny);
|
||||
a.apply(next, result[a.field], abilityCtx);
|
||||
}
|
||||
|
||||
// --- Map markers (v0.27, #44) ---
|
||||
|
||||
Reference in New Issue
Block a user