diff --git a/app/game-engine/src/turn/ai/aiUtils.ts b/app/game-engine/src/turn/ai/aiUtils.ts index 7311bb13..70782f16 100644 --- a/app/game-engine/src/turn/ai/aiUtils.ts +++ b/app/game-engine/src/turn/ai/aiUtils.ts @@ -34,6 +34,21 @@ export const readString = (value: unknown, fallback = ''): string => (typeof val export const readMetaNumber = (meta: Record, key: string, fallback = 0): number => readNumber(meta[key], fallback); +export const readRequiredMetaNumber = (meta: Record, key: string, context?: string): number => { + const value = meta[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + const suffix = context ? ` (${context})` : ''; + throw new Error(`meta.${key} is required${suffix}.`); +}; + export const readMetaString = (meta: Record, key: string, fallback = ''): string => readString(meta[key], fallback); diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index eff09187..ed6edcb1 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -18,7 +18,15 @@ import type { ReservedTurnEntry } from '../../reservedTurnStore.js'; import type { TurnGeneral, TurnWorldState } from '../../types.js'; import type { AiCommandCandidate, AiReservedTurnProvider, AiWorldView } from '../types.js'; import { AutorunGeneralPolicy, AutorunNationPolicy, AVAILABLE_INSTANT_TURN } from '../policies.js'; -import { asRecord, joinYearMonth, readMetaNumber, readNumber, roundTo, valueFit } from '../aiUtils.js'; +import { + asRecord, + joinYearMonth, + readMetaNumber, + readNumber, + readRequiredMetaNumber, + roundTo, + valueFit, +} from '../aiUtils.js'; import { searchAllDistanceByNationList } from '../distance.js'; import { generalActionHandlers } from '../generalAiGeneralActions.js'; import { nationActionHandlers } from '../generalAiNationActions.js'; @@ -554,7 +562,7 @@ export class GeneralAI { continue; } - const killturn = readMetaNumber(asRecord(candidate.meta), 'killturn', 999); + const killturn = readRequiredMetaNumber(asRecord(candidate.meta), 'killturn', `generalId=${candidate.id}`); if (killturn <= 5) { npcCivilGenerals[candidate.id] = candidate; continue; diff --git a/app/game-engine/src/turn/ai/generalAi/general/npcActions.ts b/app/game-engine/src/turn/ai/generalAi/general/npcActions.ts index 65f12308..eff312ea 100644 --- a/app/game-engine/src/turn/ai/generalAi/general/npcActions.ts +++ b/app/game-engine/src/turn/ai/generalAi/general/npcActions.ts @@ -1,5 +1,5 @@ import type { GeneralAI } from '../core.js'; -import { asRecord, readMetaNumber } from '../../aiUtils.js'; +import { asRecord, readRequiredMetaNumber } from '../../aiUtils.js'; import { t통솔장 } from './helpers.js'; export const doNPC헌납 = (ai: GeneralAI) => { @@ -64,7 +64,7 @@ export const doNPC헌납 = (ai: GeneralAI) => { }; export const doNPC사망대비 = (ai: GeneralAI) => { - const killturn = readMetaNumber(asRecord(ai.general.meta), 'killturn', 999); + const killturn = readRequiredMetaNumber(asRecord(ai.general.meta), 'killturn', `generalId=${ai.general.id}`); if (killturn > 5) { return null; } diff --git a/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts b/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts index b7146644..479a912b 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts @@ -1,5 +1,5 @@ import type { GeneralAI } from '../core.js'; -import { asRecord, readMetaNumber } from '../../aiUtils.js'; +import { asRecord, readRequiredMetaNumber } from '../../aiUtils.js'; import { buildAwardCandidate, buildSeizureCandidate, pickWeightedCandidate, resolveAwardAmount } from './helpers.js'; export const do유저장긴급포상 = (ai: GeneralAI) => { @@ -81,7 +81,7 @@ export const doNPC긴급포상 = (ai: GeneralAI) => { continue; } for (const general of Object.values(ai.npcWarGenerals)) { - const killturn = readMetaNumber(asRecord(general.meta), 'killturn', 999); + const killturn = readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`); if (killturn <= 5) { continue; } @@ -115,7 +115,7 @@ export const doNPC포상 = (ai: GeneralAI) => { continue; } for (const general of Object.values(ai.npcWarGenerals)) { - const killturn = readMetaNumber(asRecord(general.meta), 'killturn', 999); + const killturn = readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`); if (killturn <= 5) { continue; } @@ -126,7 +126,7 @@ export const doNPC포상 = (ai: GeneralAI) => { candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'), amount]); } for (const general of Object.values(ai.npcCivilGenerals)) { - const killturn = readMetaNumber(asRecord(general.meta), 'killturn', 999); + const killturn = readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`); if (killturn <= 5) { continue; } diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 405a49a8..6c914ae2 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -162,6 +162,46 @@ const normalizeGeneralTurnTime = (general: TurnGeneral, fallback: Date): TurnGen }; }; +const readMetaNumber = (meta: Record, key: string): number | null => { + const value = meta[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return null; +}; + +const resolveWorldKillturn = (meta: Record): number | null => { + const killturn = readMetaNumber(meta, 'killturn'); + if (killturn !== null) { + return Math.floor(killturn); + } + return null; +}; + +const ensureGeneralKillturn = (general: TurnGeneral, worldKillturn: number | null): TurnGeneral => { + const meta = { ...general.meta } as Record; + const existing = readMetaNumber(meta, 'killturn'); + if (existing !== null) { + return general; + } + if (worldKillturn === null) { + throw new Error(`meta.killturn is required (generalId=${general.id}).`); + } + return { + ...general, + meta: { + ...meta, + killturn: worldKillturn, + }, + }; +}; + export class InMemoryTurnWorld { // DB에서 읽어온 월드 상태를 메모리에 고정해 턴 처리를 담당한다. private readonly schedule: TurnSchedule; @@ -200,8 +240,11 @@ export class InMemoryTurnWorld { } satisfies GeneralTurnHandler); this.calendarHandler = options.calendarHandler; + const worldKillturn = resolveWorldKillturn(this.state.meta); for (const general of snapshot.generals) { - this.generals.set(general.id, normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime)); + const normalized = normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime); + const ensured = ensureGeneralKillturn(normalized, worldKillturn); + this.generals.set(general.id, ensured); } for (const city of snapshot.cities) { this.cities.set(city.id, { ...city }); @@ -571,10 +614,10 @@ export class InMemoryTurnWorld { if (this.generals.has(createdGeneral.id)) { continue; } - this.generals.set( - createdGeneral.id, - normalizeGeneralTurnTime({ ...createdGeneral }, this.state.lastTurnTime) - ); + const worldKillturn = resolveWorldKillturn(this.state.meta); + const normalized = normalizeGeneralTurnTime({ ...createdGeneral }, this.state.lastTurnTime); + const ensured = ensureGeneralKillturn(normalized, worldKillturn); + this.generals.set(createdGeneral.id, ensured); this.dirtyGeneralIds.add(createdGeneral.id); this.createdGeneralIds.add(createdGeneral.id); } diff --git a/app/game-engine/test/helpers/turnTestHarness.ts b/app/game-engine/test/helpers/turnTestHarness.ts index a2ddf2f0..65bba485 100644 --- a/app/game-engine/test/helpers/turnTestHarness.ts +++ b/app/game-engine/test/helpers/turnTestHarness.ts @@ -82,6 +82,14 @@ const defaultRunOptions = { } satisfies Required>; export const createTurnTestHarness = async (options: TurnTestHarnessOptions) => { + const stateMeta = { ...options.state.meta } as Record; + if (typeof stateMeta.killturn !== 'number' || !Number.isFinite(stateMeta.killturn)) { + stateMeta.killturn = 24; + } + const state: TurnWorldState = { + ...options.state, + meta: stateMeta, + }; const mockPrisma = createMockPrisma(); const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, { maxGeneralTurns: 10, @@ -126,7 +134,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) => ...(options.extraCalendarHandlers ?? []) ); - const world = new InMemoryTurnWorldClass(options.state, options.snapshot, { + const world = new InMemoryTurnWorldClass(state, options.snapshot, { schedule: options.schedule, generalTurnHandler, calendarHandler,