merge: add general alternative command parity

This commit is contained in:
2026-07-26 04:32:16 +00:00
6 changed files with 154 additions and 39 deletions
@@ -857,7 +857,8 @@ export const createReservedTurnHandler = async (options: {
fallbackDefinition: GeneralActionDefinition,
command: ReservedTurnEntry,
applyNextTurnAt: boolean,
alternativeDepth = 0
alternativeDepth = 0,
sharedActionRng?: RandUtil
): { nextTurnAt?: Date; actionKey: string; usedFallback: boolean; blockedReason?: string } => {
const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition);
const rawArgs = extractArgsRecord(command.args);
@@ -954,11 +955,12 @@ export const createReservedTurnHandler = async (options: {
uniqueConfig,
getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys,
});
let actionRng = sharedActionRng ?? buildRng(actionKey);
let baseContext: ActionContextBase = {
general: currentGeneral,
city: currentCity,
nation: currentNation,
rng: buildRng(actionKey),
rng: actionRng,
uniqueLottery,
};
let specificContext = buildActionContext(
@@ -985,11 +987,12 @@ export const createReservedTurnHandler = async (options: {
usedFallback = true;
blockedReason = '예약된 명령을 실행하지 못했습니다.';
logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.'));
actionRng = sharedActionRng ?? buildRng(actionKey);
baseContext = {
general: currentGeneral,
city: currentCity,
nation: currentNation,
rng: buildRng(actionKey),
rng: actionRng,
};
specificContext = baseContext;
}
@@ -1287,7 +1290,8 @@ export const createReservedTurnHandler = async (options: {
args: extractArgsRecord(resolution.alternative.args),
},
applyNextTurnAt,
alternativeDepth + 1
alternativeDepth + 1,
actionRng
);
}
@@ -110,6 +110,31 @@ export const resolveStartYear = (world: ActionContextWorldState, scenarioMeta?:
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 =>
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 { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.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 { parseArgsWithSchema } from '../parseArgs.js';
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) => {
const nationId = base.nation?.id ?? base.general.nationId;
const currentYear = options.world.currentYear;
const currentMonth = options.world.currentMonth;
const startYear = resolveStartYear(currentYear, options.scenarioMeta?.startYear);
const initYear = startYear;
const initMonth = 1;
const init = resolveInitYearMonth(options.world, options.scenarioMeta);
return {
...base,
allCities: options.worldRef?.listCities() ?? [],
nationGenerals: options.worldRef?.listGenerals().filter((general) => general.nationId === nationId) ?? [],
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 { JosaUtil } from '@sammo-ts/common';
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 { 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) => {
const nationId = base.nation?.id ?? base.general.nationId;
const worldRef = options.worldRef;
const currentYear = options.world.currentYear;
const currentMonth = options.world.currentMonth;
const initYear = resolveStartYear(currentYear, options.scenarioMeta?.startYear);
const initMonth = 1;
const init = resolveInitYearMonth(options.world, options.scenarioMeta);
return {
...base,
nationGenerals: worldRef?.listGenerals().filter((general) => general.nationId === nationId) ?? [],
nationCities: worldRef?.listCities().filter((city) => city.nationId === nationId) ?? [],
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])) {
throw new Error(`Unknown general command: ${request.action}`);
}
const generalActions = [request.action, '휴식', 'che_인재탐색', 'che_해산', 'che_이동'] as Array<
(typeof GENERAL_TURN_COMMAND_KEYS)[number]
>;
return {
general: [request.action as (typeof GENERAL_TURN_COMMAND_KEYS)[number], '휴식'],
general: [...new Set(generalActions)],
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 = {
name: string;
action: string;