커맨드 엔진 실행에서 alternative 추가
This commit is contained in:
@@ -97,6 +97,10 @@ export type GeneralActionEffect<TriggerState extends GeneralTriggerState = Gener
|
|||||||
|
|
||||||
export interface GeneralActionOutcome<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
export interface GeneralActionOutcome<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||||
effects: GeneralActionEffect<TriggerState>[];
|
effects: GeneralActionEffect<TriggerState>[];
|
||||||
|
alternative?: {
|
||||||
|
commandKey: string;
|
||||||
|
args: unknown;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GeneralActionResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState, Args = unknown> {
|
export interface GeneralActionResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState, Args = unknown> {
|
||||||
@@ -127,6 +131,10 @@ export interface GeneralActionResolution {
|
|||||||
cityId?: CityId;
|
cityId?: CityId;
|
||||||
nationId?: NationId;
|
nationId?: NationId;
|
||||||
};
|
};
|
||||||
|
alternative?: {
|
||||||
|
commandKey: string;
|
||||||
|
args: unknown;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createGeneralPatchEffect = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
export const createGeneralPatchEffect = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
||||||
@@ -205,6 +213,7 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pendingEffects: GeneralActionEffect[] = [];
|
const pendingEffects: GeneralActionEffect[] = [];
|
||||||
|
let outcome: GeneralActionOutcome<TriggerState> | undefined;
|
||||||
const [nextWorld, worldPatches] = produceWithPatches(
|
const [nextWorld, worldPatches] = produceWithPatches(
|
||||||
{
|
{
|
||||||
general: context.general,
|
general: context.general,
|
||||||
@@ -252,9 +261,10 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const outcome = resolver.resolve(
|
outcome = resolver.resolve(
|
||||||
{
|
{
|
||||||
...context,
|
...context,
|
||||||
|
// ...
|
||||||
general: castDraft(draft.general),
|
general: castDraft(draft.general),
|
||||||
city: castDraft(draft.city),
|
city: castDraft(draft.city),
|
||||||
nation: castDraft(draft.nation),
|
nation: castDraft(draft.nation),
|
||||||
@@ -345,6 +355,7 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
|||||||
nextTurnAt,
|
nextTurnAt,
|
||||||
logs,
|
logs,
|
||||||
effects: pendingEffects,
|
effects: pendingEffects,
|
||||||
|
...(outcome?.alternative ? { alternative: outcome.alternative } : {}),
|
||||||
};
|
};
|
||||||
if (nextWorld.city) {
|
if (nextWorld.city) {
|
||||||
resolution.city = nextWorld.city as City;
|
resolution.city = nextWorld.city as City;
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type {
|
||||||
|
GeneralActionResolution,
|
||||||
|
GeneralActionResolveInputContext,
|
||||||
|
GeneralActionResolver,
|
||||||
|
TurnScheduleContext,
|
||||||
|
} from '../engine.js';
|
||||||
|
import { resolveGeneralAction } from '../engine.js';
|
||||||
|
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
|
||||||
|
export interface CommandLoader<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||||
|
load(key: string): Promise<GeneralActionResolver<TriggerState> | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
import type { LogEntryDraft } from '@sammo-ts/logic/logging/types.js';
|
||||||
|
|
||||||
|
export const processGeneralActionWithFallback = async <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
||||||
|
initialResolver: GeneralActionResolver<TriggerState>,
|
||||||
|
context: GeneralActionResolveInputContext<TriggerState>,
|
||||||
|
scheduleContext: TurnScheduleContext,
|
||||||
|
initialArgs: unknown,
|
||||||
|
commandLoader: CommandLoader<TriggerState>
|
||||||
|
): Promise<GeneralActionResolution> => {
|
||||||
|
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');
|
||||||
|
};
|
||||||
@@ -336,7 +336,13 @@ export class ActionDefinition<
|
|||||||
`<G><b>${targetName}</b></>${josaRoTarget} 가는 도중 <G><b>${destCity.name}</b></>을 거치기로 합니다.`
|
`<G><b>${targetName}</b></>${josaRoTarget} 가는 도중 <G><b>${destCity.name}</b></>을 거치기로 합니다.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { effects: [] };
|
return {
|
||||||
|
effects: [],
|
||||||
|
alternative: {
|
||||||
|
commandKey: 'che_이동',
|
||||||
|
args: { destCityId: destCity.id },
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (finalTargetCity.id !== destCity.id) {
|
if (finalTargetCity.id !== destCity.id) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user