fix: align reserved turn commands with legacy

This commit is contained in:
2026-07-25 14:16:59 +00:00
parent 36d11acdfa
commit 1fef287b6b
23 changed files with 816 additions and 189 deletions
+133 -7
View File
@@ -34,6 +34,7 @@ import {
type ItemModule,
type UniqueLotteryRunner,
} from '@sammo-ts/logic';
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
@@ -58,6 +59,98 @@ import type { AiReservedTurnProvider } from './ai/types.js';
const DEFAULT_ACTION = '휴식';
const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([
'che_소집해제',
'che_랜덤임관',
'che_헌납',
'che_강행',
'che_정착장려',
'che_숙련전환',
'che_주민선정',
'che_모반시도',
'che_요양',
'che_거병',
'che_건국',
'che_증여',
'che_훈련',
'che_견문',
'che_무작위건국',
'che_화계',
'che_집합',
'cr_건국',
'che_이동',
'cr_맹훈련',
'che_인재탐색',
'che_귀환',
'che_사기진작',
'che_군량매매',
'che_기술연구',
'che_첩보',
'che_임관',
'che_상업투자',
'che_장비매매',
'che_장수대상임관',
'che_징병',
'che_단련',
'che_등용',
'che_하야',
'che_물자조달',
'che_선양',
'che_전투태세',
]);
const applyLegacyGeneralProgression = (
general: TurnGeneral,
previousGeneral: TurnGeneral,
actionKey: string,
env: TurnCommandEnv
): TurnGeneral => {
const maxStatLevel = env.maxStatLevel ?? 255;
const maxDedicationLevel = env.maxDedicationLevel ?? 30;
const expLevel = Math.max(
0,
Math.min(
maxStatLevel,
general.experience < 1_000
? Math.trunc(general.experience / 100)
: Math.trunc(Math.sqrt(general.experience / 10))
)
);
const dedicationLevel = Math.max(0, Math.min(maxDedicationLevel, Math.ceil(Math.sqrt(general.dedication) / 10)));
const meta = { ...general.meta };
if (general.experience !== previousGeneral.experience) {
meta.explevel = expLevel;
}
if (general.dedication !== previousGeneral.dedication) {
meta.dedlevel = dedicationLevel;
}
if (!LEGACY_STAT_CHANGE_GENERAL_ACTIONS.has(actionKey)) {
return { ...general, meta };
}
const stats = { ...general.stats };
const limit = env.statUpgradeLimit ?? 30;
const entries = [
['leadership', 'leadership_exp'],
['strength', 'strength_exp'],
['intelligence', 'intel_exp'],
] as const;
for (const [statKey, expKey] of entries) {
const rawExp = typeof meta[expKey] === 'number' ? meta[expKey] : 0;
if (rawExp < 0) {
meta[expKey] = rawExp + limit;
stats[statKey] -= 1;
} else if (rawExp >= limit) {
if (stats[statKey] < maxStatLevel) {
stats[statKey] += 1;
}
meta[expKey] = rawExp - limit;
}
}
return { ...general, stats, meta };
};
const resolveConstraintEnv = (
world: TurnWorldState,
scenarioMeta: ScenarioMeta | undefined,
@@ -571,6 +664,21 @@ class WorldStateView implements StateView {
const extractArgsRecord = (value: unknown): Record<string, unknown> => asRecord(value);
const withCanonicalArgumentAliases = (args: Record<string, unknown>): Record<string, unknown> => {
const normalized = { ...args };
for (const [legacyKey, canonicalKey] of [
['destCityID', 'destCityId'],
['destNationID', 'destNationId'],
['destGeneralID', 'destGeneralId'],
['destTroopID', 'destTroopId'],
] as const) {
if (normalized[canonicalKey] === undefined && normalized[legacyKey] !== undefined) {
normalized[canonicalKey] = normalized[legacyKey];
}
}
return normalized;
};
const buildConstraintContext = (
general: TurnGeneral,
city: City | undefined,
@@ -608,6 +716,7 @@ export const createReservedTurnHandler = async (options: {
unitSet?: UnitSetDefinition;
getWorld: () => InMemoryTurnWorld | null;
commandProfile?: TurnCommandProfile;
commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil;
onActionResolved?: (payload: {
kind: 'nation' | 'general';
generalId: number;
@@ -622,6 +731,9 @@ export const createReservedTurnHandler = async (options: {
const env = buildCommandEnv(options.scenarioConfig, options.unitSet);
const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]));
const uniqueConfig = resolveUniqueConfig(asRecord(options.scenarioConfig.const));
if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
}
const commandProfile = options.commandProfile ?? DEFAULT_TURN_COMMAND_PROFILE;
const { general: generalDefinitions, nation: nationDefinitions } = await buildReservedTurnDefinitions({
env,
@@ -756,14 +868,15 @@ export const createReservedTurnHandler = async (options: {
cities: worldView?.listCities() ?? [],
nations: worldView?.listNations() ?? [],
};
const constraintArgs = withCanonicalArgumentAliases(actionArgs as Record<string, unknown>);
const constraintCtx = buildConstraintContext(
currentGeneral,
currentCity,
currentNation,
actionArgs as Record<string, unknown>,
constraintArgs,
actionConstraintEnv
);
const view = new WorldStateView(worldView, actionConstraintEnv, actionArgs as Record<string, unknown>, {
const view = new WorldStateView(worldView, actionConstraintEnv, constraintArgs, {
general: currentGeneral,
city: currentCity,
nation: currentNation,
@@ -807,6 +920,13 @@ export const createReservedTurnHandler = async (options: {
currentGeneral.id,
key
);
if (options.commandRngFactory) {
return options.commandRngFactory({
kind,
actionKey: key,
seed: rngSeed,
});
}
return new RandUtil(new LiteHashDRBG(rngSeed));
};
@@ -927,6 +1047,7 @@ export const createReservedTurnHandler = async (options: {
}
const lastTurnBeforeExecution = JSON.stringify(currentGeneral.lastTurn ?? {});
const generalBeforeExecution = currentGeneral;
const resolution = resolveGeneralAction(
definition,
actionContext,
@@ -940,6 +1061,14 @@ export const createReservedTurnHandler = async (options: {
currentGeneral = resolution.general as TurnGeneral;
currentCity = resolution.city ?? currentCity;
currentNation = resolution.nation ?? currentNation;
if (!resolution.alternative && !usedFallback) {
currentGeneral = applyLegacyGeneralProgression(
currentGeneral,
generalBeforeExecution,
actionKey,
env
);
}
if (
!resolution.alternative &&
kind === 'nation' &&
@@ -1091,11 +1220,10 @@ export const createReservedTurnHandler = async (options: {
}
}
const hasDiplomacyChange = diplomacyPatches.length > 0;
const hasNationChange = (resolution.patches?.cities ?? []).some((patch) =>
Object.prototype.hasOwnProperty.call(patch.patch ?? {}, 'nationId')
);
if (hasDiplomacyChange || hasNationChange) {
if (hasNationChange) {
const worldView = worldOverlay?.view ?? worldRef;
if (worldView && options.map) {
const frontPatches = buildFrontStatePatches({
@@ -1533,9 +1661,7 @@ export const createReservedTurnHandler = async (options: {
if (!deleteGeneral && currentGeneral.age >= retirementYear && currentGeneral.npcState === 0) {
currentGeneral = resetRetiredGeneral(currentGeneral);
lifecycleOutcome = 'retired';
logs.push(
createActionLog('나이가 들어 <R>은퇴</>하고 자손에게 자리를 물려줍니다.')
);
logs.push(createActionLog('나이가 들어 <R>은퇴</>하고 자손에게 자리를 물려줍니다.'));
}
currentGeneral = {