import type { RandomGenerator } from '@sammo-ts/common'; import { enablePatches, produceWithPatches, castDraft } from 'immer'; import type { City, General, GeneralTriggerState, CityId, GeneralId, Nation, NationId, } from '@sammo-ts/logic/domain/entities.js'; import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js'; import { getNextTurnAt, type TurnSchedule } from '@sammo-ts/logic/turn/calendar.js'; import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; enablePatches(); export interface WorldState { general: General; city?: City; nation?: Nation | null; } export interface GeneralActionResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, > extends GeneralActionContext { rng: RandomGenerator; city?: City; nation?: Nation | null; addLog(message: string, options?: Partial>): void; } export type GeneralActionResolveInputContext = Omit< GeneralActionResolveContext, 'addLog' >; export interface TurnScheduleContext { now: Date; schedule: TurnSchedule; } export interface GeneralPatchEffect { type: 'general:patch'; patch: Partial>; targetId?: GeneralId; } export interface GeneralAddEffect { type: 'general:add'; general: General; } export interface CityPatchEffect { type: 'city:patch'; patch: Partial; targetId?: CityId; } export interface NationPatchEffect { type: 'nation:patch'; patch: Partial; targetId?: NationId; } export interface DiplomacyPatchEffect { type: 'diplomacy:patch'; srcNationId: NationId; destNationId: NationId; patch: { state?: number; term?: number; dead?: number; deadDelta?: number; meta?: Record; }; } export interface LogEffect { type: 'log'; entry: LogEntryDraft; } export interface NextTurnOverrideEffect { type: 'schedule:override'; nextTurnAt: Date; } export type GeneralActionEffect = | GeneralPatchEffect | GeneralAddEffect | CityPatchEffect | NationPatchEffect | NationAddEffect | DiplomacyPatchEffect | LogEffect | NextTurnOverrideEffect; export interface GeneralActionOutcome { effects: GeneralActionEffect[]; alternative?: { commandKey: string; args: unknown; }; } export interface GeneralActionResolver { key: string; resolve(context: GeneralActionResolveContext, args: Args): GeneralActionOutcome; } export interface GeneralActionResolution { general: General; city?: City; nation?: Nation | null; nextTurnAt: Date; logs: LogEntryDraft[]; effects: GeneralActionEffect[]; created?: { generals: General[]; nations?: Nation[]; }; patches?: { generals: Array<{ id: GeneralId; patch: Partial }>; cities: Array<{ id: CityId; patch: Partial }>; nations: Array<{ id: NationId; patch: Partial }>; }; dirty?: { general: boolean; city: boolean; nation: boolean; generalId?: GeneralId; cityId?: CityId; nationId?: NationId; }; alternative?: { commandKey: string; args: unknown; }; } export const createGeneralPatchEffect = ( patch: Partial>, targetId?: GeneralId ): GeneralPatchEffect => ({ type: 'general:patch', patch, ...(targetId !== undefined ? { targetId } : {}), }); export const createGeneralAddEffect = ( general: General ): GeneralAddEffect => ({ type: 'general:add', general, }); export const createCityPatchEffect = (patch: Partial, targetId?: CityId): CityPatchEffect => ({ type: 'city:patch', patch, ...(targetId !== undefined ? { targetId } : {}), }); export const createNationPatchEffect = (patch: Partial, targetId?: NationId): NationPatchEffect => ({ type: 'nation:patch', patch, ...(targetId !== undefined ? { targetId } : {}), }); export interface NationAddEffect { type: 'nation:add'; nation: Nation; } export const createNationAddEffect = (nation: Nation): NationAddEffect => ({ type: 'nation:add', nation, }); export const createDiplomacyPatchEffect = ( srcNationId: NationId, destNationId: NationId, patch: DiplomacyPatchEffect['patch'] ): DiplomacyPatchEffect => ({ type: 'diplomacy:patch', srcNationId, destNationId, patch, }); export const createLogEffect = (message: string, options: Partial> = {}): LogEffect => ({ type: 'log', entry: { scope: options.scope ?? LogScope.GENERAL, category: options.category ?? LogCategory.ACTION, text: message, ...(options.generalId !== undefined ? { generalId: options.generalId } : {}), ...(options.nationId !== undefined ? { nationId: options.nationId } : {}), ...(options.userId !== undefined ? { userId: options.userId } : {}), ...(options.subType !== undefined ? { subType: options.subType } : {}), ...(options.meta !== undefined ? { meta: options.meta } : {}), format: options.format ?? LogFormat.MONTH, }, }); export const createNextTurnOverrideEffect = (nextTurnAt: Date): NextTurnOverrideEffect => ({ type: 'schedule:override', nextTurnAt, }); // 행동 결과를 Effect로 모아 상태/턴 계산을 수행한다. export const resolveGeneralAction = ( resolver: GeneralActionResolver, context: GeneralActionResolveInputContext, scheduleContext: TurnScheduleContext, args: Args ): GeneralActionResolution => { const logs: LogEntryDraft[] = []; let nextTurnAtOverride: Date | null = null; const createdGenerals: General[] = []; const createdNations: Nation[] = []; const patches: NonNullable = { generals: [], cities: [], nations: [], }; const pendingEffects: GeneralActionEffect[] = []; let outcome: GeneralActionOutcome | undefined; const [nextWorld, worldPatches] = produceWithPatches( { general: context.general, city: context.city, nation: context.nation, } as WorldState, (draft) => { const addLog = (message: string, options: Partial> = {}) => { const entry: LogEntryDraft = { scope: options.scope ?? LogScope.GENERAL, category: options.category ?? LogCategory.ACTION, text: message, format: options.format ?? LogFormat.MONTH, ...options, }; switch (entry.scope) { case LogScope.GENERAL: logs.push({ ...entry, generalId: entry.generalId ?? context.general.id, }); break; case LogScope.NATION: if (entry.nationId !== undefined) { logs.push(entry); break; } if (context.nation?.id !== undefined) { logs.push({ ...entry, nationId: context.nation.id, }); } break; case LogScope.USER: if (entry.userId) { logs.push(entry); } break; case LogScope.SYSTEM: default: logs.push(entry); break; } }; outcome = resolver.resolve( { ...context, // ... general: castDraft(draft.general), city: castDraft(draft.city), nation: castDraft(draft.nation), addLog, } as GeneralActionResolveContext, args ); for (const effect of outcome.effects) { switch (effect.type) { case 'log': addLog(effect.entry.text, effect.entry); break; case 'schedule:override': nextTurnAtOverride = effect.nextTurnAt; break; case 'general:add': createdGenerals.push(effect.general as General); break; case 'nation:add': createdNations.push(effect.nation as Nation); break; case 'diplomacy:patch': pendingEffects.push(effect); break; case 'general:patch': case 'city:patch': case 'nation:patch': // 타겟이 다른 경우 patches에 추가 if ( effect.type === 'general:patch' && effect.targetId !== undefined && effect.targetId !== context.general.id ) { patches.generals.push({ id: effect.targetId, patch: effect.patch as Partial, }); } else if (effect.type === 'general:patch') { Object.assign(draft.general, effect.patch); } else if ( effect.type === 'city:patch' && effect.targetId !== undefined && effect.targetId !== context.city?.id ) { patches.cities.push({ id: effect.targetId, patch: effect.patch, }); } else if (effect.type === 'city:patch' && draft.city) { Object.assign(draft.city, effect.patch); } else if ( effect.type === 'nation:patch' && effect.targetId !== undefined && effect.targetId !== context.nation?.id ) { patches.nations.push({ id: effect.targetId, patch: effect.patch, }); } else if (effect.type === 'nation:patch' && draft.nation) { Object.assign(draft.nation, effect.patch); } break; } } } ); const nextTurnAt = nextTurnAtOverride ?? getNextTurnAt(scheduleContext.now, scheduleContext.schedule); const dirty: NonNullable = { general: false, city: false, nation: false, generalId: context.general.id, }; if (context.city) dirty.cityId = context.city.id; if (context.nation) dirty.nationId = context.nation.id; // worldPatches를 분석하여 dirty 설정 for (const patch of worldPatches) { if (patch.path[0] === 'general') dirty.general = true; if (patch.path[0] === 'city') dirty.city = true; if (patch.path[0] === 'nation') dirty.nation = true; } const resolution: GeneralActionResolution = { general: nextWorld.general as General, nation: nextWorld.nation as Nation | null, nextTurnAt, logs, effects: pendingEffects, ...(outcome?.alternative ? { alternative: outcome.alternative } : {}), }; if (nextWorld.city) { resolution.city = nextWorld.city as City; } if (dirty.general || dirty.city || dirty.nation) { resolution.dirty = dirty; } if (patches.generals.length > 0 || patches.cities.length > 0 || patches.nations.length > 0) { resolution.patches = patches; } if (createdGenerals.length > 0 || createdNations.length > 0) { resolution.created = { generals: createdGenerals, ...(createdNations.length > 0 ? { nations: createdNations } : {}), }; } return resolution; };