refactor(#74) P3: add the 8 ability modules + ABILITIES registry
Each per-feature LLM ability is now a self-contained module in server/abilities/ with its own Ability<K> const (schema line, constraint bullet, validator, applier, isMeaningful, stub default, user-payload field). The ordered ABILITIES registry (index.ts) assembles them in the canonical order: takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology. All function bodies are moved VERBATIM from engine.ts. The engine is untouched in this commit — P4 will rewire it to use the registry.
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
// The INDUSTRY ability (v0.36, #59) as a modular LLM-ability (v0.40.0, #67).
|
||||
// The per-region economy-boost lever: each project is a building in one
|
||||
// region (`{ regionKey, type }`, type one of oil / iron / food / industry).
|
||||
// `GameState.industry` is a PER-REGION SET — a region key that is not a valid
|
||||
// manifest key is dropped; a region that already hosts a project is untouched
|
||||
// (the first declared type wins — re-declaring is a no-op). Every committed
|
||||
// turn each active project grants its region's SOLE owner a small,
|
||||
// deterministic economy boost (`INDUSTRY_ECONOMY_BONUS`, plus one extra point
|
||||
// when the region's `industry` resource value is ≥ 0.5 — from the synthesized
|
||||
// resource manifest; without it just the base) — clamped to 0–100, mirrored
|
||||
// to every region the owner holds, and re-derived like the economy/hybrid
|
||||
// effects. A project on a region with 0 or 2+ owners yields no boost but is
|
||||
// kept. Sole ownership is untouched (the layer moves stats, never territory).
|
||||
// All bodies are moved VERBATIM from the pre-refactor engine.ts.
|
||||
|
||||
import { clampStat, deriveNationStats } from '../../shared/protocol';
|
||||
import type { GameState, IndustryType, RegionResources } from '../../shared/types';
|
||||
import { INDUSTRY_TYPES } from '../../shared/types';
|
||||
import type { IndustryOps } from '../../shared/protocol';
|
||||
import { str } from './shared';
|
||||
import type { Ability } from './types';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* (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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const industry: Ability<'industry'> = {
|
||||
field: 'industry',
|
||||
default: () => ({ projects: [] }),
|
||||
validate: (raw) => validateIndustryOps(raw),
|
||||
apply: (state, data, ctx) => {
|
||||
// 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(state, data, ctx.validRegionKeys);
|
||||
applyIndustryEffects(state, ctx.resourceOf, ctx.geoOf);
|
||||
},
|
||||
schemaLine: () =>
|
||||
' "industry": {"projects":[{"regionKey":"<region key>","type":"<oil|iron|food|industry>"}]},',
|
||||
constraint: () =>
|
||||
'- 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".',
|
||||
isMeaningful: (data) => data.projects.length > 0,
|
||||
stateFields: ['industry'],
|
||||
stub: () => ({ projects: [] }),
|
||||
payload: (ctx) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (ctx.nationResources !== undefined) out.resources = ctx.nationResources;
|
||||
if (ctx.industryProjects !== undefined) out.industry = ctx.industryProjects;
|
||||
return out;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user