fix: 내부 전용 예약 명령 실행 경계를 분리한다
This commit is contained in:
@@ -13,9 +13,8 @@ import {
|
|||||||
} from '../../turns/commandTable.js';
|
} from '../../turns/commandTable.js';
|
||||||
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
|
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
|
||||||
import {
|
import {
|
||||||
assertReservedTurnActionAvailable,
|
|
||||||
buildEquipmentTradeItemOptions,
|
buildEquipmentTradeItemOptions,
|
||||||
parseRegisteredTurnArgs,
|
parseReservedTurnArgs,
|
||||||
TURN_COMMAND_NATION_COLORS,
|
TURN_COMMAND_NATION_COLORS,
|
||||||
type TurnCommandInputOptions,
|
type TurnCommandInputOptions,
|
||||||
} from '../../turns/commandInput.js';
|
} from '../../turns/commandInput.js';
|
||||||
@@ -64,9 +63,17 @@ const buildBulkEntrySchema = (turnList: z.ZodType<number[]>) =>
|
|||||||
args: z.unknown().optional(),
|
args: z.unknown().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const parseCommandArgs = async (scope: 'general' | 'nation', action: string, args: unknown) => {
|
const parseCommandArgs = async (
|
||||||
|
scope: 'general' | 'nation',
|
||||||
|
action: string,
|
||||||
|
args: unknown,
|
||||||
|
worldState: WorldStateRow
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
return await parseRegisteredTurnArgs(scope, action, args);
|
// 사용자 입력은 action별 argument schema보다 먼저 현재 scenario의
|
||||||
|
// 선택 가능 profile을 통과해야 한다. 내부 전용 명령의 parser를 외부
|
||||||
|
// 요청이 직접 호출하지 못하게 하는 첫 경계다.
|
||||||
|
return await parseReservedTurnArgs(scope, action, args, asRecord(worldState.config).const);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
@@ -76,22 +83,6 @@ const parseCommandArgs = async (scope: 'general' | 'nation', action: string, arg
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const assertScenarioCommandAvailable = async (
|
|
||||||
scope: 'general' | 'nation',
|
|
||||||
action: string,
|
|
||||||
worldState: WorldStateRow
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await assertReservedTurnActionAvailable(scope, action, asRecord(worldState.config).const);
|
|
||||||
} catch (error) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'BAD_REQUEST',
|
|
||||||
message: error instanceof Error ? error.message : 'Unavailable turn command.',
|
|
||||||
cause: error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const mutateReservedTurns = async <T>(mutation: () => Promise<T>): Promise<T> => {
|
const mutateReservedTurns = async <T>(mutation: () => Promise<T>): Promise<T> => {
|
||||||
try {
|
try {
|
||||||
return await mutation();
|
return await mutation();
|
||||||
@@ -428,9 +419,8 @@ export const turnsRouter = router({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||||
const args = await parseCommandArgs('general', input.action, input.args);
|
|
||||||
const worldState = await getReservationWorldState(ctx);
|
const worldState = await getReservationWorldState(ctx);
|
||||||
await assertScenarioCommandAvailable('general', input.action, worldState);
|
const args = await parseCommandArgs('general', input.action, input.args, worldState);
|
||||||
await assertReservedTurnPermission(worldState, general, 'general', input.action, args);
|
await assertReservedTurnPermission(worldState, general, 'general', input.action, args);
|
||||||
|
|
||||||
const snapshot = await mutateReservedTurns(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
@@ -485,16 +475,15 @@ export const turnsRouter = router({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||||
|
const worldState = await getReservationWorldState(ctx);
|
||||||
const updates = await Promise.all(
|
const updates = await Promise.all(
|
||||||
input.entries.map(async (entry) => ({
|
input.entries.map(async (entry) => ({
|
||||||
turnIndices: expandGeneralTurnIndices(entry.turnList),
|
turnIndices: expandGeneralTurnIndices(entry.turnList),
|
||||||
action: entry.action,
|
action: entry.action,
|
||||||
args: await parseCommandArgs('general', entry.action, entry.args),
|
args: await parseCommandArgs('general', entry.action, entry.args, worldState),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const worldState = await getReservationWorldState(ctx);
|
|
||||||
for (const update of updates) {
|
for (const update of updates) {
|
||||||
await assertScenarioCommandAvailable('general', update.action, worldState);
|
|
||||||
await assertReservedTurnPermission(worldState, general, 'general', update.action, update.args);
|
await assertReservedTurnPermission(worldState, general, 'general', update.action, update.args);
|
||||||
}
|
}
|
||||||
const snapshot = await mutateReservedTurns(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
@@ -532,9 +521,8 @@ export const turnsRouter = router({
|
|||||||
message: 'General is not an officer.',
|
message: 'General is not an officer.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const args = await parseCommandArgs('nation', input.action, input.args);
|
|
||||||
const worldState = await getReservationWorldState(ctx);
|
const worldState = await getReservationWorldState(ctx);
|
||||||
await assertScenarioCommandAvailable('nation', input.action, worldState);
|
const args = await parseCommandArgs('nation', input.action, input.args, worldState);
|
||||||
await assertReservedTurnPermission(worldState, general, 'nation', input.action, args);
|
await assertReservedTurnPermission(worldState, general, 'nation', input.action, args);
|
||||||
|
|
||||||
const snapshot = await mutateReservedTurns(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
@@ -639,16 +627,15 @@ export const turnsRouter = router({
|
|||||||
message: 'General is not an officer.',
|
message: 'General is not an officer.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const worldState = await getReservationWorldState(ctx);
|
||||||
const updates = await Promise.all(
|
const updates = await Promise.all(
|
||||||
input.entries.map(async (entry) => ({
|
input.entries.map(async (entry) => ({
|
||||||
turnIndices: entry.turnList,
|
turnIndices: entry.turnList,
|
||||||
action: entry.action,
|
action: entry.action,
|
||||||
args: await parseCommandArgs('nation', entry.action, entry.args),
|
args: await parseCommandArgs('nation', entry.action, entry.args, worldState),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const worldState = await getReservationWorldState(ctx);
|
|
||||||
for (const update of updates) {
|
for (const update of updates) {
|
||||||
await assertScenarioCommandAvailable('nation', update.action, worldState);
|
|
||||||
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
|
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
|
||||||
}
|
}
|
||||||
const snapshot = await mutateReservedTurns(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ export const loadTurnCommandSpecs = async (scenarioConst?: unknown) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const parseRegisteredTurnArgs = async (
|
const parseRegisteredTurnArgs = async (
|
||||||
scope: 'general' | 'nation',
|
scope: 'general' | 'nation',
|
||||||
action: string,
|
action: string,
|
||||||
rawArgs: unknown
|
rawArgs: unknown
|
||||||
|
|||||||
@@ -99,6 +99,31 @@ describe('turn command argument input', () => {
|
|||||||
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command');
|
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects internal general commands before parsing their arguments or scenario overrides', async () => {
|
||||||
|
await expect(parseReservedTurnArgs('general', 'che_NPC능동', {})).rejects.toThrow(
|
||||||
|
'Unknown general turn command: che_NPC능동'
|
||||||
|
);
|
||||||
|
await expect(parseReservedTurnArgs('general', 'che_방랑', {})).rejects.toThrow(
|
||||||
|
'Unknown general turn command: che_방랑'
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
parseReservedTurnArgs('general', 'che_등용수락', { destNationId: 1, destGeneralId: 2 })
|
||||||
|
).rejects.toThrow('Unknown general turn command: che_등용수락');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
parseReservedTurnArgs(
|
||||||
|
'general',
|
||||||
|
'che_NPC능동',
|
||||||
|
{ optionText: '순간이동', destCityId: 1 },
|
||||||
|
{
|
||||||
|
availableGeneralCommand: {
|
||||||
|
내부: ['휴식', 'che_NPC능동'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).rejects.toThrow('Unknown scenario general command key: che_NPC능동');
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts and rejects reserved commands from the real 904/905/910/912 world config', async () => {
|
it('accepts and rejects reserved commands from the real 904/905/910/912 world config', async () => {
|
||||||
const scenarioConsts = Object.fromEntries(
|
const scenarioConsts = Object.fromEntries(
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
|||||||
@@ -1227,7 +1227,12 @@ describe('appRouter', () => {
|
|||||||
const generalWrites: unknown[] = [];
|
const generalWrites: unknown[] = [];
|
||||||
const nationWrites: unknown[] = [];
|
const nationWrites: unknown[] = [];
|
||||||
const caller = appRouter.createCaller(
|
const caller = appRouter.createCaller(
|
||||||
buildContext({ general, generalTurnWrites: generalWrites, nationTurnWrites: nationWrites })
|
buildContext({
|
||||||
|
state: buildWorldState(),
|
||||||
|
general,
|
||||||
|
generalTurnWrites: generalWrites,
|
||||||
|
nationTurnWrites: nationWrites,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -1248,6 +1253,18 @@ describe('appRouter', () => {
|
|||||||
expectedRevision: 0,
|
expectedRevision: 0,
|
||||||
})
|
})
|
||||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||||
|
await expect(
|
||||||
|
caller.turns.reserved.setGeneral({
|
||||||
|
generalId: 14,
|
||||||
|
turnIndex: 0,
|
||||||
|
action: 'che_NPC능동',
|
||||||
|
args: {},
|
||||||
|
expectedRevision: 0,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Unknown general turn command: che_NPC능동',
|
||||||
|
});
|
||||||
|
|
||||||
expect(generalWrites).toHaveLength(0);
|
expect(generalWrites).toHaveLength(0);
|
||||||
expect(nationWrites).toHaveLength(0);
|
expect(nationWrites).toHaveLength(0);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
UnitSetDefinition,
|
UnitSetDefinition,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import {
|
import {
|
||||||
|
INTERNAL_GENERAL_TURN_COMMAND_KEYS,
|
||||||
LEGACY_RANDOM_GENERAL_FIRST_NAMES,
|
LEGACY_RANDOM_GENERAL_FIRST_NAMES,
|
||||||
LEGACY_RANDOM_GENERAL_LAST_NAMES,
|
LEGACY_RANDOM_GENERAL_LAST_NAMES,
|
||||||
LEGACY_DEFAULT_MAX_LEVEL,
|
LEGACY_DEFAULT_MAX_LEVEL,
|
||||||
@@ -173,6 +174,7 @@ export const buildReservedTurnDefinitions = async (options: {
|
|||||||
env: TurnCommandEnv;
|
env: TurnCommandEnv;
|
||||||
commandProfile: TurnCommandProfile;
|
commandProfile: TurnCommandProfile;
|
||||||
defaultActionKey: GeneralTurnCommandKey & NationTurnCommandKey;
|
defaultActionKey: GeneralTurnCommandKey & NationTurnCommandKey;
|
||||||
|
internalGeneralCommandKeys?: readonly GeneralTurnCommandKey[];
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
general: Map<string, GeneralActionDefinition>;
|
general: Map<string, GeneralActionDefinition>;
|
||||||
nation: Map<string, GeneralActionDefinition>;
|
nation: Map<string, GeneralActionDefinition>;
|
||||||
@@ -198,7 +200,10 @@ export const buildReservedTurnDefinitions = async (options: {
|
|||||||
options.env.warActionModules ??= moduleBundle.war;
|
options.env.warActionModules ??= moduleBundle.war;
|
||||||
options.env.nationTraitModules = moduleBundle.nationTraitModules;
|
options.env.nationTraitModules = moduleBundle.nationTraitModules;
|
||||||
|
|
||||||
const generalSpecs = await loadGeneralTurnCommandSpecs(options.commandProfile.general);
|
const generalSpecs = await loadGeneralTurnCommandSpecs([
|
||||||
|
...options.commandProfile.general,
|
||||||
|
...(options.internalGeneralCommandKeys ?? INTERNAL_GENERAL_TURN_COMMAND_KEYS),
|
||||||
|
]);
|
||||||
const nationSpecs = await loadNationTurnCommandSpecs(options.commandProfile.nation);
|
const nationSpecs = await loadNationTurnCommandSpecs(options.commandProfile.nation);
|
||||||
|
|
||||||
const general = new Map(generalSpecs.map((spec) => [spec.key, spec.createDefinition(options.env)]));
|
const general = new Map(generalSpecs.map((spec) => [spec.key, spec.createDefinition(options.env)]));
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import type {
|
|||||||
ScenarioConfig,
|
ScenarioConfig,
|
||||||
ScenarioMeta,
|
ScenarioMeta,
|
||||||
Troop,
|
Troop,
|
||||||
GeneralTurnCommandKey,
|
|
||||||
TurnCommandProfile,
|
TurnCommandProfile,
|
||||||
TurnCommandEnv,
|
TurnCommandEnv,
|
||||||
UnitSetDefinition,
|
UnitSetDefinition,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import {
|
import {
|
||||||
DEFAULT_TURN_COMMAND_PROFILE,
|
DEFAULT_TURN_COMMAND_PROFILE,
|
||||||
|
INTERNAL_GENERAL_TURN_COMMAND_KEYS,
|
||||||
GeneralTurnCommandLoader,
|
GeneralTurnCommandLoader,
|
||||||
GeneralActionPipeline,
|
GeneralActionPipeline,
|
||||||
NationTurnCommandLoader,
|
NationTurnCommandLoader,
|
||||||
@@ -71,8 +71,6 @@ import {
|
|||||||
} from './scenarioStaticEvents.js';
|
} from './scenarioStaticEvents.js';
|
||||||
|
|
||||||
const DEFAULT_ACTION = '휴식';
|
const DEFAULT_ACTION = '휴식';
|
||||||
const AI_INTERNAL_GENERAL_ACTION_KEYS = ['che_NPC능동'] as const satisfies readonly GeneralTurnCommandKey[];
|
|
||||||
|
|
||||||
const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([
|
const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([
|
||||||
'che_소집해제',
|
'che_소집해제',
|
||||||
'che_랜덤임관',
|
'che_랜덤임관',
|
||||||
@@ -964,10 +962,9 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
};
|
};
|
||||||
const generalModuleLoader = new GeneralTurnCommandLoader();
|
const generalModuleLoader = new GeneralTurnCommandLoader();
|
||||||
const nationModuleLoader = new NationTurnCommandLoader();
|
const nationModuleLoader = new NationTurnCommandLoader();
|
||||||
// NPC AI emits a few engine-internal commands that are intentionally not
|
// AI·월간 이벤트·서신이 생성하는 내부 명령은 사용자 선택 profile에는
|
||||||
// exposed by the scenario's player command profile. Keep their definitions
|
// 노출하지 않지만, 내부 실행 경로에서는 항상 정의와 context를 찾을 수 있어야 한다.
|
||||||
// available to AI resolution without adding them to the public profile.
|
for (const key of INTERNAL_GENERAL_TURN_COMMAND_KEYS) {
|
||||||
for (const key of AI_INTERNAL_GENERAL_ACTION_KEYS) {
|
|
||||||
const module = await generalModuleLoader.load(key);
|
const module = await generalModuleLoader.load(key);
|
||||||
if (!generalDefinitions.has(key)) {
|
if (!generalDefinitions.has(key)) {
|
||||||
generalDefinitions.set(key, module.commandSpec.createDefinition(env));
|
generalDefinitions.set(key, module.commandSpec.createDefinition(env));
|
||||||
@@ -2460,18 +2457,13 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
|||||||
const env = buildCommandEnv(options.world.getScenarioConfig(), options.world.getUnitSet());
|
const env = buildCommandEnv(options.world.getScenarioConfig(), options.world.getUnitSet());
|
||||||
const commandProfile = options.commandProfile ?? DEFAULT_TURN_COMMAND_PROFILE;
|
const commandProfile = options.commandProfile ?? DEFAULT_TURN_COMMAND_PROFILE;
|
||||||
// 등용수락은 예약 화면에 노출되는 명령이 아니라 등용 서신의 응답이
|
// 등용수락은 예약 화면에 노출되는 명령이 아니라 등용 서신의 응답이
|
||||||
// 직접 실행하는 내부 명령이다. 선택 가능 명령 프로필에 없더라도 등용
|
// 직접 실행하는 내부 명령이다. 공통 내부 집합 전체 대신 이 실행기에 필요한
|
||||||
// 서신을 수락할 수 있도록 즉시 행동 정의에는 항상 포함한다.
|
// 정의만 명시해 loader 경계를 재사용한다.
|
||||||
const immediateCommandProfile: TurnCommandProfile = commandProfile.general.includes('che_등용수락')
|
|
||||||
? commandProfile
|
|
||||||
: {
|
|
||||||
...commandProfile,
|
|
||||||
general: [...commandProfile.general, 'che_등용수락'],
|
|
||||||
};
|
|
||||||
const { general: definitions } = await buildReservedTurnDefinitions({
|
const { general: definitions } = await buildReservedTurnDefinitions({
|
||||||
env,
|
env,
|
||||||
commandProfile: immediateCommandProfile,
|
commandProfile,
|
||||||
defaultActionKey: DEFAULT_ACTION,
|
defaultActionKey: DEFAULT_ACTION,
|
||||||
|
internalGeneralCommandKeys: ['che_등용수락'],
|
||||||
});
|
});
|
||||||
const generalModuleLoader = new GeneralTurnCommandLoader();
|
const generalModuleLoader = new GeneralTurnCommandLoader();
|
||||||
const contextBuilders = new Map<string, ActionContextBuilder>();
|
const contextBuilders = new Map<string, ActionContextBuilder>();
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { DEFAULT_TURN_COMMAND_PROFILE, type ScenarioConfig } from '@sammo-ts/logic';
|
import {
|
||||||
|
DEFAULT_TURN_COMMAND_PROFILE,
|
||||||
|
INTERNAL_GENERAL_TURN_COMMAND_KEYS,
|
||||||
|
type ScenarioConfig,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { buildCommandEnv, buildReservedTurnDefinitions } from '../src/turn/reservedTurnCommands.js';
|
import { buildCommandEnv, buildReservedTurnDefinitions } from '../src/turn/reservedTurnCommands.js';
|
||||||
|
|
||||||
@@ -54,6 +58,7 @@ describe('Ref active-action inheritance inventory', () => {
|
|||||||
env: buildCommandEnv(scenarioConfig),
|
env: buildCommandEnv(scenarioConfig),
|
||||||
commandProfile: DEFAULT_TURN_COMMAND_PROFILE,
|
commandProfile: DEFAULT_TURN_COMMAND_PROFILE,
|
||||||
defaultActionKey: '휴식',
|
defaultActionKey: '휴식',
|
||||||
|
internalGeneralCommandKeys: INTERNAL_GENERAL_TURN_COMMAND_KEYS,
|
||||||
});
|
});
|
||||||
|
|
||||||
const generalWithFixedOrContextAmount = [...general.entries()]
|
const generalWithFixedOrContextAmount = [...general.entries()]
|
||||||
@@ -71,4 +76,14 @@ describe('Ref active-action inheritance inventory', () => {
|
|||||||
.sort();
|
.sort();
|
||||||
expect(nationWithPoint).toEqual([...nationCommands].sort());
|
expect(nationWithPoint).toEqual([...nationCommands].sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads every internal command without adding it to the selectable profile', async () => {
|
||||||
|
const { general } = await buildReservedTurnDefinitions({
|
||||||
|
env: buildCommandEnv(scenarioConfig),
|
||||||
|
commandProfile: { general: ['휴식'], nation: ['휴식'] },
|
||||||
|
defaultActionKey: '휴식',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([...general.keys()].sort()).toEqual(['che_NPC능동', 'che_등용수락', 'che_방랑', '휴식'].sort());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { createMonthlyEventHandler } from '../src/turn/monthlyEventHandler.js';
|
import { createMonthlyEventHandler } from '../src/turn/monthlyEventHandler.js';
|
||||||
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
|
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
|
||||||
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
|
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
|
||||||
|
import { createReservedTurnHandler } from '../src/turn/reservedTurnHandler.js';
|
||||||
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
|
||||||
const buildCity = (id: number, nationId: number, level: number): City => ({
|
const buildCity = (id: number, nationId: number, level: number): City => ({
|
||||||
@@ -313,8 +314,24 @@ describe('invader monthly actions', () => {
|
|||||||
getWorld: () => world,
|
getWorld: () => world,
|
||||||
reservedTurns: harness.reservedTurns,
|
reservedTurns: harness.reservedTurns,
|
||||||
});
|
});
|
||||||
|
const reservedTurnHandler = await createReservedTurnHandler({
|
||||||
|
reservedTurns: harness.reservedTurns,
|
||||||
|
scenarioConfig,
|
||||||
|
scenarioMeta: {
|
||||||
|
title: '내부 방랑 실행 fixture',
|
||||||
|
startYear: 190,
|
||||||
|
life: null,
|
||||||
|
fiction: null,
|
||||||
|
history: [],
|
||||||
|
ignoreDefaultEvents: false,
|
||||||
|
},
|
||||||
|
map: harness.snapshot.map,
|
||||||
|
getWorld: () => world,
|
||||||
|
commandProfile: { general: ['휴식'], nation: ['휴식'] },
|
||||||
|
});
|
||||||
world = new InMemoryTurnWorld(state, harness.snapshot, {
|
world = new InMemoryTurnWorld(state, harness.snapshot, {
|
||||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||||
|
generalTurnHandler: reservedTurnHandler,
|
||||||
calendarHandler: createMonthlyEventHandler({
|
calendarHandler: createMonthlyEventHandler({
|
||||||
getWorld: () => world,
|
getWorld: () => world,
|
||||||
startYear: 190,
|
startYear: 190,
|
||||||
@@ -342,6 +359,17 @@ describe('invader monthly actions', () => {
|
|||||||
Array.from({ length: 30 }, () => ({ action: 'che_방랑', args: {} }))
|
Array.from({ length: 30 }, () => ({ action: 'che_방랑', args: {} }))
|
||||||
);
|
);
|
||||||
expect(harness.reservedTurns.peekDirtyState().generalIds).toEqual([2]);
|
expect(harness.reservedTurns.peekDirtyState().generalIds).toEqual([2]);
|
||||||
|
|
||||||
|
const ruler = world.getGeneralById(2);
|
||||||
|
expect(ruler).not.toBeNull();
|
||||||
|
expect(() => world.executeGeneralTurn(ruler!)).not.toThrow();
|
||||||
|
expect(world.getNationById(2)).toMatchObject({
|
||||||
|
name: 'ⓞ도시2대왕',
|
||||||
|
level: 0,
|
||||||
|
capitalCityId: 0,
|
||||||
|
typeCode: 'None',
|
||||||
|
});
|
||||||
|
expect(world.getCityById(2)?.nationId).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('finishes with the legacy user-win logs and refresh multiplier', async () => {
|
it('finishes with the legacy user-win logs and refresh multiplier', async () => {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { GENERAL_TURN_COMMAND_KEYS, isGeneralTurnCommandKey, type GeneralTurnCommandKey } from './general/index.js';
|
import {
|
||||||
|
SELECTABLE_GENERAL_TURN_COMMAND_KEYS,
|
||||||
|
isSelectableGeneralTurnCommandKey,
|
||||||
|
type SelectableGeneralTurnCommandKey,
|
||||||
|
} from './general/index.js';
|
||||||
import { NATION_TURN_COMMAND_KEYS, isNationTurnCommandKey, type NationTurnCommandKey } from './nation/index.js';
|
import { NATION_TURN_COMMAND_KEYS, isNationTurnCommandKey, type NationTurnCommandKey } from './nation/index.js';
|
||||||
import { asStringArray, isRecord } from '@sammo-ts/common';
|
import { asStringArray, isRecord } from '@sammo-ts/common';
|
||||||
import { TurnCommandProfileInputSchema } from '../../resources/turnCommandSchema.js';
|
import { TurnCommandProfileInputSchema } from '../../resources/turnCommandSchema.js';
|
||||||
|
|
||||||
export interface TurnCommandProfile {
|
export interface TurnCommandProfile {
|
||||||
general: GeneralTurnCommandKey[];
|
general: SelectableGeneralTurnCommandKey[];
|
||||||
nation: NationTurnCommandKey[];
|
nation: NationTurnCommandKey[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,7 +19,7 @@ export interface TurnCommandGroup<Key extends string> {
|
|||||||
|
|
||||||
export interface ScenarioTurnCommandProfileResolution {
|
export interface ScenarioTurnCommandProfileResolution {
|
||||||
profile: TurnCommandProfile;
|
profile: TurnCommandProfile;
|
||||||
generalGroups: Array<TurnCommandGroup<GeneralTurnCommandKey>> | null;
|
generalGroups: Array<TurnCommandGroup<SelectableGeneralTurnCommandKey>> | null;
|
||||||
nationGroups: Array<TurnCommandGroup<NationTurnCommandKey>> | null;
|
nationGroups: Array<TurnCommandGroup<NationTurnCommandKey>> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +53,7 @@ const parseKeyList = <T extends string>(options: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_TURN_COMMAND_PROFILE: TurnCommandProfile = {
|
export const DEFAULT_TURN_COMMAND_PROFILE: TurnCommandProfile = {
|
||||||
general: [...GENERAL_TURN_COMMAND_KEYS],
|
general: [...SELECTABLE_GENERAL_TURN_COMMAND_KEYS],
|
||||||
nation: [...NATION_TURN_COMMAND_KEYS],
|
nation: [...NATION_TURN_COMMAND_KEYS],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,7 +66,7 @@ export const parseTurnCommandProfile = (raw: unknown): TurnCommandProfile => {
|
|||||||
return {
|
return {
|
||||||
general: parseKeyList({
|
general: parseKeyList({
|
||||||
raw: data.general,
|
raw: data.general,
|
||||||
isKey: isGeneralTurnCommandKey,
|
isKey: isSelectableGeneralTurnCommandKey,
|
||||||
label: 'general',
|
label: 'general',
|
||||||
}),
|
}),
|
||||||
nation: parseKeyList({
|
nation: parseKeyList({
|
||||||
@@ -133,7 +137,7 @@ export const resolveScenarioTurnCommandProfile = (
|
|||||||
const config = isRecord(scenarioConst) ? scenarioConst : {};
|
const config = isRecord(scenarioConst) ? scenarioConst : {};
|
||||||
const generalGroups = parseScenarioCommandGroups({
|
const generalGroups = parseScenarioCommandGroups({
|
||||||
raw: config.availableGeneralCommand,
|
raw: config.availableGeneralCommand,
|
||||||
isKey: isGeneralTurnCommandKey,
|
isKey: isSelectableGeneralTurnCommandKey,
|
||||||
label: 'general',
|
label: 'general',
|
||||||
});
|
});
|
||||||
const nationGroups = parseScenarioCommandGroups({
|
const nationGroups = parseScenarioCommandGroups({
|
||||||
|
|||||||
@@ -60,6 +60,21 @@ export const GENERAL_TURN_COMMAND_KEYS = [
|
|||||||
|
|
||||||
export type GeneralTurnCommandKey = (typeof GENERAL_TURN_COMMAND_KEYS)[number];
|
export type GeneralTurnCommandKey = (typeof GENERAL_TURN_COMMAND_KEYS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔진·서신·월간 이벤트만 생성할 수 있고 예약 API의 사용자 입력으로는
|
||||||
|
* 노출하지 않는 명령입니다. 저장된 예약 턴은 문자열 action을 유지하므로,
|
||||||
|
* 입력 경계와 실행 경계를 서로 다른 집합으로 관리합니다.
|
||||||
|
*/
|
||||||
|
export const INTERNAL_GENERAL_TURN_COMMAND_KEYS = [
|
||||||
|
'che_등용수락',
|
||||||
|
'che_방랑',
|
||||||
|
'che_NPC능동',
|
||||||
|
] as const satisfies readonly GeneralTurnCommandKey[];
|
||||||
|
|
||||||
|
export type InternalGeneralTurnCommandKey = (typeof INTERNAL_GENERAL_TURN_COMMAND_KEYS)[number];
|
||||||
|
|
||||||
|
export type SelectableGeneralTurnCommandKey = Exclude<GeneralTurnCommandKey, InternalGeneralTurnCommandKey>;
|
||||||
|
|
||||||
export type GeneralTurnCommandSpec = TurnCommandSpecBase<GeneralTurnCommandKey>;
|
export type GeneralTurnCommandSpec = TurnCommandSpecBase<GeneralTurnCommandKey>;
|
||||||
|
|
||||||
export type GeneralTurnCommandModule = TurnCommandModule<GeneralTurnCommandSpec>;
|
export type GeneralTurnCommandModule = TurnCommandModule<GeneralTurnCommandSpec>;
|
||||||
@@ -127,6 +142,20 @@ const defaultImporters: Record<GeneralTurnCommandKey, GeneralTurnCommandImporter
|
|||||||
export const isGeneralTurnCommandKey = (value: string): value is GeneralTurnCommandKey =>
|
export const isGeneralTurnCommandKey = (value: string): value is GeneralTurnCommandKey =>
|
||||||
GENERAL_TURN_COMMAND_KEYS.includes(value as GeneralTurnCommandKey);
|
GENERAL_TURN_COMMAND_KEYS.includes(value as GeneralTurnCommandKey);
|
||||||
|
|
||||||
|
const internalGeneralTurnCommandKeySet: ReadonlySet<GeneralTurnCommandKey> = new Set(
|
||||||
|
INTERNAL_GENERAL_TURN_COMMAND_KEYS
|
||||||
|
);
|
||||||
|
|
||||||
|
export const isInternalGeneralTurnCommandKey = (value: string): value is InternalGeneralTurnCommandKey =>
|
||||||
|
isGeneralTurnCommandKey(value) && internalGeneralTurnCommandKeySet.has(value);
|
||||||
|
|
||||||
|
export const isSelectableGeneralTurnCommandKey = (value: string): value is SelectableGeneralTurnCommandKey =>
|
||||||
|
isGeneralTurnCommandKey(value) && !internalGeneralTurnCommandKeySet.has(value);
|
||||||
|
|
||||||
|
export const SELECTABLE_GENERAL_TURN_COMMAND_KEYS = GENERAL_TURN_COMMAND_KEYS.filter(
|
||||||
|
(key): key is SelectableGeneralTurnCommandKey => !internalGeneralTurnCommandKeySet.has(key)
|
||||||
|
);
|
||||||
|
|
||||||
export class GeneralTurnCommandLoader {
|
export class GeneralTurnCommandLoader {
|
||||||
private readonly cache = new Map<GeneralTurnCommandKey, Promise<GeneralTurnCommandModule>>();
|
private readonly cache = new Map<GeneralTurnCommandKey, Promise<GeneralTurnCommandModule>>();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { GameClock, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
|
|||||||
import { readLegacyStoredFloat } from '@sammo-ts/logic/compat/legacyFloat.js';
|
import { readLegacyStoredFloat } from '@sammo-ts/logic/compat/legacyFloat.js';
|
||||||
import {
|
import {
|
||||||
GENERAL_TURN_COMMAND_KEYS,
|
GENERAL_TURN_COMMAND_KEYS,
|
||||||
|
isSelectableGeneralTurnCommandKey,
|
||||||
LogFormat,
|
LogFormat,
|
||||||
NATION_TURN_COMMAND_KEYS,
|
NATION_TURN_COMMAND_KEYS,
|
||||||
normalizeScenarioEffect,
|
normalizeScenarioEffect,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
type MessageRecordDraft,
|
type MessageRecordDraft,
|
||||||
type Nation,
|
type Nation,
|
||||||
type TurnCommandProfile,
|
type TurnCommandProfile,
|
||||||
|
type SelectableGeneralTurnCommandKey,
|
||||||
type UnitSetDefinition,
|
type UnitSetDefinition,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import { InMemoryTurnWorld } from '@sammo-ts/game-engine/turn/inMemoryWorld.js';
|
import { InMemoryTurnWorld } from '@sammo-ts/game-engine/turn/inMemoryWorld.js';
|
||||||
@@ -239,14 +241,14 @@ export const createCoreTurnCommandProfile = (request: TurnCommandFixtureRequest)
|
|||||||
if (!GENERAL_TURN_COMMAND_KEYS.includes(request.action as (typeof GENERAL_TURN_COMMAND_KEYS)[number])) {
|
if (!GENERAL_TURN_COMMAND_KEYS.includes(request.action as (typeof GENERAL_TURN_COMMAND_KEYS)[number])) {
|
||||||
throw new Error(`Unknown general command: ${request.action}`);
|
throw new Error(`Unknown general command: ${request.action}`);
|
||||||
}
|
}
|
||||||
const generalActions = [
|
const generalActions: SelectableGeneralTurnCommandKey[] = [
|
||||||
request.action,
|
...(isSelectableGeneralTurnCommandKey(request.action) ? [request.action] : []),
|
||||||
...configuredGeneralActions,
|
...configuredGeneralActions.filter(isSelectableGeneralTurnCommandKey),
|
||||||
'휴식',
|
'휴식',
|
||||||
'che_인재탐색',
|
'che_인재탐색',
|
||||||
'che_해산',
|
'che_해산',
|
||||||
'che_이동',
|
'che_이동',
|
||||||
] as Array<(typeof GENERAL_TURN_COMMAND_KEYS)[number]>;
|
];
|
||||||
return {
|
return {
|
||||||
general: [...new Set(generalActions)],
|
general: [...new Set(generalActions)],
|
||||||
nation: [...new Set(['휴식', ...configuredNationActions])] as Array<
|
nation: [...new Set(['휴식', ...configuredNationActions])] as Array<
|
||||||
@@ -257,10 +259,12 @@ export const createCoreTurnCommandProfile = (request: TurnCommandFixtureRequest)
|
|||||||
if (!NATION_TURN_COMMAND_KEYS.includes(request.action as (typeof NATION_TURN_COMMAND_KEYS)[number])) {
|
if (!NATION_TURN_COMMAND_KEYS.includes(request.action as (typeof NATION_TURN_COMMAND_KEYS)[number])) {
|
||||||
throw new Error(`Unknown nation command: ${request.action}`);
|
throw new Error(`Unknown nation command: ${request.action}`);
|
||||||
}
|
}
|
||||||
|
const generalActions: SelectableGeneralTurnCommandKey[] = [
|
||||||
|
'휴식',
|
||||||
|
...configuredGeneralActions.filter(isSelectableGeneralTurnCommandKey),
|
||||||
|
];
|
||||||
return {
|
return {
|
||||||
general: [...new Set(['휴식', ...configuredGeneralActions])] as Array<
|
general: [...new Set(generalActions)],
|
||||||
(typeof GENERAL_TURN_COMMAND_KEYS)[number]
|
|
||||||
>,
|
|
||||||
nation: [
|
nation: [
|
||||||
...new Set([
|
...new Set([
|
||||||
request.action as (typeof NATION_TURN_COMMAND_KEYS)[number],
|
request.action as (typeof NATION_TURN_COMMAND_KEYS)[number],
|
||||||
|
|||||||
Reference in New Issue
Block a user