From e91fe067623c9f7a267e4278c8678cf9fa198eb6 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Thu, 8 Jan 2026 14:39:45 +0000 Subject: [PATCH] =?UTF-8?q?=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EC=97=94?= =?UTF-8?q?=EC=A7=84=20=EC=8B=A4=ED=96=89=EC=97=90=EC=84=9C=20alternative?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/logic/src/actions/engine.ts | 13 +- .../logic/src/actions/turn/executionHelper.ts | 53 ++++++++ .../src/actions/turn/general/che_출병.ts | 8 +- .../test/actions/turn/executionHelper.test.ts | 124 ++++++++++++++++++ 4 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 packages/logic/src/actions/turn/executionHelper.ts create mode 100644 packages/logic/test/actions/turn/executionHelper.test.ts diff --git a/packages/logic/src/actions/engine.ts b/packages/logic/src/actions/engine.ts index 675d983..fbd97a8 100644 --- a/packages/logic/src/actions/engine.ts +++ b/packages/logic/src/actions/engine.ts @@ -97,6 +97,10 @@ export type GeneralActionEffect { effects: GeneralActionEffect[]; + alternative?: { + commandKey: string; + args: unknown; + }; } export interface GeneralActionResolver { @@ -127,6 +131,10 @@ export interface GeneralActionResolution { cityId?: CityId; nationId?: NationId; }; + alternative?: { + commandKey: string; + args: unknown; + }; } export const createGeneralPatchEffect = ( @@ -205,6 +213,7 @@ export const resolveGeneralAction = | undefined; const [nextWorld, worldPatches] = produceWithPatches( { general: context.general, @@ -252,9 +261,10 @@ export const resolveGeneralAction = { + load(key: string): Promise | null>; +} + +import type { LogEntryDraft } from '@sammo-ts/logic/logging/types.js'; + +export const processGeneralActionWithFallback = async ( + initialResolver: GeneralActionResolver, + context: GeneralActionResolveInputContext, + scheduleContext: TurnScheduleContext, + initialArgs: unknown, + commandLoader: CommandLoader +): Promise => { + let currentResolver = initialResolver; + let currentArgs = initialArgs; + let loopLimit = 5; // Prevent infinite loops + const accumulatedLogs: LogEntryDraft[] = []; + + while (loopLimit > 0) { + loopLimit--; + + const resolution = resolveGeneralAction(currentResolver, context, scheduleContext, currentArgs); + + if (resolution.alternative) { + accumulatedLogs.push(...resolution.logs); + const { commandKey, args } = resolution.alternative; + const nextResolver = await commandLoader.load(commandKey); + + if (nextResolver) { + currentResolver = nextResolver; + currentArgs = args; + continue; + } + } + + // Prepend accumulated logs to the final resolution + if (accumulatedLogs.length > 0) { + resolution.logs.unshift(...accumulatedLogs); + } + return resolution; + } + + throw new Error('Command fallback loop limit exceeded'); +}; diff --git a/packages/logic/src/actions/turn/general/che_출병.ts b/packages/logic/src/actions/turn/general/che_출병.ts index 59448fd..cce136a 100644 --- a/packages/logic/src/actions/turn/general/che_출병.ts +++ b/packages/logic/src/actions/turn/general/che_출병.ts @@ -336,7 +336,13 @@ export class ActionDefinition< `${targetName}${josaRoTarget} 가는 도중 ${destCity.name}을 거치기로 합니다.` ); } - return { effects: [] }; + return { + effects: [], + alternative: { + commandKey: 'che_이동', + args: { destCityId: destCity.id }, + }, + }; } if (finalTargetCity.id !== destCity.id) { diff --git a/packages/logic/test/actions/turn/executionHelper.test.ts b/packages/logic/test/actions/turn/executionHelper.test.ts new file mode 100644 index 0000000..9e5142b --- /dev/null +++ b/packages/logic/test/actions/turn/executionHelper.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi } from 'vitest'; +import { processGeneralActionWithFallback, type CommandLoader } from '../../../src/actions/turn/executionHelper.js'; +import { + type GeneralActionResolver, + type GeneralActionResolveInputContext, + type TurnScheduleContext, +} from '../../../src/actions/engine.js'; + +describe('processGeneralActionWithFallback', () => { + const mockContext = { + general: { id: 100, name: 'TestGeneral' } as any, + } as GeneralActionResolveInputContext; + const mockSchedule = { + now: new Date('2025-01-01T00:00:00Z'), + schedule: { + entries: [{ startMinute: 0, tickMinutes: 1 }], + }, + } as TurnScheduleContext; + + // Mock Resolvers + const successResolver: GeneralActionResolver = { + key: 'successCmd', + resolve: () => ({ + effects: [], + }), + }; + + const fallbackResolver: GeneralActionResolver = { + key: 'fallbackCmd', + resolve: () => ({ + effects: [ + { + type: 'log', + entry: { text: 'Primary failed', scope: 'general', category: 'action', format: 'month' }, + } as any, + ], + alternative: { + commandKey: 'alternativeCmd', + args: { foo: 'bar' }, + }, + }), + }; + + const alternativeResolver: GeneralActionResolver = { + key: 'alternativeCmd', + resolve: (_, args) => ({ + effects: [ + { + type: 'log', + entry: { + text: `Alternative executed with ${JSON.stringify(args)}`, + scope: 'general', + category: 'action', + format: 'month', + }, + } as any, + ], + }), + }; + + const infiniteResolver: GeneralActionResolver = { + key: 'infiniteCmd', + resolve: () => ({ + effects: [], + alternative: { + commandKey: 'infiniteCmd', + args: {}, + }, + }), + }; + + // Mock Loader + const mockLoader: CommandLoader = { + load: vi.fn(async (key: string) => { + if (key === 'alternativeCmd') return alternativeResolver; + if (key === 'infiniteCmd') return infiniteResolver; + return null; + }), + }; + + it('should execute a simple command successfully', async () => { + const resolution = await processGeneralActionWithFallback( + successResolver, + mockContext, + mockSchedule, + {}, + mockLoader + ); + + expect(resolution.effects).toHaveLength(0); + expect(resolution.alternative).toBeUndefined(); + }); + + it('should handle alternative command fallback', async () => { + const resolution = await processGeneralActionWithFallback( + fallbackResolver, + mockContext, + mockSchedule, + {}, + mockLoader + ); + + // It should eventually execute alternativeResolver + // BUT resolveGeneralAction creates a FRESH resolution from the FINAL resolver. + // It does NOT merge logs currently. (As per my implementation comment) + // Wait, did I implement log merging? No. + // I implemented a simple loop that re-runs `resolveGeneralAction`. + // So the final resolution comes from `alternativeResolver`. + + // Let's verify what we expect. + // If we want legacy parity, we might expect logs from the first attempt too. + // But for now, let's verify the loop works. + + expect(resolution.logs).toHaveLength(2); // 'Primary failed' + 'Alternative executed...' + expect(resolution.alternative).toBeUndefined(); // The final one succeeded + expect(mockLoader.load).toHaveBeenCalledWith('alternativeCmd'); + }); + + it('should prevent infinite loops', async () => { + await expect( + processGeneralActionWithFallback(infiniteResolver, mockContext, mockSchedule, {}, mockLoader) + ).rejects.toThrow('Command fallback loop limit exceeded'); + }); +});