fix(turn): preserve legacy alternative command execution
This commit is contained in:
@@ -849,7 +849,8 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
fallbackDefinition: GeneralActionDefinition,
|
fallbackDefinition: GeneralActionDefinition,
|
||||||
command: ReservedTurnEntry,
|
command: ReservedTurnEntry,
|
||||||
applyNextTurnAt: boolean,
|
applyNextTurnAt: boolean,
|
||||||
alternativeDepth = 0
|
alternativeDepth = 0,
|
||||||
|
sharedActionRng?: RandUtil
|
||||||
): { nextTurnAt?: Date; actionKey: string; usedFallback: boolean; blockedReason?: string } => {
|
): { nextTurnAt?: Date; actionKey: string; usedFallback: boolean; blockedReason?: string } => {
|
||||||
const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition);
|
const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition);
|
||||||
const rawArgs = extractArgsRecord(command.args);
|
const rawArgs = extractArgsRecord(command.args);
|
||||||
@@ -945,11 +946,12 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
itemRegistry,
|
itemRegistry,
|
||||||
uniqueConfig,
|
uniqueConfig,
|
||||||
});
|
});
|
||||||
|
let actionRng = sharedActionRng ?? buildRng(actionKey);
|
||||||
let baseContext: ActionContextBase = {
|
let baseContext: ActionContextBase = {
|
||||||
general: currentGeneral,
|
general: currentGeneral,
|
||||||
city: currentCity,
|
city: currentCity,
|
||||||
nation: currentNation,
|
nation: currentNation,
|
||||||
rng: buildRng(actionKey),
|
rng: actionRng,
|
||||||
uniqueLottery,
|
uniqueLottery,
|
||||||
};
|
};
|
||||||
let specificContext = buildActionContext(
|
let specificContext = buildActionContext(
|
||||||
@@ -976,11 +978,12 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
usedFallback = true;
|
usedFallback = true;
|
||||||
blockedReason = '예약된 명령을 실행하지 못했습니다.';
|
blockedReason = '예약된 명령을 실행하지 못했습니다.';
|
||||||
logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.'));
|
logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.'));
|
||||||
|
actionRng = sharedActionRng ?? buildRng(actionKey);
|
||||||
baseContext = {
|
baseContext = {
|
||||||
general: currentGeneral,
|
general: currentGeneral,
|
||||||
city: currentCity,
|
city: currentCity,
|
||||||
nation: currentNation,
|
nation: currentNation,
|
||||||
rng: buildRng(actionKey),
|
rng: actionRng,
|
||||||
};
|
};
|
||||||
specificContext = baseContext;
|
specificContext = baseContext;
|
||||||
}
|
}
|
||||||
@@ -1278,7 +1281,8 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
args: extractArgsRecord(resolution.alternative.args),
|
args: extractArgsRecord(resolution.alternative.args),
|
||||||
},
|
},
|
||||||
applyNextTurnAt,
|
applyNextTurnAt,
|
||||||
alternativeDepth + 1
|
alternativeDepth + 1,
|
||||||
|
actionRng
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,31 @@ export const resolveStartYear = (world: ActionContextWorldState, scenarioMeta?:
|
|||||||
return world.currentYear;
|
return world.currentYear;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readFiniteInteger = (value: unknown, fallback: number): number => {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return Math.floor(value);
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return Math.floor(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveInitYearMonth = (
|
||||||
|
world: ActionContextWorldState,
|
||||||
|
scenarioMeta?: ScenarioMeta
|
||||||
|
): { year: number; month: number } => {
|
||||||
|
const meta = asRecord(world.meta);
|
||||||
|
const startYear = resolveStartYear(world, scenarioMeta);
|
||||||
|
return {
|
||||||
|
year: readFiniteInteger(meta.initYear, startYear),
|
||||||
|
month: readFiniteInteger(meta.initMonth, 1),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const resolveTurnTermMinutes = (world: ActionContextWorldState): number =>
|
export const resolveTurnTermMinutes = (world: ActionContextWorldState): number =>
|
||||||
Math.max(1, Math.round(world.tickSeconds / 60));
|
Math.max(1, Math.round(world.tickSeconds / 60));
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { z } from 'zod';
|
|||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
|
import { resolveInitYearMonth } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
@@ -223,34 +224,18 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveStartYear = (currentYear: number, raw: unknown): number => {
|
|
||||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
|
||||||
return Math.floor(raw);
|
|
||||||
}
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
const parsed = Number(raw);
|
|
||||||
if (Number.isFinite(parsed)) {
|
|
||||||
return Math.floor(parsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return currentYear;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||||
const nationId = base.nation?.id ?? base.general.nationId;
|
const nationId = base.nation?.id ?? base.general.nationId;
|
||||||
const currentYear = options.world.currentYear;
|
const currentYear = options.world.currentYear;
|
||||||
const currentMonth = options.world.currentMonth;
|
const currentMonth = options.world.currentMonth;
|
||||||
|
const init = resolveInitYearMonth(options.world, options.scenarioMeta);
|
||||||
const startYear = resolveStartYear(currentYear, options.scenarioMeta?.startYear);
|
|
||||||
const initYear = startYear;
|
|
||||||
const initMonth = 1;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
allCities: options.worldRef?.listCities() ?? [],
|
allCities: options.worldRef?.listCities() ?? [],
|
||||||
nationGenerals: options.worldRef?.listGenerals().filter((general) => general.nationId === nationId) ?? [],
|
nationGenerals: options.worldRef?.listGenerals().filter((general) => general.nationId === nationId) ?? [],
|
||||||
currentYearMonth: currentYear * 12 + currentMonth - 1,
|
currentYearMonth: currentYear * 12 + currentMonth - 1,
|
||||||
initYearMonth: initYear * 12 + initMonth - 1,
|
initYearMonth: init.year * 12 + init.month - 1,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
|
import { resolveInitYearMonth } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
|
||||||
@@ -145,33 +146,19 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveStartYear = (currentYear: number, raw: unknown): number => {
|
|
||||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
|
||||||
return Math.floor(raw);
|
|
||||||
}
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
const parsed = Number(raw);
|
|
||||||
if (Number.isFinite(parsed)) {
|
|
||||||
return Math.floor(parsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return currentYear;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||||
const nationId = base.nation?.id ?? base.general.nationId;
|
const nationId = base.nation?.id ?? base.general.nationId;
|
||||||
const worldRef = options.worldRef;
|
const worldRef = options.worldRef;
|
||||||
const currentYear = options.world.currentYear;
|
const currentYear = options.world.currentYear;
|
||||||
const currentMonth = options.world.currentMonth;
|
const currentMonth = options.world.currentMonth;
|
||||||
const initYear = resolveStartYear(currentYear, options.scenarioMeta?.startYear);
|
const init = resolveInitYearMonth(options.world, options.scenarioMeta);
|
||||||
const initMonth = 1;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
nationGenerals: worldRef?.listGenerals().filter((general) => general.nationId === nationId) ?? [],
|
nationGenerals: worldRef?.listGenerals().filter((general) => general.nationId === nationId) ?? [],
|
||||||
nationCities: worldRef?.listCities().filter((city) => city.nationId === nationId) ?? [],
|
nationCities: worldRef?.listCities().filter((city) => city.nationId === nationId) ?? [],
|
||||||
currentYearMonth: currentYear * 12 + currentMonth - 1,
|
currentYearMonth: currentYear * 12 + currentMonth - 1,
|
||||||
initYearMonth: initYear * 12 + initMonth - 1,
|
initYearMonth: init.year * 12 + init.month - 1,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -160,8 +160,11 @@ const createCommandProfile = (request: TurnCommandFixtureRequest): TurnCommandPr
|
|||||||
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 = [request.action, '휴식', 'che_인재탐색', 'che_해산', 'che_이동'] as Array<
|
||||||
|
(typeof GENERAL_TURN_COMMAND_KEYS)[number]
|
||||||
|
>;
|
||||||
return {
|
return {
|
||||||
general: [request.action as (typeof GENERAL_TURN_COMMAND_KEYS)[number], '휴식'],
|
general: [...new Set(generalActions)],
|
||||||
nation: ['휴식'],
|
nation: ['휴식'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -717,6 +717,117 @@ integration('general sabotage injury boundary matrix', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
integration('general command alternative matrix', () => {
|
||||||
|
const expectAlternativeParity = async (
|
||||||
|
request: TurnCommandFixtureRequest,
|
||||||
|
expectedActionKey: string,
|
||||||
|
expectedLogText: string
|
||||||
|
): Promise<void> => {
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
|
||||||
|
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
||||||
|
expect(core.execution.outcome).toMatchObject({
|
||||||
|
requestedAction: request.action,
|
||||||
|
actionKey: expectedActionKey,
|
||||||
|
usedFallback: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
reference.after.logs.some((entry) => typeof entry.text === 'string' && entry.text.includes(expectedLogText))
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
core.after.logs.some((entry) => typeof entry.text === 'string' && entry.text.includes(expectedLogText))
|
||||||
|
).toBe(true);
|
||||||
|
expect(core.rng).toEqual(reference.rng);
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||||
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('che_해산 continues into che_인재탐색 with the legacy RNG and state delta', async () => {
|
||||||
|
const request = buildRequest(
|
||||||
|
'che_해산',
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
world: { initYear: 190, initMonth: 1 },
|
||||||
|
nations: { 1: { level: 0, capitalCityId: 0, typeCode: 'None' } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.setup!.world!.hiddenSeed = 'general-alternative-disband-search';
|
||||||
|
await expectAlternativeParity(request, 'che_인재탐색', '다음 턴부터 해산할 수 있습니다.');
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
it('che_랜덤임관 continues into che_인재탐색 when no eligible nation exists', async () => {
|
||||||
|
const request = buildRequest(
|
||||||
|
'che_랜덤임관',
|
||||||
|
undefined,
|
||||||
|
{ nationId: 0, officerLevel: 0 },
|
||||||
|
{
|
||||||
|
generals: {
|
||||||
|
2: { npcState: 5 },
|
||||||
|
3: { npcState: 5 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.setup!.world!.hiddenSeed = 'general-alternative-random-appointment-search';
|
||||||
|
await expectAlternativeParity(request, 'che_인재탐색', '임관 가능한 국가가 없습니다.');
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
it('che_무작위건국 continues into che_인재탐색 during the initial month', async () => {
|
||||||
|
const request = buildRequest(
|
||||||
|
'che_무작위건국',
|
||||||
|
{ nationName: '신국', nationType: 'che_도적', colorType: 1 },
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
world: { startYear: 190, initYear: 190, initMonth: 1 },
|
||||||
|
nations: { 1: { level: 0, capitalCityId: 0, typeCode: 'None' } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.setup!.world!.hiddenSeed = 'general-alternative-random-founding-search';
|
||||||
|
await expectAlternativeParity(request, 'che_인재탐색', '다음 턴부터 건국할 수 있습니다.');
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
it('che_무작위건국 continues into che_해산 when no founding city exists', async () => {
|
||||||
|
const request = buildRequest(
|
||||||
|
'che_무작위건국',
|
||||||
|
{ nationName: '신국', nationType: 'che_도적', colorType: 1 },
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
world: { initYear: 180, initMonth: 1, year: 181 },
|
||||||
|
nations: { 1: { level: 0, capitalCityId: 0, typeCode: 'None' } },
|
||||||
|
cities: {
|
||||||
|
3: { nationId: 1, level: 4 },
|
||||||
|
70: { nationId: 0, level: 4 },
|
||||||
|
},
|
||||||
|
randomFoundingCandidateCityIds: [3],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.setup!.world!.hiddenSeed = 'general-alternative-random-founding-disband';
|
||||||
|
await expectAlternativeParity(request, 'che_해산', '건국할 수 있는 도시가 없습니다.');
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
it('che_출병 continues into che_이동 when the destination belongs to the actor nation', async () => {
|
||||||
|
const request = buildRequest(
|
||||||
|
'che_출병',
|
||||||
|
{ destCityID: 70 },
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
world: { startYear: 180, year: 185 },
|
||||||
|
cities: { 70: { nationId: 1, supplyState: 1, frontState: 0 } },
|
||||||
|
generals: { 2: { nationId: 1 } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.setup!.world!.hiddenSeed = 'general-alternative-sortie-move';
|
||||||
|
await expectAlternativeParity(request, 'che_이동', '본국입니다.');
|
||||||
|
}, 120_000);
|
||||||
|
});
|
||||||
|
|
||||||
type GeneralConstraintCase = {
|
type GeneralConstraintCase = {
|
||||||
name: string;
|
name: string;
|
||||||
action: string;
|
action: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user