// 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(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; if (Array.isArray(o.projects)) { for (const e of o.projects) { if (!e || typeof e !== 'object') continue; const p = e as Record; 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, ): 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(); { const owners = new Map(); 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(); 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(); 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":"","type":""}]},', constraint: () => '- Industry ("industry") is the per-resource development lever: each project is a building in one region you (or a nation) controls — {"regionKey":"","type":""} (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 = {}; if (ctx.nationResources !== undefined) out.resources = ctx.nationResources; if (ctx.industryProjects !== undefined) out.industry = ctx.industryProjects; return out; }, };