import type { ActionContextBase, ActionContextBuilder, City, GeneralActionDefinition, LogEntryDraft, MapDefinition, MessageDraft, Nation, ScenarioConfig, ScenarioMeta, Troop, GeneralTurnCommandKey, TurnCommandProfile, TurnCommandEnv, UnitSetDefinition, } from '@sammo-ts/logic'; import { DEFAULT_TURN_COMMAND_PROFILE, GeneralTurnCommandLoader, GeneralActionPipeline, NationTurnCommandLoader, createGeneralTriggerContext, defaultActionContextBuilder, evaluateConstraints, resolveGeneralAction, ITEM_KEYS, addOccupiedUniqueItemKeys, buildGenericUniqueSeed, countOccupiedUniqueItems, createItemModuleRegistry, loadItemModules, resolveUniqueConfig, rollUniqueLottery, getNextTurnAt, getBillByLevel, type ItemModule, type UniqueLotteryRunner, } from '@sammo-ts/logic'; import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import { asRecord, JosaUtil, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import type { ConstraintContext, StateView } from '@sammo-ts/logic'; import type { GeneralTurnHandler, GeneralTurnResult } from './inMemoryWorld.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { TurnDiplomacy, TurnGeneral, TurnWorldState } from './types.js'; import type { ReservedTurnEntry } from './reservedTurnStore.js'; import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; import { applyDiplomacyPatch as applyDiplomacyPatchToEntry, buildDefaultDiplomacy, buildDiplomacyKey, type DiplomacyPatch, } from '@sammo-ts/logic'; import { buildCommandEnv, buildReservedTurnDefinitions } from './reservedTurnCommands.js'; import { buildFrontStatePatches } from './frontStateHandler.js'; import { buildActionContext } from './reservedTurnActionContext.js'; import { GeneralAI, shouldUseAi } from './ai/generalAi.js'; import type { AiReservedTurnProvider } from './ai/types.js'; import { withCanonicalArgumentAliases } from './ai/aiUtils.js'; import { rankMetaKey } from './rankData.js'; import { hasScenarioStaticEventHandler, IMMEDIATE_ASSIGNMENT_GATHER_HANDLER, LEGACY_NATION_ASSIGNMENT_EVENT, } from './scenarioStaticEvents.js'; const DEFAULT_ACTION = '휴식'; const AI_INTERNAL_GENERAL_ACTION_KEYS = ['che_NPC능동'] as const satisfies readonly GeneralTurnCommandKey[]; const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([ 'che_소집해제', 'che_랜덤임관', 'che_헌납', 'che_강행', 'che_정착장려', 'che_숙련전환', 'che_주민선정', 'che_모반시도', 'che_요양', 'che_거병', 'che_건국', 'che_증여', 'che_훈련', 'che_견문', 'che_무작위건국', 'che_화계', 'che_선동', 'che_파괴', 'che_탈취', 'che_집합', 'cr_건국', 'che_이동', 'cr_맹훈련', 'che_인재탐색', 'che_귀환', 'che_사기진작', 'che_군량매매', 'che_기술연구', 'che_첩보', 'che_임관', 'che_상업투자', // These legacy classes inherit che_상업투자::run(), including its // unconditional checkStatChange() tail. 'che_농지개간', 'che_수비강화', 'che_성벽보수', 'che_치안강화', 'che_장비매매', 'che_장수대상임관', 'che_징병', 'che_단련', 'che_등용', 'che_하야', 'che_물자조달', 'che_선양', 'che_전투태세', ]); export const applyLegacyGeneralProgression = ( general: TurnGeneral, previousGeneral: TurnGeneral, actionKey: string, env: TurnCommandEnv, logs: LogEntryDraft[] ): TurnGeneral => { const maxStatLevel = env.maxStatLevel ?? 255; const maxDedicationLevel = env.maxDedicationLevel ?? 30; const expLevel = Math.max( 0, Math.min( maxStatLevel, general.experience < 1_000 ? Math.trunc(general.experience / 100) : Math.trunc(Math.sqrt(general.experience / 10)) ) ); const dedicationLevel = Math.max(0, Math.min(maxDedicationLevel, Math.ceil(Math.sqrt(general.dedication) / 10))); const meta = { ...general.meta }; // 하야는 ref에서 addExperience(0)/addDedication(0)을 호출해 현재 값으로 // 등급을 강제 재계산한다. 반대로 은퇴의 rebirth()와 선양의 // multiplyVar('experience')는 수치를 줄이면서도 기존 등급을 그대로 둔다. const forceRefreshLevel = actionKey === 'che_하야'; // Battle units update levels before finishBattle() rounds the legacy INT // columns. che_출병 must retain that pre-round result just like Ref. const preserveLevel = actionKey === 'che_은퇴' || actionKey === 'che_선양' || actionKey === 'che_출병' || actionKey === 'che_물자조달'; if (!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience)) { const previousExpLevel = readMetaNumber(previousGeneral.meta, 'explevel', 0); const actionResolvedExpLevel = readMetaNumber(general.meta, 'explevel', previousExpLevel); meta.explevel = expLevel; if (expLevel !== previousExpLevel && actionResolvedExpLevel !== expLevel) { const josaRo = JosaUtil.pick(String(expLevel), '로'); logs.push({ scope: LogScope.GENERAL, category: LogCategory.ACTION, format: LogFormat.PLAIN, text: expLevel > previousExpLevel ? `Lv ${expLevel}${josaRo} 레벨업!` : `Lv ${expLevel}${josaRo} 레벨다운!`, }); } } if (!preserveLevel && (forceRefreshLevel || general.dedication !== previousGeneral.dedication)) { const previousDedicationLevel = readMetaNumber(previousGeneral.meta, 'dedlevel', 0); meta.dedlevel = dedicationLevel; if (dedicationLevel !== previousDedicationLevel) { const dedicationLevelText = dedicationLevel === 0 ? '무품관' : `${maxDedicationLevel - dedicationLevel + 1}품관`; const billText = getBillByLevel(dedicationLevel).toLocaleString('en-US'); const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로'); const josaRoBill = JosaUtil.pick(billText, '로'); logs.push({ scope: LogScope.GENERAL, category: LogCategory.ACTION, format: LogFormat.PLAIN, text: dedicationLevel > previousDedicationLevel ? `${dedicationLevelText}${josaRoDedication} 승급하여 봉록이 ${billText}${josaRoBill} 상승했습니다!` : `${dedicationLevelText}${josaRoDedication} 강등되어 봉록이 ${billText}${josaRoBill} 하락했습니다!`, }); } } if (!LEGACY_STAT_CHANGE_GENERAL_ACTIONS.has(actionKey)) { return { ...general, meta }; } const stats = { ...general.stats }; const limit = env.statUpgradeLimit ?? 30; const entries = [ ['leadership', 'leadership_exp'], ['strength', 'strength_exp'], ['intelligence', 'intel_exp'], ] as const; for (const [statKey, expKey] of entries) { const rawExp = typeof meta[expKey] === 'number' ? meta[expKey] : 0; if (rawExp < 0) { meta[expKey] = rawExp + limit; stats[statKey] -= 1; } else if (rawExp >= limit) { if (stats[statKey] < maxStatLevel) { stats[statKey] += 1; } meta[expKey] = rawExp - limit; } } return { ...general, stats, meta }; }; const resolveConstraintEnv = ( world: TurnWorldState, scenarioMeta: ScenarioMeta | undefined, env: TurnCommandEnv ): Record => { const worldMeta = asRecord(world.meta); const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.startYear : undefined; const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined; const joinModeRaw = worldMeta.join_mode ?? worldMeta.joinMode; const joinMode = joinModeRaw === 'onlyRandom' ? 'onlyRandom' : 'full'; const killturnRaw = worldMeta.killturn; const killturn = typeof killturnRaw === 'number' ? killturnRaw : typeof killturnRaw === 'string' ? Number(killturnRaw) : undefined; return { ...env, currentYear: world.currentYear, currentMonth: world.currentMonth, year: world.currentYear, month: world.currentMonth, startYear, relYear, openingPartYear: env.openingPartYear, minAvailableRecruitPop: env.minAvailableRecruitPop, join_mode: joinMode, ...(Number.isFinite(killturn) ? { killturn } : {}), }; }; const buildSeedBase = (world: TurnWorldState): string => { const meta = asRecord(world.meta); const rawSeed = meta.hiddenSeed ?? meta.seed ?? world.id; return String(rawSeed); }; const serializeSeed = (...values: Array): string => values .map((value) => (typeof value === 'string' ? `str(${value.length},${value})` : `int(${Math.floor(value)})`)) .join('|'); const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1; const readConfigNumber = (config: ScenarioConfig, key: string, fallback: number): number => { const value = asRecord(config.const)[key]; return typeof value === 'number' && Number.isFinite(value) ? value : fallback; }; const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({ ...general, stats: { ...general.stats }, role: { ...general.role, items: { ...general.role.items }, }, meta: { ...general.meta }, triggerState: { ...general.triggerState, flags: { ...general.triggerState.flags }, counters: { ...general.triggerState.counters }, modifiers: { ...general.triggerState.modifiers }, meta: { ...general.triggerState.meta }, }, }); const resetRetiredGeneral = (general: TurnGeneral): TurnGeneral => { const meta = { ...general.meta }; for (const type of LEGACY_RANK_DATA_TYPES) { meta[rankMetaKey(type)] = 0; } meta.specage = 0; meta.specage2 = 0; for (let dex = 1; dex <= 5; dex += 1) { const key = `dex${dex}`; meta[key] = Math.round(readMetaNumber(meta, key, 0) * 0.5); } meta.inherit_lived_month = 0; meta.inherit_active_action = 0; return { ...general, stats: { leadership: Math.max(10, Math.round(general.stats.leadership * 0.85)), strength: Math.max(10, Math.round(general.stats.strength * 0.85)), intelligence: Math.max(10, Math.round(general.stats.intelligence * 0.85)), }, injury: 0, experience: Math.round(general.experience * 0.5), dedication: Math.round(general.dedication * 0.5), age: 20, meta, }; }; type LegacyLastTurn = { command: string; arg?: Record; term?: number; seq?: number; }; const nationLastTurnKey = (officerLevel: number): string => `turn_last_${officerLevel}`; const normalizeLastTurn = (value: unknown): LegacyLastTurn => { const raw = asRecord(value); return { command: typeof raw.command === 'string' ? raw.command : '휴식', ...(asRecord(raw.arg) && Object.keys(asRecord(raw.arg)).length > 0 ? { arg: asRecord(raw.arg) } : undefined), ...(typeof raw.term === 'number' && Number.isFinite(raw.term) ? { term: Math.floor(raw.term) } : undefined), ...(typeof raw.seq === 'number' && Number.isFinite(raw.seq) ? { seq: Math.floor(raw.seq) } : undefined), }; }; const sameArgs = (left: Record | undefined, right: Record): boolean => JSON.stringify(left ?? {}) === JSON.stringify(right); const readNextAvailableTurn = (nation: Nation, actionName: string): number | null => { const raw = asRecord(nation.meta)[`next_execute_${actionName}`]; if (typeof raw === 'number' && Number.isFinite(raw)) { return Math.floor(raw); } if (typeof raw === 'string') { const parsed = Number(raw); return Number.isFinite(parsed) ? Math.floor(parsed) : null; } return null; }; const readGeneralNextAvailableTurn = (general: TurnGeneral, actionName: string): number | null => { const raw = asRecord(general.meta)[`next_execute_${actionName}`]; if (typeof raw === 'number' && Number.isFinite(raw)) { return Math.floor(raw); } if (typeof raw === 'string') { const parsed = Number(raw); return Number.isFinite(parsed) ? Math.floor(parsed) : null; } return null; }; const readMetaNumber = (meta: Record, key: string, fallback: number): number => { const value = meta[key]; if (typeof value === 'number' && Number.isFinite(value)) { return Math.floor(value); } if (typeof value === 'string') { const parsed = Number(value); if (Number.isFinite(parsed)) { return Math.floor(parsed); } } return fallback; }; const readMetaBool = (meta: Record, key: string, fallback = false): boolean => { const value = meta[key]; if (typeof value === 'boolean') { return value; } if (typeof value === 'number') { return value !== 0; } if (typeof value === 'string') { const lowered = value.toLowerCase(); if (lowered === 'true' || lowered === '1') { return true; } if (lowered === 'false' || lowered === '0') { return false; } } return fallback; }; const resolveStartYear = (world: TurnWorldState, scenarioMeta?: ScenarioMeta): number => { if (typeof scenarioMeta?.startYear === 'number') { return scenarioMeta.startYear; } const worldMeta = asRecord(world.meta); const scenarioMetaRecord = asRecord(worldMeta.scenarioMeta); return readMetaNumber(scenarioMetaRecord, 'startYear', world.currentYear); }; const buildUniqueLotteryRunner = (options: { world: TurnWorldState; worldView: WorldView | null; scenarioMeta?: ScenarioMeta; seedBase: string; itemRegistry: Map; uniqueConfig: ReturnType; getAdditionalOccupiedUniqueItemKeys?: () => Iterable; }): UniqueLotteryRunner => { if (!options.worldView) { return () => null; } const worldView = options.worldView; const world = options.world; const worldMeta = asRecord(world.meta); const startYear = resolveStartYear(world, options.scenarioMeta); const initYear = readMetaNumber(worldMeta, 'initYear', startYear); const initMonth = readMetaNumber(worldMeta, 'initMonth', 1); const scenarioId = readMetaNumber(worldMeta, 'scenarioId', 0); const minMonthToAllowInherit = options.uniqueConfig.minMonthToAllowInheritItem; return ({ acquireType, reason, general }) => { if (general.npcState >= 2) { return null; } const allGenerals = worldView.listGenerals(); const userCount = allGenerals.filter((entry) => entry.npcState < 2).length; if (userCount <= 0) { return null; } const generalItemsList = allGenerals.map((entry) => entry.id === general.id ? general.role.items : entry.role.items ); const occupiedUniqueCounts = countOccupiedUniqueItems(generalItemsList, options.itemRegistry); addOccupiedUniqueItemKeys( occupiedUniqueCounts, options.getAdditionalOccupiedUniqueItemKeys?.() ?? [], options.itemRegistry ); const rngSeed = buildGenericUniqueSeed( options.seedBase, world.currentYear, world.currentMonth, general.id, reason ); const rng = new RandUtil(LiteHashDRBG.build(rngSeed)); const inheritRandomUnique = readMetaBool(asRecord(general.meta), 'inheritRandomUnique', false); const relMonthByInit = joinYearMonth(world.currentYear, world.currentMonth) - joinYearMonth(initYear, initMonth); const availableBuyUnique = relMonthByInit >= minMonthToAllowInherit; const itemKey = rollUniqueLottery({ rng, config: options.uniqueConfig, itemRegistry: options.itemRegistry, generalItems: general.role.items, occupiedUniqueCounts, scenarioId, userCount, currentYear: world.currentYear, currentMonth: world.currentMonth, startYear, initYear, initMonth, acquireType, inheritRandomUnique, }); if (!itemKey) { return null; } if (inheritRandomUnique && availableBuyUnique) { delete asRecord(general.meta).inheritRandomUnique; } return options.itemRegistry.get(itemKey) ?? null; }; }; type WorldView = { getGeneralById(id: number): TurnGeneral | null; getCityById(id: number): City | null; getNationById(id: number): Nation | null; getTroopById(id: number): Troop | null; getDiplomacyEntry(srcNationId: number, destNationId: number): TurnDiplomacy | null; listGenerals(): TurnGeneral[]; listCities(): City[]; listNations(): Nation[]; listTroops(): Troop[]; listDiplomacy(): TurnDiplomacy[]; }; const mergeStats = (base: TurnGeneral['stats'], patch: Partial): TurnGeneral['stats'] => ({ leadership: patch.leadership ?? base.leadership, strength: patch.strength ?? base.strength, intelligence: patch.intelligence ?? base.intelligence, }); const mergeRole = (base: TurnGeneral['role'], patch: Partial): TurnGeneral['role'] => ({ ...base, ...patch, items: { ...base.items, ...(patch.items ?? {}), }, }); const mergeTriggerState = ( base: TurnGeneral['triggerState'], patch: Partial ): TurnGeneral['triggerState'] => ({ ...base, ...patch, flags: { ...base.flags, ...(patch.flags ?? {}) }, counters: { ...base.counters, ...(patch.counters ?? {}) }, modifiers: { ...base.modifiers, ...(patch.modifiers ?? {}) }, meta: { ...base.meta, ...(patch.meta ?? {}) }, }); const applyGeneralPatch = (base: TurnGeneral, patch: Partial): TurnGeneral => ({ ...base, ...patch, stats: patch.stats ? mergeStats(base.stats, patch.stats) : base.stats, role: patch.role ? mergeRole(base.role, patch.role) : base.role, triggerState: patch.triggerState ? mergeTriggerState(base.triggerState, patch.triggerState) : base.triggerState, meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, }); const applyCityPatch = (base: City, patch: Partial): City => ({ ...base, ...patch, meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, }); const applyNationPatch = (base: Nation, patch: Partial): Nation => ({ ...base, ...patch, meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, }); const mergeDiplomacyList = (base: TurnDiplomacy[], overrides: Map): TurnDiplomacy[] => { const merged = new Map(); for (const entry of base) { merged.set(buildDiplomacyKey(entry.fromNationId, entry.toNationId), entry); } for (const [key, entry] of overrides) { merged.set(key, entry); } return Array.from(merged.values()); }; // 예약 턴 내에서 패치를 즉시 반영하기 위한 로컬 오버레이. const createWorldOverlay = (world: InMemoryTurnWorld) => { const generalOverrides = new Map(); const cityOverrides = new Map(); const nationOverrides = new Map(); const diplomacyOverrides = new Map(); const mergeList = (base: T[], overrides: Map): T[] => { const merged = new Map(); for (const entry of base) { merged.set(entry.id, entry); } for (const [id, entry] of overrides) { merged.set(id, entry); } return Array.from(merged.values()); }; const view: WorldView = { getGeneralById: (id) => generalOverrides.get(id) ?? world.getGeneralById(id), getCityById: (id) => cityOverrides.get(id) ?? world.getCityById(id), getNationById: (id) => nationOverrides.get(id) ?? world.getNationById(id), getTroopById: (id) => world.getTroopById(id), getDiplomacyEntry: (srcNationId, destNationId) => diplomacyOverrides.get(buildDiplomacyKey(srcNationId, destNationId)) ?? world.getDiplomacyEntry(srcNationId, destNationId), listGenerals: () => mergeList(world.listGenerals(), generalOverrides).map((general) => ({ ...general, })), listCities: () => mergeList(world.listCities(), cityOverrides).map((city) => ({ ...city, })), listNations: () => mergeList(world.listNations(), nationOverrides).map((nation) => ({ ...nation, })), listTroops: () => world.listTroops().map((troop) => ({ ...troop })), listDiplomacy: () => mergeDiplomacyList(world.listDiplomacy(), diplomacyOverrides).map((entry) => ({ ...entry, meta: { ...entry.meta }, })), }; return { view, syncGeneral: (general: TurnGeneral) => { generalOverrides.set(general.id, general); }, syncCity: (city: City) => { cityOverrides.set(city.id, city); }, syncNation: (nation: Nation) => { nationOverrides.set(nation.id, nation); }, applyGeneralPatch: (id: number, patch: Partial) => { const base = generalOverrides.get(id) ?? world.getGeneralById(id); if (!base) { return; } generalOverrides.set(id, applyGeneralPatch(base, patch)); }, applyCityPatch: (id: number, patch: Partial) => { const base = cityOverrides.get(id) ?? world.getCityById(id); if (!base) { return; } cityOverrides.set(id, applyCityPatch(base, patch)); }, applyNationPatch: (id: number, patch: Partial) => { const base = nationOverrides.get(id) ?? world.getNationById(id); if (!base) { return; } nationOverrides.set(id, applyNationPatch(base, patch)); }, applyDiplomacyPatch: (srcNationId: number, destNationId: number, patch: DiplomacyPatch) => { const key = buildDiplomacyKey(srcNationId, destNationId); const base = diplomacyOverrides.get(key) ?? world.getDiplomacyEntry(srcNationId, destNationId) ?? buildDefaultDiplomacy(srcNationId, destNationId); diplomacyOverrides.set(key, applyDiplomacyPatchToEntry(base, patch)); }, }; }; class WorldStateView implements StateView { constructor( private readonly world: WorldView | null, private readonly env: Record, private readonly args: Record, private readonly overrides?: { general?: TurnGeneral; city?: City; nation?: Nation | null; } ) {} has(req: Parameters[0]): boolean { return this.get(req) !== null; } get(req: Parameters[0]): unknown | null { if (!this.world) { return null; } switch (req.kind) { case 'general': if (this.overrides?.general && this.overrides.general.id === req.id) { return this.overrides.general; } return this.world.getGeneralById(req.id); case 'generalList': return this.world.listGenerals(); case 'destGeneral': return this.world.getGeneralById(req.id); case 'city': if (this.overrides?.city && this.overrides.city.id === req.id) { return this.overrides.city; } return this.world.getCityById(req.id); case 'destCity': return this.world.getCityById(req.id); case 'nation': if (this.overrides?.nation && this.overrides.nation.id === req.id) { return this.overrides.nation; } return this.world.getNationById(req.id); case 'nationList': return this.world.listNations(); case 'destNation': return this.world.getNationById(req.id); case 'diplomacy': return this.world.getDiplomacyEntry(req.srcNationId, req.destNationId); case 'diplomacyList': return this.world.listDiplomacy(); case 'arg': return this.args[req.key] ?? null; case 'env': return this.env[req.key] ?? null; default: return null; } } } const extractArgsRecord = (value: unknown): Record => asRecord(value); const buildConstraintContext = ( general: TurnGeneral, city: City | undefined, nation: Nation | null | undefined, args: Record, env: Record ): ConstraintContext => ({ actorId: general.id, cityId: city?.id, nationId: nation?.id, args, env, mode: 'full', }); const createActionLog = (message: string, meta?: Record): LogEntryDraft => ({ scope: LogScope.GENERAL, category: LogCategory.ACTION, format: LogFormat.MONTH, text: message, meta, }); const resolveDefinition = ( actionKey: string, definitions: Map, fallback: GeneralActionDefinition ): GeneralActionDefinition => definitions.get(actionKey) ?? fallback; export const createReservedTurnHandler = async (options: { reservedTurns: InMemoryReservedTurnStore; scenarioConfig: ScenarioConfig; scenarioMeta?: ScenarioMeta; map?: MapDefinition; unitSet?: UnitSetDefinition; getWorld: () => InMemoryTurnWorld | null; commandProfile?: TurnCommandProfile; commandEnv?: TurnCommandEnv; commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil; getAdditionalOccupiedUniqueItemKeys?: () => Iterable; calculateNpcNationFinance?: ( world: InMemoryTurnWorld, nation: Nation, currentMonth: number ) => Nation['meta'] | null; onActionResolved?: (payload: { kind: 'nation' | 'general'; generalId: number; nationId: number | null; requestedAction: string; actionKey: string; usedFallback: boolean; completed?: boolean; blockedReason?: string; aiState?: ReturnType; }) => void; }): Promise => { const env = options.commandEnv ?? buildCommandEnv(options.scenarioConfig, options.unitSet); const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS])); const uniqueConfig = resolveUniqueConfig(asRecord(options.scenarioConfig.const)); if (Object.keys(uniqueConfig.allItems).length === 0) { uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry); } const commandProfile = options.commandProfile ?? DEFAULT_TURN_COMMAND_PROFILE; const { general: generalDefinitions, nation: nationDefinitions } = await buildReservedTurnDefinitions({ env, commandProfile, defaultActionKey: DEFAULT_ACTION, }); if (process.env.CORE_AI_TRACE_GENERAL_IDS) { process.stdout.write( `RESERVED_TURN_DEFINITION_TRACE ${JSON.stringify({ profileHasAppointment: commandProfile.general.includes('che_임관'), definitionHasAppointment: generalDefinitions.has('che_임관'), generalDefinitionCount: generalDefinitions.size, })}\n` ); } const generalFallback = generalDefinitions.get(DEFAULT_ACTION)!; const nationFallback = nationDefinitions.get(DEFAULT_ACTION)!; const actionContextBuilders = new Map(); const seenActionKeys = new Set(); const applyActionContextBuilder = (module: { commandSpec: { key: string }; actionContextBuilder?: ActionContextBuilder; }): void => { actionContextBuilders.set(module.commandSpec.key, module.actionContextBuilder ?? defaultActionContextBuilder); }; const generalModuleLoader = new GeneralTurnCommandLoader(); const nationModuleLoader = new NationTurnCommandLoader(); // NPC AI emits a few engine-internal commands that are intentionally not // exposed by the scenario's player command profile. Keep their definitions // available to AI resolution without adding them to the public profile. for (const key of AI_INTERNAL_GENERAL_ACTION_KEYS) { const module = await generalModuleLoader.load(key); if (!generalDefinitions.has(key)) { generalDefinitions.set(key, module.commandSpec.createDefinition(env)); } seenActionKeys.add(key); applyActionContextBuilder(module); } for (const key of commandProfile.general) { if (seenActionKeys.has(key)) { continue; } seenActionKeys.add(key); const module = await generalModuleLoader.load(key); applyActionContextBuilder(module); } for (const key of commandProfile.nation) { if (seenActionKeys.has(key)) { continue; } seenActionKeys.add(key); const module = await nationModuleLoader.load(key); applyActionContextBuilder(module); } if (!actionContextBuilders.has(DEFAULT_ACTION)) { applyActionContextBuilder(await generalModuleLoader.load(DEFAULT_ACTION)); } let nextGeneralId: number | null = null; const createGeneralId = (): number => { const world = options.getWorld(); if (world) { return world.getNextGeneralId(); } if (nextGeneralId === null) { nextGeneralId = 1; } const result = nextGeneralId; nextGeneralId += 1; return result; }; let nextNationId: number | null = null; const createNationId = (): number => { const world = options.getWorld(); if (world) { return world.getNextNationId(); } if (nextNationId === null) { nextNationId = 1; } const result = nextNationId; nextNationId += 1; return result; }; const reservedTurnProvider: AiReservedTurnProvider = { getGeneralTurn: (generalId, turnIdx) => options.reservedTurns.getGeneralTurn(generalId, turnIdx), }; return { execute(context): GeneralTurnResult { // Legacy reads the current game_env.develcost for every command. // Scenario const is only a fallback; the value changes with year. env.develCost = readMetaNumber(asRecord(context.world.meta), 'develcost', env.develCost); const worldRef = options.getWorld(); const worldOverlay = worldRef ? createWorldOverlay(worldRef) : null; const worldView = worldOverlay?.view ?? worldRef; const baseConstraintEnv = { ...resolveConstraintEnv(context.world, options.scenarioMeta, env), ...(options.map ? { map: options.map } : {}), ...(options.unitSet ? { unitSet: options.unitSet } : {}), }; const logs: LogEntryDraft[] = []; const messages: MessageDraft[] = []; const patches = { generals: [] as Array<{ id: number; patch: Partial }>, cities: [] as Array<{ id: number; patch: Partial }>, nations: [] as Array<{ id: number; patch: Partial }>, troops: [] as Array<{ id: number; patch: Partial }>, }; const diplomacyPatches: Array<{ srcNationId: number; destNationId: number; patch: DiplomacyPatch; }> = []; const createdGenerals: TurnGeneral[] = []; const createdNations: Nation[] = []; const commandDeletedTroopIds = new Set(); let currentGeneral = context.general; let currentCity = context.city; let currentNation = context.nation ?? null; const runAction = ( kind: 'nation' | 'general', definitionMap: Map, fallbackDefinition: GeneralActionDefinition, command: ReservedTurnEntry, applyNextTurnAt: boolean, alternativeDepth = 0, sharedActionRng?: RandUtil ): { nextTurnAt?: Date; actionKey: string; usedFallback: boolean; completed: boolean; blockedReason?: string; } => { const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition); const rawArgs = extractArgsRecord(command.args); const parsedArgs = resolvedDefinition.parseArgs(rawArgs); let definition = resolvedDefinition; let actionArgs = parsedArgs ?? {}; let actionKey = definition.key; let usedFallback = false; let blockedReason: string | undefined = undefined; if (parsedArgs === null) { const failureText = `인자가 올바르지 않습니다. ${resolvedDefinition.name} 실패.`; definition = fallbackDefinition; actionArgs = definition.parseArgs({}) ?? {}; actionKey = definition.key; usedFallback = true; blockedReason = failureText; logs.push(createActionLog(failureText)); } const actionConstraintEnv = { ...baseConstraintEnv, cities: worldView?.listCities() ?? [], nations: worldView?.listNations() ?? [], }; const constraintArgs = withCanonicalArgumentAliases(actionArgs as Record); const constraintCtx = buildConstraintContext( currentGeneral, currentCity, currentNation, constraintArgs, actionConstraintEnv ); const view = new WorldStateView(worldView, actionConstraintEnv, constraintArgs, { general: currentGeneral, city: currentCity, nation: currentNation, }); const constraints = definition.buildConstraints(constraintCtx, actionArgs); const result = evaluateConstraints(constraints, constraintCtx, view); if (result.kind !== 'allow') { const failedDefinition = definition; const failedActionArgs = actionArgs; definition = fallbackDefinition; actionArgs = definition.parseArgs({}) ?? {}; actionKey = definition.key; usedFallback = true; const reason = result.kind === 'deny' ? result.reason : '조건을 확인할 수 없습니다.'; blockedReason = reason; const meta = result.kind === 'deny' ? { constraintName: result.constraintName } : undefined; const failureText = failedDefinition.formatConstraintFailure?.(reason, constraintCtx, failedActionArgs, view) ?? `${reason} ${failedDefinition.name} 실패.`; logs.push(createActionLog(failureText, meta)); } if (!usedFallback && (kind === 'general' || currentNation)) { const currentYearMonth = joinYearMonth(context.world.currentYear, context.world.currentMonth); const nextAvailableTurn = kind === 'general' ? readGeneralNextAvailableTurn(currentGeneral, definition.name) : readNextAvailableTurn(currentNation!, definition.name); if (nextAvailableTurn !== null && currentYearMonth < nextAvailableTurn) { const remainTurn = nextAvailableTurn - currentYearMonth; definition = fallbackDefinition; actionArgs = definition.parseArgs({}) ?? {}; actionKey = definition.key; usedFallback = true; blockedReason = `${remainTurn}턴 더 기다려야 합니다`; logs.push(createActionLog(blockedReason)); } } const seedBase = buildSeedBase(context.world); const buildRng = (key: string) => { const rngSeed = serializeSeed( seedBase, kind === 'general' ? 'generalCommand' : 'nationCommand', context.world.currentYear, context.world.currentMonth, currentGeneral.id, key ); if (options.commandRngFactory) { return options.commandRngFactory({ kind, actionKey: key, seed: rngSeed, }); } return new RandUtil(new LiteHashDRBG(rngSeed)); }; const actionArgsRecord = extractArgsRecord(actionArgs); const uniqueLottery = buildUniqueLotteryRunner({ world: context.world, worldView, scenarioMeta: options.scenarioMeta, seedBase, itemRegistry, uniqueConfig, getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys, }); let actionRng = sharedActionRng ?? buildRng(actionKey); const actionTime = { year: context.world.currentYear, month: context.world.currentMonth, startYear: resolveStartYear(context.world, options.scenarioMeta), }; let baseContext: ActionContextBase = { general: currentGeneral, city: currentCity, nation: currentNation, ...(worldView ? { worldView: { listGenerals: () => worldView.listGenerals(), listGeneralsByCity: (cityId: number) => worldView.listGenerals().filter((general) => general.cityId === cityId), listNations: () => worldView.listNations(), }, } : {}), rng: actionRng, time: actionTime, uniqueLottery, }; let specificContext = buildActionContext( actionKey, baseContext, { world: context.world, scenarioConfig: options.scenarioConfig, scenarioMeta: options.scenarioMeta, map: options.map, unitSet: options.unitSet, worldRef: worldView, actionArgs: actionArgsRecord, createGeneralId, createNationId, seedBase, }, actionContextBuilders ); if (!specificContext && actionKey !== fallbackDefinition.key) { definition = fallbackDefinition; actionArgs = definition.parseArgs({}) ?? {}; actionKey = definition.key; usedFallback = true; blockedReason = '예약된 명령을 실행하지 못했습니다.'; logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.')); actionRng = sharedActionRng ?? buildRng(actionKey); baseContext = { general: currentGeneral, city: currentCity, nation: currentNation, rng: actionRng, time: actionTime, }; specificContext = baseContext; } const actionContext = specificContext ?? baseContext; if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))) { const tracedContext = actionContext as ActionContextBase & { destCity?: City; destGeneral?: TurnGeneral; }; process.stdout.write( `AI_ACTION_INPUT_TRACE ${JSON.stringify({ generalId: currentGeneral.id, kind, actionKey, actionArgs, destCityId: tracedContext.destCity?.id, destGeneralId: tracedContext.destGeneral?.id })}\n` ); } const executionDefinition = definition as unknown as { getPreReqTurn?: (context: ActionContextBase, args: unknown) => number; getPostReqTurn?: (context: ActionContextBase, args: unknown) => number; getStackSequence?: (context: ActionContextBase, args: unknown) => number | null; getProgressText?: ( context: ActionContextBase, args: unknown, term: number, termMax: number ) => string; getInheritanceActiveActionAmount?: (context: ActionContextBase, args: unknown) => number; }; const preReqTurn = !usedFallback ? Math.max(0, Math.floor(executionDefinition.getPreReqTurn?.(actionContext, actionArgs) ?? 0)) : 0; const postReqTurn = !usedFallback ? Math.max(0, Math.floor(executionDefinition.getPostReqTurn?.(actionContext, actionArgs) ?? 0)) : 0; if (!usedFallback && preReqTurn > 0 && (kind === 'general' || currentNation)) { const metaKey = nationLastTurnKey(currentGeneral.officerLevel); const lastTurn = kind === 'general' ? normalizeLastTurn(currentGeneral.lastTurn) : normalizeLastTurn(asRecord(currentNation!.meta)[metaKey]); const stackSequence = executionDefinition.getStackSequence?.(actionContext, actionArgs) ?? null; const sequenceChanged = stackSequence !== null && (lastTurn.seq === undefined || lastTurn.seq < stackSequence); const continuing = lastTurn.command === definition.name && sameArgs(lastTurn.arg, actionArgsRecord) && !sequenceChanged; const nextTerm = continuing ? (lastTurn.term ?? 0) + 1 : 1; if (!continuing || (lastTurn.term ?? 0) < preReqTurn) { const nextLastTurn: LegacyLastTurn = { command: definition.name, ...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined), term: nextTerm, ...(stackSequence !== null ? { seq: stackSequence } : undefined), }; if (kind === 'general') { currentGeneral = { ...currentGeneral, lastTurn: nextLastTurn, }; worldOverlay?.syncGeneral(currentGeneral); } else { const nextNation: Nation = { ...currentNation!, meta: { ...currentNation!.meta, [metaKey]: nextLastTurn, } as Nation['meta'], }; currentNation = nextNation; worldOverlay?.syncNation(nextNation); } const termMax = preReqTurn + 1; const progressText = executionDefinition.getProgressText?.(actionContext, actionArgs, nextTerm, termMax) ?? `${definition.name} 수행중... (${nextTerm}/${termMax})`; logs.push(createActionLog(progressText)); return { actionKey, usedFallback, completed: false, blockedReason }; } } const lastTurnBeforeExecution = JSON.stringify(currentGeneral.lastTurn ?? {}); const generalBeforeExecution = currentGeneral; const cityNationIdsBeforeExecution = new Map( (worldView?.listCities() ?? []).map((city) => [city.id, city.nationId] as const) ); const resolution = resolveGeneralAction( definition, actionContext, { now: currentGeneral.turnTime, schedule: context.schedule, }, actionArgs ); if ( (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id)) && resolution.patches ) { process.stdout.write( `AI_ACTION_PATCH_TRACE ${JSON.stringify({ generalId: currentGeneral.id, kind, actionKey, patches: resolution.patches })}\n` ); } for (const troopId of resolution.deletedTroopIds ?? []) { commandDeletedTroopIds.add(troopId); } for (const plan of resolution.reservedGeneralTurnPlans ?? []) { for (let turnIdx = 0; turnIdx < plan.joinTurn; turnIdx += 1) { options.reservedTurns.setGeneralTurn(plan.generalId, turnIdx, { action: 'che_견문', args: {}, }); } options.reservedTurns.setGeneralTurn(plan.generalId, plan.joinTurn, { action: 'che_임관', args: { destNationId: plan.destNationId }, }); } currentGeneral = resolution.general as TurnGeneral; currentCity = resolution.city ?? currentCity; currentNation = resolution.nation ?? currentNation; if (!resolution.alternative && !usedFallback && resolution.completed) { currentGeneral = applyLegacyGeneralProgression( currentGeneral, generalBeforeExecution, actionKey, env, logs ); } if ( !resolution.alternative && kind === 'nation' && !usedFallback && resolution.completed && definition.countsAsInheritanceActiveAction && Boolean(currentGeneral.userId) && currentGeneral.npcState < 2 ) { const meta = { ...currentGeneral.meta }; const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0; meta.inherit_active_action = active + 1; currentGeneral = { ...currentGeneral, meta }; } if ( !resolution.alternative && kind === 'general' && !usedFallback && resolution.completed && executionDefinition.getInheritanceActiveActionAmount && Boolean(currentGeneral.userId) && currentGeneral.npcState < 2 ) { const amount = executionDefinition.getInheritanceActiveActionAmount(actionContext, actionArgs); if (Number.isFinite(amount) && amount !== 0) { const meta = { ...currentGeneral.meta }; const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0; meta.inherit_active_action = active + amount; currentGeneral = { ...currentGeneral, meta }; } } if (!currentNation && resolution.created?.nations) { currentNation = (resolution.created.nations as Nation[]).find((n) => n.id === currentGeneral.nationId) ?? currentNation; } if (!resolution.alternative && kind === 'general' && !usedFallback && resolution.completed) { const actionChangedLastTurn = JSON.stringify(currentGeneral.lastTurn ?? {}) !== lastTurnBeforeExecution; const nextMeta = { ...currentGeneral.meta }; if (postReqTurn > 0) { nextMeta[`next_execute_${definition.name}`] = joinYearMonth(context.world.currentYear, context.world.currentMonth) + postReqTurn - preReqTurn; } currentGeneral = { ...currentGeneral, meta: nextMeta, lastTurn: actionChangedLastTurn ? currentGeneral.lastTurn : { command: definition.name, ...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined), }, }; } if ( !resolution.alternative && kind === 'nation' && !usedFallback && resolution.completed && currentNation ) { const metaKey = nationLastTurnKey(currentGeneral.officerLevel); const nextMeta: Record = { ...currentNation.meta, [metaKey]: { command: definition.name, ...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined), term: 0, } satisfies LegacyLastTurn, }; if (postReqTurn > 0) { nextMeta[`next_execute_${definition.name}`] = joinYearMonth(context.world.currentYear, context.world.currentMonth) + postReqTurn - preReqTurn; } currentNation = { ...currentNation, meta: nextMeta as Nation['meta'], }; } logs.push(...resolution.logs); if (worldOverlay) { worldOverlay.syncGeneral(currentGeneral); if (currentCity) { worldOverlay.syncCity(currentCity); } if (currentNation) { worldOverlay.syncNation(currentNation); } } if (resolution.effects.length > 0) { for (const effect of resolution.effects) { if (effect.type === 'message:add') { messages.push(effect.draft); } else if (effect.type === 'diplomacy:patch') { diplomacyPatches.push({ srcNationId: effect.srcNationId, destNationId: effect.destNationId, patch: effect.patch, }); worldOverlay?.applyDiplomacyPatch(effect.srcNationId, effect.destNationId, effect.patch); } } } if (resolution.patches) { patches.generals.push( ...(resolution.patches.generals as Array<{ id: number; patch: Partial; }>) ); patches.cities.push( ...(resolution.patches.cities as Array<{ id: number; patch: Partial; }>) ); patches.nations.push( ...(resolution.patches.nations as Array<{ id: number; patch: Partial; }>) ); if (worldOverlay) { for (const patch of resolution.patches.generals) { worldOverlay.applyGeneralPatch(patch.id, patch.patch as Partial); } for (const patch of resolution.patches.cities) { worldOverlay.applyCityPatch(patch.id, patch.patch); } for (const patch of resolution.patches.nations) { worldOverlay.applyNationPatch(patch.id, patch.patch); } } } if ( kind === 'nation' && actionKey === 'che_발령' && !usedFallback && resolution.completed && worldOverlay && hasScenarioStaticEventHandler( options.scenarioConfig, LEGACY_NATION_ASSIGNMENT_EVENT, IMMEDIATE_ASSIGNMENT_GATHER_HANDLER ) ) { const destGeneralId = actionArgsRecord.destGeneralId; const destCityId = actionArgsRecord.destCityId; if (typeof destGeneralId === 'number' && typeof destCityId === 'number') { const destGeneral = worldOverlay.view.getGeneralById(destGeneralId); const destCity = worldOverlay.view.getCityById(destCityId); if ( destGeneral && destCity && destGeneral.id === destGeneral.troopId && destGeneral.nationId === currentGeneral.nationId ) { const troopName = worldOverlay.view.getTroopById(destGeneral.id)?.name ?? ''; const cityJosa = JosaUtil.pick(destCity.name, '로'); const troopMembers = worldOverlay.view .listGenerals() .filter( (member) => member.id !== destGeneral.id && member.nationId === destGeneral.nationId && member.troopId === destGeneral.id && member.cityId !== destCity.id ); for (const member of troopMembers) { const patch = { cityId: destCity.id }; patches.generals.push({ id: member.id, patch }); worldOverlay.applyGeneralPatch(member.id, patch); if (member.id === currentGeneral.id) { currentGeneral = applyGeneralPatch(currentGeneral, patch); } logs.push({ scope: LogScope.GENERAL, category: LogCategory.ACTION, generalId: member.id, format: LogFormat.PLAIN, text: `${troopName} 부대원들은 ${destCity.name}${cityJosa} 즉시 집합되었습니다.`, }); } } } } if (resolution.created?.generals) { const newGenerals = resolution.created.generals as TurnGeneral[]; createdGenerals.push(...newGenerals); if (worldOverlay) { for (const general of newGenerals) { worldOverlay.syncGeneral(general); } } } if (resolution.created?.nations) { const newNations = resolution.created.nations as Nation[]; createdNations.push(...newNations); if (actionKey === 'che_거병') { for (const nation of newNations) { options.reservedTurns.ensureNationTurns(nation.id, 12); options.reservedTurns.ensureNationTurns(nation.id, 11); } } if (worldOverlay) { for (const nation of newNations) { worldOverlay.syncNation(nation); } } } const cityNationChanges = (resolution.patches?.cities ?? []).flatMap((patch) => { if (!Object.prototype.hasOwnProperty.call(patch.patch ?? {}, 'nationId')) { return []; } const nextNationId = patch.patch.nationId; const previousNationId = cityNationIdsBeforeExecution.get(patch.id); if ( typeof nextNationId !== 'number' || (previousNationId !== undefined && nextNationId === previousNationId) ) { return []; } return [{ cityId: patch.id, nextNationId }]; }); const hasNationChange = cityNationChanges.length > 0; const refreshesFrontForDiplomacyState = actionKey === 'che_이호경식' && resolution.effects.some( (effect) => effect.type === 'diplomacy:patch' && Object.prototype.hasOwnProperty.call(effect.patch, 'state') ); // 레거시 건국 계열과 무작위 수도 이전은 명령이 직접 지정한 // front 값만 바꾸고 전체 전선을 즉시 재계산하지 않는다. const preservesImmediateFrontState = [ 'che_건국', 'cr_건국', 'che_무작위건국', 'che_무작위수도이전', ].includes(actionKey); if ((hasNationChange || refreshesFrontForDiplomacyState) && !preservesImmediateFrontState) { const worldView = worldOverlay?.view ?? worldRef; if (worldView && options.map) { const frontNationIds = new Set(); const cityById = new Map(worldView.listCities().map((city) => [city.id, city] as const)); const mapCityById = new Map(options.map.cities.map((city) => [city.id, city] as const)); for (const change of cityNationChanges) { frontNationIds.add(change.nextNationId); const mapCity = mapCityById.get(change.cityId); for (const cityId of [change.cityId, ...(mapCity?.connections ?? [])]) { const nationId = cityById.get(cityId)?.nationId; if (nationId && nationId > 0) { frontNationIds.add(nationId); } } } if (refreshesFrontForDiplomacyState) { for (const effect of resolution.effects) { if (effect.type !== 'diplomacy:patch') { continue; } frontNationIds.add(effect.srcNationId); frontNationIds.add(effect.destNationId); } } const frontPatches = buildFrontStatePatches({ worldView, map: options.map, nationIds: [...frontNationIds], }); if (frontPatches.length > 0) { for (const patch of frontPatches) { const existing = patches.cities.find((entry) => entry.id === patch.id); if (existing) { existing.patch = { ...existing.patch, ...patch.patch }; } else { patches.cities.push({ id: patch.id, patch: patch.patch }); } worldOverlay?.applyCityPatch(patch.id, patch.patch); } } } } if (resolution.alternative) { if (alternativeDepth >= 5) { throw new Error('Command fallback loop limit exceeded'); } return runAction( kind, definitionMap, fallbackDefinition, { action: resolution.alternative.commandKey, args: extractArgsRecord(resolution.alternative.args), }, applyNextTurnAt, alternativeDepth + 1, actionRng ); } return { nextTurnAt: applyNextTurnAt ? resolution.nextTurnAt : undefined, actionKey, usedFallback, completed: resolution.completed, blockedReason, }; }; const lifecycleBefore = cloneTurnGeneral(currentGeneral); currentGeneral = cloneTurnGeneral(currentGeneral); if (currentGeneral.npcState < 2) { currentGeneral.meta.inherit_lived_month = readMetaNumber(currentGeneral.meta, 'inherit_lived_month', 0) + 1; } const preprocessRng = new RandUtil( new LiteHashDRBG( serializeSeed( buildSeedBase(context.world), 'preprocess', context.world.currentYear, context.world.currentMonth, currentGeneral.id ) ) ); currentCity = currentCity ? { ...currentCity, meta: { ...currentCity.meta } } : currentCity; let cityGeneralCopies: Map | undefined; const getCityGeneralCopies = (): Map => { if (cityGeneralCopies) { return cityGeneralCopies; } cityGeneralCopies = new Map(); for (const general of worldView?.listGenerals() ?? []) { if (general.cityId !== currentGeneral.cityId) { continue; } cityGeneralCopies.set( general.id, general.id === currentGeneral.id ? currentGeneral : cloneTurnGeneral(general) ); } cityGeneralCopies.set(currentGeneral.id, currentGeneral); return cityGeneralCopies; }; const preTurnPipeline = new GeneralActionPipeline(env.generalActionModules ?? []); const preTurnContext = createGeneralTriggerContext({ general: currentGeneral, nation: currentNation, worldView: { listGenerals: () => Array.from(getCityGeneralCopies().values()), listGeneralsByCity: (cityId) => Array.from(getCityGeneralCopies().values()).filter((general) => general.cityId === cityId), }, rng: preprocessRng, log: { push: (message) => logs.push(createActionLog(message)), }, }); preTurnPipeline.getPreTurnExecuteTriggerList(preTurnContext).fire(preTurnContext, baseConstraintEnv); if (currentGeneral.injury > 0 && !preTurnContext.skill.has('pre.부상경감')) { currentGeneral.injury = Math.max(0, currentGeneral.injury - 10); preTurnContext.skill.activate('pre.부상경감'); } if (currentGeneral.crew >= 100) { const consumeRice = Math.trunc(currentGeneral.crew / 100); if (consumeRice <= currentGeneral.rice) { currentGeneral.rice -= consumeRice; } else { const releasedCrew = Math.trunc( preTurnPipeline.onCalcDomestic(preTurnContext, '징집인구', 'score', currentGeneral.crew) ); if (currentCity) { currentCity = { ...currentCity, population: currentCity.population + releasedCrew, meta: { ...currentCity.meta }, }; worldOverlay?.syncCity(currentCity); } currentGeneral.crew = 0; currentGeneral.rice = 0; logs.push(createActionLog('군량이 모자라 병사들이 소집해제되었습니다!')); preTurnContext.skill.activate('pre.소집해제'); } preTurnContext.skill.activate('pre.병력군량소모'); } for (const [generalId, next] of cityGeneralCopies ?? []) { if (generalId === currentGeneral.id) { continue; } const previous = worldView?.getGeneralById(generalId); if (!previous || previous.injury === next.injury) { continue; } patches.generals.push({ id: generalId, patch: { injury: next.injury } }); worldOverlay?.applyGeneralPatch(generalId, { injury: next.injury }); } worldOverlay?.syncGeneral(currentGeneral); const blockCode = readMetaNumber(currentGeneral.meta, 'block', 0); const isBlocked = blockCode === 2 || blockCode === 3; if (isBlocked) { currentGeneral.meta.killturn = Math.max(0, currentGeneral.meta.killturn - 1); logs.push( createActionLog( blockCode === 2 ? '현재 멀티, 또는 비매너로 인한블럭 대상자입니다.' : '현재 악성유저로 분류되어 블럭 대상자입니다.' ) ); } let hasReservedTurn = false; let sharedAi: GeneralAI | undefined; if (!isBlocked && currentNation && currentGeneral.officerLevel >= 5) { let nationCommand = options.reservedTurns.getNationTurn( currentNation.id, currentGeneral.officerLevel, 0 ); if (nationCommand.action !== DEFAULT_ACTION) { hasReservedTurn = true; } let nationAiState: ReturnType | undefined; if (worldView && shouldUseAi(currentGeneral, context.world)) { sharedAi = new GeneralAI({ general: currentGeneral, city: currentCity, nation: currentNation, world: context.world, worldRef: worldView, reservedTurnProvider, scenarioConfig: options.scenarioConfig, scenarioMeta: options.scenarioMeta, map: options.map, unitSet: options.unitSet, commandEnv: env, generalDefinitions, nationDefinitions, generalFallback, nationFallback, }); const ai = sharedAi; const candidate = ai.chooseNationTurn(nationCommand); if (candidate) { if ( (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes( String(currentGeneral.id) ) ) { process.stdout.write( `AI_NATION_CANDIDATE_TRACE ${JSON.stringify({ generalId: currentGeneral.id, candidate })}\n` ); } nationCommand = { action: candidate.action, args: candidate.args }; } const promotion = ai.consumePromotionPatches(); for (const entry of promotion.generals) { const promotedGeneral = worldOverlay?.view.getGeneralById(entry.generalId); const patch = { officerLevel: entry.officerLevel, ...(promotedGeneral ? { meta: { ...promotedGeneral.meta, officer_city: entry.officerCity } } : {}), }; patches.generals.push({ id: entry.generalId, patch }); worldOverlay?.applyGeneralPatch(entry.generalId, patch); if (entry.generalId === currentGeneral.id) { currentGeneral = applyGeneralPatch(currentGeneral, patch); } } if (promotion.nationMeta && currentNation) { currentNation = { ...currentNation, meta: promotion.nationMeta as Nation['meta'] }; worldOverlay?.applyNationPatch(currentNation.id, { meta: currentNation.meta }); } if (currentNation && currentGeneral.officerLevel === 12 && options.calculateNpcNationFinance) { const baseWorld = options.getWorld(); const financeMeta = baseWorld ? options.calculateNpcNationFinance(baseWorld, currentNation, context.world.currentMonth) : null; if (financeMeta) { currentNation = { ...currentNation, meta: financeMeta as Nation['meta'] }; worldOverlay?.applyNationPatch(currentNation.id, { meta: currentNation.meta }); } } nationAiState = ai.getDebugState(); } const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false); // Ref persists the nation command here, but LazyVarUpdater only // clears its dirty flags: it does not replace the same PHP // General object's fractional values with the MariaDB INT row. // The following general command therefore observes and adds to // those fractions before the turn's final persistence boundary. worldOverlay?.syncGeneral(currentGeneral); if ( worldView && (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id)) ) { process.stdout.write( `AI_DIPLOMACY_TRACE ${JSON.stringify({ generalId: currentGeneral.id, stage: 'after-nation-action', entries: worldView .listDiplomacy() .filter((entry) => entry.fromNationId === currentGeneral.nationId && entry.state <= 1), })}\n` ); } options.onActionResolved?.({ kind: 'nation', generalId: currentGeneral.id, nationId: currentNation?.id ?? null, requestedAction: nationCommand.action, actionKey: nationResult.actionKey, usedFallback: nationResult.usedFallback, completed: nationResult.completed, ...(nationResult.blockedReason ? { blockedReason: nationResult.blockedReason } : {}), ...(nationAiState ? { aiState: nationAiState } : {}), }); options.reservedTurns.shiftNationTurns(currentNation.id, currentGeneral.officerLevel, -1); } if (isBlocked && currentNation && currentGeneral.officerLevel >= 5) { options.reservedTurns.shiftNationTurns(currentNation.id, currentGeneral.officerLevel, -1); } let generalCommand = options.reservedTurns.getGeneralTurn(currentGeneral.id, 0); if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))) { process.stdout.write( `RESERVED_TURN_HANDLER_TRACE ${JSON.stringify({ generalId: currentGeneral.id, action: generalCommand.action, actionLength: generalCommand.action.length, actionCodePoints: Array.from(generalCommand.action).map((value) => value.codePointAt(0)), hasDefinition: generalDefinitions.has(generalCommand.action), })}\n` ); } if (!isBlocked && generalCommand.action !== DEFAULT_ACTION) { hasReservedTurn = true; } let generalAiState: ReturnType | undefined; let generalAutorunMode = false; if (!isBlocked && worldView && shouldUseAi(currentGeneral, context.world)) { const ai = sharedAi ?? new GeneralAI({ general: currentGeneral, city: currentCity, nation: currentNation, world: context.world, worldRef: worldView, reservedTurnProvider, scenarioConfig: options.scenarioConfig, scenarioMeta: options.scenarioMeta, map: options.map, unitSet: options.unitSet, commandEnv: env, generalDefinitions, nationDefinitions, generalFallback, nationFallback, }); const candidate = ai.chooseGeneralTurn(generalCommand); // Ref GeneralAI::calcDiplomacyState writes // nation_env.last_attackable for ordinary generals too. The // nation-turn path consumes this patch above, but most NPCs // never execute a nation turn. const generalAiNationState = ai.consumePromotionPatches(); if (generalAiNationState.nationMeta && currentNation) { currentNation = { ...currentNation, meta: generalAiNationState.nationMeta as Nation['meta'], }; worldOverlay?.applyNationPatch(currentNation.id, { meta: currentNation.meta }); } const npcMessage = ai.consumeNpcMessage(); if (npcMessage) { const messageTarget = { generalId: currentGeneral.id, generalName: currentGeneral.name, nationId: currentGeneral.nationId, nationName: currentNation?.name ?? '재야', color: currentNation?.color ?? '#000000', icon: currentGeneral.picture ?? '', }; messages.push({ msgType: 'public', src: messageTarget, dest: messageTarget, text: npcMessage, time: new Date(context.world.lastTurnTime), validUntil: new Date('9999-12-31T00:00:00.000Z'), option: {}, }); } if (candidate) { generalAutorunMode = candidate.action !== generalCommand.action || JSON.stringify(candidate.args ?? {}) !== JSON.stringify(generalCommand.args ?? {}); generalCommand = { action: candidate.action, args: candidate.args }; } const aiMetaPatch = ai.consumePersistentGeneralMetaPatch(); if (Object.keys(aiMetaPatch.set).length > 0 || aiMetaPatch.unset.length > 0) { const nextMeta = { ...currentGeneral.meta } as Record; for (const key of aiMetaPatch.unset) { delete nextMeta[key]; } currentGeneral = { ...currentGeneral, meta: { ...nextMeta, ...aiMetaPatch.set } as TurnGeneral['meta'], }; worldOverlay?.syncGeneral(currentGeneral); } generalAiState = ai.getDebugState(); } const generalResult = isBlocked ? { actionKey: DEFAULT_ACTION, usedFallback: true, completed: false, blockedReason: '블럭 대상자입니다.', } : runAction('general', generalDefinitions, generalFallback, generalCommand, true); options.onActionResolved?.({ kind: 'general', generalId: currentGeneral.id, nationId: currentNation?.id ?? null, requestedAction: generalCommand.action, actionKey: generalResult.actionKey, usedFallback: generalResult.usedFallback, completed: generalResult.completed, ...(generalResult.blockedReason ? { blockedReason: generalResult.blockedReason } : {}), ...(generalAiState ? { aiState: generalAiState } : {}), }); let nextTurnAt = 'nextTurnAt' in generalResult ? generalResult.nextTurnAt : undefined; options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1); const worldMeta = asRecord(context.world.meta); if (!isBlocked) { const meta = { ...currentGeneral.meta }; const currentKillturn = readMetaNumber(meta, 'killturn', 0); const worldKillturn = readMetaNumber(worldMeta, 'killturn', currentKillturn); const requestedRest = generalCommand.action === DEFAULT_ACTION; if ( currentGeneral.npcState >= 2 || currentKillturn > worldKillturn || generalAutorunMode || requestedRest ) { meta.killturn = Math.max(0, currentKillturn - 1); } else { meta.killturn = worldKillturn; } currentGeneral = { ...currentGeneral, meta }; worldOverlay?.syncGeneral(currentGeneral); } const incDefSettingChange = readConfigNumber(options.scenarioConfig, 'incDefSettingChange', 3); const maxDefSettingChange = readConfigNumber(options.scenarioConfig, 'maxDefSettingChange', 9); currentGeneral = { ...currentGeneral, meta: { ...currentGeneral.meta, myset: Math.min( maxDefSettingChange, readMetaNumber(currentGeneral.meta, 'myset', 0) + incDefSettingChange ), }, }; const autorunUser = asRecord(worldMeta.autorun_user); const autorunLimitMinutes = readMetaNumber(autorunUser, 'limit_minutes', 0); if (hasReservedTurn && currentGeneral.npcState < 2 && autorunLimitMinutes > 0) { const turnMinutes = Math.max(1, Math.round(context.world.tickSeconds / 60)); currentGeneral.meta.autorun_limit = joinYearMonth(context.world.currentYear, context.world.currentMonth) + Math.trunc(autorunLimitMinutes / turnMinutes); } const nextTurnTimeBase = readMetaNumber(currentGeneral.meta, 'nextTurnTimeBase', -1); if (nextTurnTimeBase >= 0) { const alignedNextTurn = nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, context.schedule); nextTurnAt = new Date(alignedNextTurn.getTime() + nextTurnTimeBase * 1000); delete currentGeneral.meta.nextTurnTimeBase; } let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = 'active'; let deleteGeneral = false; const deletedTroopIds = Array.from(commandDeletedTroopIds); const lifecycleSnapshot = cloneTurnGeneral(currentGeneral); if (currentGeneral.meta.killturn <= 0 && typeof currentGeneral.deadYear === 'number') { if ( currentGeneral.npcState === 1 && typeof currentGeneral.deadYear === 'number' && currentGeneral.deadYear > context.world.currentYear ) { const npcOrg = readMetaNumber(currentGeneral.meta, 'npc_org', 2); const ownerName = typeof currentGeneral.meta.owner_name === 'string' ? currentGeneral.meta.owner_name : currentGeneral.userId; logs.push( createActionLog( `${ownerName ?? '사용자'}이 ${currentGeneral.name}의 육체에서 유체이탈합니다!` ) ); currentGeneral = { ...currentGeneral, userId: null, npcState: npcOrg, meta: { ...currentGeneral.meta, killturn: (currentGeneral.deadYear - context.world.currentYear) * 12, defence_train: 80, owner_name: '', }, }; lifecycleOutcome = 'detached'; } else { if (currentGeneral.officerLevel === 12 && currentNation && worldView) { const candidates = worldView .listGenerals() .filter( (candidate) => candidate.id !== currentGeneral.id && candidate.nationId === currentGeneral.nationId && candidate.officerLevel !== 12 && candidate.npcState !== 5 ); let successor: TurnGeneral | undefined; const fiction = readMetaNumber(worldMeta, 'fiction', 0); if ( fiction === 0 && currentGeneral.npcState > 0 && typeof currentGeneral.affinity === 'number' ) { const npcCandidates = candidates.filter( (candidate) => candidate.npcState >= 1 && candidate.npcState <= 3 && typeof candidate.affinity === 'number' ); const affinityDistance = (candidate: TurnGeneral): number => { const distance = Math.abs((candidate.affinity ?? 0) - (currentGeneral.affinity ?? 0)); return distance > 75 ? 150 - distance : distance; }; const minDistance = Math.min(...npcCandidates.map(affinityDistance)); const nearest = npcCandidates.filter( (candidate) => affinityDistance(candidate) === minDistance ); if (nearest.length > 0) { const rng = new RandUtil( new LiteHashDRBG( serializeSeed( buildSeedBase(context.world), 'NextNPCRuler', context.world.currentYear, context.world.currentMonth, currentGeneral.id ) ) ); successor = rng.choice(nearest); } } successor ??= candidates .filter((candidate) => candidate.officerLevel >= 9) .sort((left, right) => right.officerLevel - left.officerLevel || left.id - right.id)[0]; successor ??= candidates.sort( (left, right) => right.dedication - left.dedication || left.id - right.id )[0]; if (successor) { patches.generals.push({ id: successor.id, patch: { officerLevel: 12 }, }); currentNation = { ...currentNation, chiefGeneralId: successor.id, }; logs.push( createActionLog( `${successor.name}이 ${currentNation.name}의 유지를 이어 받았습니다` ) ); } } if (currentGeneral.troopId === currentGeneral.id) { deletedTroopIds.push(currentGeneral.id); for (const member of worldView?.listGenerals() ?? []) { if (member.id !== currentGeneral.id && member.troopId === currentGeneral.id) { patches.generals.push({ id: member.id, patch: { troopId: 0 } }); } } } if (currentNation) { const gennum = readMetaNumber(asRecord(currentNation.meta), 'gennum', 0); currentNation = { ...currentNation, meta: { ...currentNation.meta, gennum: Math.max(0, gennum - 1), }, }; } deleteGeneral = true; lifecycleOutcome = 'deleted'; } } const retirementYear = readConfigNumber(options.scenarioConfig, 'retirementYear', 80); if (!deleteGeneral && currentGeneral.age >= retirementYear && currentGeneral.npcState === 0) { currentGeneral = resetRetiredGeneral(currentGeneral); lifecycleOutcome = 'retired'; logs.push(createActionLog('나이가 들어 은퇴하고 자손에게 자리를 물려줍니다.')); } currentGeneral = { ...currentGeneral, triggerState: { ...currentGeneral.triggerState, flags: {}, }, }; if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))) { process.stdout.write( `AI_GENERAL_PRE_APPLY_TRACE ${JSON.stringify({ engine: 'core', generalId: currentGeneral.id, stats: currentGeneral.stats, experience: currentGeneral.experience, dedication: currentGeneral.dedication, meta: { leadership_exp: currentGeneral.meta.leadership_exp, strength_exp: currentGeneral.meta.strength_exp, intel_exp: currentGeneral.meta.intel_exp, dex1: currentGeneral.meta.dex1, dex2: currentGeneral.meta.dex2, dex3: currentGeneral.meta.dex3, dex4: currentGeneral.meta.dex4, dex5: currentGeneral.meta.dex5 } })}\n` ); } const result: GeneralTurnResult = { general: currentGeneral, city: currentCity, nation: currentNation, nextTurnAt, logs, ...(messages.length > 0 ? { messages } : undefined), patches, ...(diplomacyPatches.length > 0 ? { diplomacyPatches } : undefined), created: createdGenerals.length > 0 || createdNations.length > 0 ? { generals: createdGenerals, ...(createdNations.length > 0 ? { nations: createdNations } : {}), } : undefined, ...(deleteGeneral || deletedTroopIds.length > 0 ? { deleted: { general: deleteGeneral, ...(deletedTroopIds.length > 0 ? { troopIds: deletedTroopIds } : {}), }, } : undefined), lifecycleEvent: { generalId: currentGeneral.id, outcome: lifecycleOutcome, before: lifecycleOutcome === 'active' ? lifecycleBefore : lifecycleSnapshot, ...(deleteGeneral ? {} : { after: currentGeneral }), year: context.world.currentYear, month: context.world.currentMonth, }, }; return result; }, }; }; export type ImmediateGeneralActionKey = 'che_거병' | 'che_접경귀환'; export type ImmediateGeneralActionExecutor = { execute(input: { actionKey: ImmediateGeneralActionKey; generalId: number; rng: RandUtil; refreshKillturn?: boolean; }): Promise<{ ok: boolean; reason?: string }>; }; /** * Ref의 MyPage 즉시 행동은 예약 턴과 같은 command/action-module stack을 * 실행하지만 장수의 turnTime과 예약 큐는 진행시키지 않는다. */ export const createImmediateGeneralActionExecutor = async (options: { world: InMemoryTurnWorld; reservedTurns?: InMemoryReservedTurnStore; scenarioMeta?: ScenarioMeta; map?: MapDefinition; commandProfile?: TurnCommandProfile; getAdditionalOccupiedUniqueItemKeys?: () => Iterable | Promise>; }): Promise => { const env = buildCommandEnv(options.world.getScenarioConfig(), options.world.getUnitSet()); const commandProfile = options.commandProfile ?? DEFAULT_TURN_COMMAND_PROFILE; const { general: definitions } = await buildReservedTurnDefinitions({ env, commandProfile, defaultActionKey: DEFAULT_ACTION, }); const generalModuleLoader = new GeneralTurnCommandLoader(); const contextBuilders = new Map(); for (const actionKey of ['che_거병', 'che_접경귀환'] as const) { if (!definitions.has(actionKey)) { continue; } const module = await generalModuleLoader.load(actionKey); contextBuilders.set(actionKey, module.actionContextBuilder ?? defaultActionContextBuilder); } const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS])); const uniqueConfig = resolveUniqueConfig(asRecord(options.world.getScenarioConfig().const)); if (Object.keys(uniqueConfig.allItems).length === 0) { uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry); } return { async execute(input) { const definition = definitions.get(input.actionKey); if (!definition) { return { ok: false, reason: input.actionKey === 'che_거병' ? '거병할 수 없는 모드입니다.' : '접경귀환을 사용할 수 없는 모드입니다.', }; } const general = options.world.getGeneralById(input.generalId); if (!general) { return { ok: false, reason: '장수 정보를 찾을 수 없습니다.' }; } const city = options.world.getCityById(general.cityId) ?? undefined; const nation = general.nationId > 0 ? options.world.getNationById(general.nationId) : null; const args = definition.parseArgs({}); if (args === null) { return { ok: false, reason: '인자가 올바르지 않습니다.' }; } const state = options.world.getState(); const constraintEnv = { ...resolveConstraintEnv(state, options.scenarioMeta, env), ...(options.map ? { map: options.map } : {}), ...(options.world.getUnitSet() ? { unitSet: options.world.getUnitSet() } : {}), cities: options.world.listCities(), nations: options.world.listNations(), }; const constraintArgs = withCanonicalArgumentAliases(extractArgsRecord(args)); const constraintCtx = buildConstraintContext(general, city, nation, constraintArgs, constraintEnv); const view = new WorldStateView(options.world, constraintEnv, constraintArgs, { general, city, nation, }); const constraintResult = evaluateConstraints( definition.buildConstraints(constraintCtx, args), constraintCtx, view ); if (constraintResult.kind !== 'allow') { const reason = constraintResult.kind === 'deny' ? constraintResult.reason : '조건을 확인할 수 없습니다.'; const failureText = definition.formatConstraintFailure?.(reason, constraintCtx, args, view) ?? `${reason} ${definition.name} 실패.`; if (input.actionKey === 'che_접경귀환') { options.world.pushLog({ ...createActionLog(failureText), generalId: general.id, }); } return { ok: false, reason: failureText }; } if (input.actionKey === 'che_거병' && !options.reservedTurns) { throw new Error('Immediate uprising requires the reserved-turn store.'); } const additionalOccupiedUniqueItemKeys = (await options.getAdditionalOccupiedUniqueItemKeys?.()) ?? []; const seedBase = buildSeedBase(state); const uniqueLottery = buildUniqueLotteryRunner({ world: state, worldView: options.world, scenarioMeta: options.scenarioMeta, seedBase, itemRegistry, uniqueConfig, getAdditionalOccupiedUniqueItemKeys: () => additionalOccupiedUniqueItemKeys, }); const startYear = resolveStartYear(state, options.scenarioMeta); const baseContext: ActionContextBase = { general, city, nation, worldView: { listGenerals: () => options.world.listGenerals(), listGeneralsByCity: (cityId) => options.world.listGenerals().filter((candidate) => candidate.cityId === cityId), listNations: () => options.world.listNations(), }, rng: input.rng, time: { year: state.currentYear, month: state.currentMonth, startYear, }, uniqueLottery, }; const actionContext = buildActionContext( input.actionKey, baseContext, { world: state, scenarioConfig: options.world.getScenarioConfig(), scenarioMeta: options.scenarioMeta, map: options.map, unitSet: options.world.getUnitSet(), worldRef: options.world, actionArgs: constraintArgs, createGeneralId: () => options.world.getNextGeneralId(), createNationId: () => options.world.getNextNationId(), seedBase, }, contextBuilders ) ?? baseContext; const resolution = resolveGeneralAction( definition, actionContext, { now: general.turnTime, schedule: { entries: [ { startMinute: 0, tickMinutes: Math.max(1, Math.floor(state.tickSeconds / 60)), }, ], }, }, args ); if (input.actionKey === 'che_접경귀환' && (resolution.general as TurnGeneral).cityId === general.cityId) { for (const log of resolution.logs) { options.world.pushLog(log); } return { ok: false, reason: '가까운 아국 도시가 없습니다.' }; } const progressionLogs: LogEntryDraft[] = []; let nextGeneral = resolution.general as TurnGeneral; if (input.actionKey === 'che_거병') { const activeActionAmount = ( definition as GeneralActionDefinition & { getInheritanceActiveActionAmount?: (context: ActionContextBase, args: unknown) => number; } ).getInheritanceActiveActionAmount?.(actionContext, args) ?? 0; const nextMeta = { ...nextGeneral.meta, inherit_active_action: readMetaNumber(asRecord(nextGeneral.meta), 'inherit_active_action', 0) + activeActionAmount, ...(input.refreshKillturn ? { killturn: readMetaNumber(asRecord(state.meta), 'killturn', 0) } : {}), }; nextGeneral = applyLegacyGeneralProgression( { ...nextGeneral, meta: nextMeta, lastTurn: { command: definition.name, arg: extractArgsRecord(args), }, }, general, input.actionKey, env, progressionLogs ); } for (const createdNation of resolution.created?.nations ?? []) { if (!options.world.addNation(createdNation)) { throw new Error(`Immediate action could not create nation ${createdNation.id}.`); } options.reservedTurns?.ensureNationTurns(createdNation.id, 12); options.reservedTurns?.ensureNationTurns(createdNation.id, 11); } if (resolution.dirty?.city && resolution.city) { options.world.updateCity(resolution.city.id, resolution.city); } if (resolution.dirty?.nation && resolution.nation) { options.world.updateNation(resolution.nation.id, resolution.nation); } for (const patch of resolution.patches?.generals ?? []) { options.world.updateGeneral(patch.id, patch.patch as Partial); } for (const patch of resolution.patches?.cities ?? []) { options.world.updateCity(patch.id, patch.patch); } for (const patch of resolution.patches?.nations ?? []) { options.world.updateNation(patch.id, patch.patch); } for (const effect of resolution.effects) { if (effect.type === 'diplomacy:patch') { options.world.applyDiplomacyPatch({ srcNationId: effect.srcNationId, destNationId: effect.destNationId, patch: effect.patch, }); } else if (effect.type === 'message:add') { options.world.queueMessage(effect.draft); } } for (const log of [...resolution.logs, ...progressionLogs]) { options.world.pushLog(log); } options.world.updateGeneral(input.generalId, nextGeneral); return { ok: true }; }, }; };