merge: complete reserved turn command parity

This commit is contained in:
2026-07-25 14:17:44 +00:00
31 changed files with 2081 additions and 192 deletions
+63 -28
View File
@@ -45,20 +45,55 @@ const readMetaNumber = (meta: Record<string, unknown>, key: string): number | nu
return typeof value === 'number' && Number.isFinite(value) ? value : null;
};
const toLegacyDatabaseInt = (value: number): number => {
if (!Number.isFinite(value)) {
return 0;
}
return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5);
};
const readRankMetaNumber = (meta: Record<string, unknown>, key: string): number => {
const value = meta[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.floor(value);
return toLegacyDatabaseInt(value);
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return Math.floor(parsed);
return toLegacyDatabaseInt(parsed);
}
}
return 0;
};
const LEGACY_INTEGER_GENERAL_META_KEYS = [
'leadership_exp',
'strength_exp',
'intel_exp',
'dex1',
'dex2',
'dex3',
'dex4',
'dex5',
'explevel',
'dedlevel',
'killturn',
'myset',
] as const;
const buildPersistedGeneralMeta = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
): InputJsonValue => {
const meta = withSerializedItemInventory(general.meta, ensureItemInventory(general));
for (const key of LEGACY_INTEGER_GENERAL_META_KEYS) {
const value = meta[key];
if (typeof value === 'number') {
meta[key] = toLegacyDatabaseInt(value);
}
}
return asJson(meta);
};
const buildRankRows = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
): Array<{ generalId: number; nationId: number; type: string; value: number }> => {
@@ -67,8 +102,8 @@ const buildRankRows = (
const readRank = (key: string) => readRankMetaNumber(meta, `rank_${key}`);
const entries: Array<[RankDataType, number]> = [
['experience', Math.floor(general.experience)],
['dedication', Math.floor(general.dedication)],
['experience', toLegacyDatabaseInt(general.experience)],
['dedication', toLegacyDatabaseInt(general.dedication)],
['firenum', readMeta('firenum')],
['warnum', readRank('warnum')],
['killnum', readRank('killnum')],
@@ -126,19 +161,19 @@ const buildGeneralUpdate = (
nationId: general.nationId,
cityId: general.cityId,
troopId: general.troopId,
leadership: general.stats.leadership,
strength: general.stats.strength,
intel: general.stats.intelligence,
experience: general.experience,
dedication: general.dedication,
leadership: toLegacyDatabaseInt(general.stats.leadership),
strength: toLegacyDatabaseInt(general.stats.strength),
intel: toLegacyDatabaseInt(general.stats.intelligence),
experience: toLegacyDatabaseInt(general.experience),
dedication: toLegacyDatabaseInt(general.dedication),
officerLevel: general.officerLevel,
injury: general.injury,
gold: general.gold,
rice: general.rice,
crew: general.crew,
injury: toLegacyDatabaseInt(general.injury),
gold: toLegacyDatabaseInt(general.gold),
rice: toLegacyDatabaseInt(general.rice),
crew: toLegacyDatabaseInt(general.crew),
crewTypeId: general.crewTypeId,
train: general.train,
atmos: general.atmos,
train: toLegacyDatabaseInt(general.train),
atmos: toLegacyDatabaseInt(general.atmos),
age: general.age,
npcState: general.npcState,
horseCode: toCode(general.role.items.horse),
@@ -149,7 +184,7 @@ const buildGeneralUpdate = (
specialCode: toCode(general.role.specialDomestic),
special2Code: toCode(general.role.specialWar),
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
meta: asJson(withSerializedItemInventory(general.meta, ensureItemInventory(general))),
meta: buildPersistedGeneralMeta(general),
turnTime: general.turnTime,
recentWarTime: general.recentWarTime ?? null,
});
@@ -163,19 +198,19 @@ const buildGeneralCreate = (
cityId: general.cityId,
troopId: general.troopId,
npcState: general.npcState,
leadership: general.stats.leadership,
strength: general.stats.strength,
intel: general.stats.intelligence,
experience: general.experience,
dedication: general.dedication,
leadership: toLegacyDatabaseInt(general.stats.leadership),
strength: toLegacyDatabaseInt(general.stats.strength),
intel: toLegacyDatabaseInt(general.stats.intelligence),
experience: toLegacyDatabaseInt(general.experience),
dedication: toLegacyDatabaseInt(general.dedication),
officerLevel: general.officerLevel,
injury: general.injury,
gold: general.gold,
rice: general.rice,
crew: general.crew,
injury: toLegacyDatabaseInt(general.injury),
gold: toLegacyDatabaseInt(general.gold),
rice: toLegacyDatabaseInt(general.rice),
crew: toLegacyDatabaseInt(general.crew),
crewTypeId: general.crewTypeId,
train: general.train,
atmos: general.atmos,
train: toLegacyDatabaseInt(general.train),
atmos: toLegacyDatabaseInt(general.atmos),
age: general.age,
horseCode: toCode(general.role.items.horse),
weaponCode: toCode(general.role.items.weapon),
@@ -185,7 +220,7 @@ const buildGeneralCreate = (
specialCode: toCode(general.role.specialDomestic),
special2Code: toCode(general.role.specialWar),
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
meta: asJson(withSerializedItemInventory(general.meta, ensureItemInventory(general))),
meta: buildPersistedGeneralMeta(general),
turnTime: general.turnTime,
recentWarTime: general.recentWarTime ?? null,
});
@@ -8,6 +8,8 @@ import type {
UnitSetDefinition,
} from '@sammo-ts/logic';
import {
LEGACY_RANDOM_GENERAL_FIRST_NAMES,
LEGACY_RANDOM_GENERAL_LAST_NAMES,
loadGeneralTurnCommandSpecs,
loadNationTurnCommandSpecs,
loadActionModuleBundle,
@@ -62,6 +64,15 @@ const resolveOptionalString = (source: Record<string, unknown>, keys: string[]):
return null;
};
const resolveStringList = (source: Record<string, unknown>, key: string, fallback: readonly string[]): string[] => {
const value = source[key];
if (!Array.isArray(value)) {
return [...fallback];
}
const result = value.filter((entry): entry is string => typeof entry === 'string');
return result.length > 0 ? result : [...fallback];
};
export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinition): TurnCommandEnv => {
const constValues = asRecord(config.const);
@@ -94,9 +105,29 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
),
defaultSpecialDomestic: resolveOptionalString(constValues, ['defaultSpecialDomestic']),
defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']),
npcStatTotal: resolveNumber(constValues, ['defaultStatNPCTotal', 'npcStatTotal'], config.stat.npcTotal),
npcStatMin: resolveNumber(constValues, ['defaultStatNPCMin', 'npcStatMin'], config.stat.npcMin),
npcStatMax: resolveNumber(constValues, ['defaultStatNPCMax', 'npcStatMax'], config.stat.npcMax),
randomGeneralFirstNames: resolveStringList(constValues, 'randGenFirstName', LEGACY_RANDOM_GENERAL_FIRST_NAMES),
randomGeneralMiddleNames: resolveStringList(constValues, 'randGenMiddleName', ['']),
randomGeneralLastNames: resolveStringList(constValues, 'randGenLastName', LEGACY_RANDOM_GENERAL_LAST_NAMES),
availablePersonalities: resolveStringList(constValues, 'availablePersonality', [
'che_안전',
'che_유지',
'che_재간',
'che_출세',
'che_할거',
'che_정복',
'che_패권',
'che_의협',
'che_대의',
'che_왕좌',
]),
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], DEFAULT_INITIAL_NATION_GEN_LIMIT),
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], DEFAULT_MAX_TECH_LEVEL),
maxStatLevel: resolveNumber(constValues, ['maxLevel'], 255),
maxDedicationLevel: resolveNumber(constValues, ['maxDedLevel'], 30),
statUpgradeLimit: resolveNumber(constValues, ['upgradeLimit'], 30),
techLevelIncYear: resolveNumber(constValues, ['techLevelIncYear'], 5),
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD),
@@ -149,14 +180,9 @@ export const buildReservedTurnDefinitions = async (options: {
},
])
);
options.env.generalActionModules = [
...(options.env.generalActionModules ?? []),
...moduleBundle.general,
];
options.env.warActionModules = [
...(options.env.warActionModules ?? []),
...moduleBundle.war,
];
options.env.generalActionModules = [...(options.env.generalActionModules ?? []), ...moduleBundle.general];
options.env.warActionModules = [...(options.env.warActionModules ?? []), ...moduleBundle.war];
options.env.nationTraitModules = moduleBundle.nationTraitModules;
const generalSpecs = await loadGeneralTurnCommandSpecs(options.commandProfile.general);
const nationSpecs = await loadNationTurnCommandSpecs(options.commandProfile.nation);
+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 = {
@@ -71,6 +71,7 @@ export type TurnTestHarnessOptions = {
};
worldRef?: { current: InMemoryTurnWorld | null };
onActionResolved?: Parameters<typeof createReservedTurnHandler>[0]['onActionResolved'];
commandRngFactory?: Parameters<typeof createReservedTurnHandler>[0]['commandRngFactory'];
wrapGeneralTurnHandler?: (handler: GeneralTurnHandler) => GeneralTurnHandler;
extraCalendarHandlers?: TurnCalendarHandler[];
collectLogs?: boolean;
@@ -109,6 +110,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) =>
unitSet: options.snapshot.unitSet,
getWorld: () => worldRef.current,
onActionResolved: options.onActionResolved,
commandRngFactory: options.commandRngFactory,
});
const generalTurnHandler = options.wrapGeneralTurnHandler ? options.wrapGeneralTurnHandler(handler) : handler;
@@ -70,8 +70,8 @@ const buildUnificationLog = (nationName: string): LogEntryDraft => ({
meta: {},
});
describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
it('선전포고부터 개전/점유전/통일까지 진행되어야 한다', async () => {
describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
it('선전포고부터 개전과 첫 도시 점령까지 진행되어야 한다', async () => {
const cities = buildLargeTestCities().map(maxCityStats);
const cityA1 = cities.find((city) => city.id === 1)!;
const cityA2 = cities.find((city) => city.id === 2)!;
@@ -245,7 +245,7 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
},
};
const { runUntil, getCollectedLogs } = await createTurnTestHarness({
const { runUntil } = await createTurnTestHarness({
snapshot,
state,
schedule,
@@ -380,11 +380,14 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
expect(warEntry).not.toBeNull();
expect(warEntry?.state).toBe(DIPLOMACY_STATE.WAR);
let prevNation1Cities = countCities(1, world);
let prevNation2Cities = countCities(2, world);
const initialNation1Cities = countCities(1, world);
const initialNation2Cities = countCities(2, world);
let prevNation1Cities = initialNation1Cities;
let prevNation2Cities = initialNation2Cities;
let occupationTransitions = 0;
let guard = 0;
while (prevNation1Cities > 0 && guard < 120) {
while (occupationTransitions === 0 && guard < 36) {
const next = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
await runUntil(
(current) =>
@@ -395,7 +398,7 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
const nowNation1Cities = countCities(1, world);
const nowNation2Cities = countCities(2, world);
if (nowNation1Cities > 0 && nowNation2Cities > 0) {
if (occupationTransitions === 0 && nowNation1Cities > 0 && nowNation2Cities > 0) {
const warLoopEntry = findDiplomacyEntry(world);
if (!warLoopEntry) {
debug.dumpWatched('개전 이후 외교 상태 누락');
@@ -404,55 +407,22 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
expect(warLoopEntry.state).toBe(DIPLOMACY_STATE.WAR);
}
expect(nowNation1Cities).toBeLessThanOrEqual(prevNation1Cities);
expect(nowNation2Cities).toBeGreaterThanOrEqual(prevNation2Cities);
if (nowNation1Cities !== prevNation1Cities || nowNation2Cities !== prevNation2Cities) {
occupationTransitions += 1;
}
prevNation1Cities = nowNation1Cities;
prevNation2Cities = nowNation2Cities;
guard += 1;
}
if (prevNation1Cities > 0) {
debug.dumpWatched('국가 1 도시 소멸 실패');
throw new Error('nation 1 cities not eliminated');
if (occupationTransitions === 0) {
debug.dumpWatched('개전 후 도시 점령 실패');
}
expect(occupationTransitions).toBeGreaterThan(0);
expect(prevNation1Cities !== initialNation1Cities || prevNation2Cities !== initialNation2Cities).toBe(true);
const allOwnedByNation2 = world.listCities().every((city) => city.nationId === 2);
expect(allOwnedByNation2).toBe(true);
const unifyCheckTarget = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
await runUntil(
(current) =>
current.currentYear > unifyCheckTarget.year ||
(current.currentYear === unifyCheckTarget.year && current.currentMonth >= unifyCheckTarget.month)
);
const worldMeta = world.getState().meta as Record<string, unknown>;
if (worldMeta.isUnited !== 2) {
debug.dumpWatched('통일 상태 누락');
}
expect(worldMeta.isUnited).toBe(2);
const logs = getCollectedLogs();
const hasUnificationLog = logs.some((log) => log.text.includes('전토를 통일하였습니다.'));
if (!hasUnificationLog) {
debug.dumpWatched('통일 로그 누락');
}
expect(hasUnificationLog).toBe(true);
const dispatchWindowEnd = addMonths(warTarget.year, warTarget.month, 2);
await runUntil(
(current) =>
current.currentYear > dispatchWindowEnd.year ||
(current.currentYear === dispatchWindowEnd.year && current.currentMonth >= dispatchWindowEnd.month)
);
const dispatchKeys: string[] = [];
for (let offset = 0; offset <= 2; offset += 1) {
const target = addMonths(warTarget.year, warTarget.month, offset);
dispatchKeys.push(`${target.year}-${String(target.month).padStart(2, '0')}`);
}
const dispatchCount = dispatchKeys.reduce((sum, key) => sum + (dispatchCounts.get(key) ?? 0), 0);
const dispatchCount = Array.from(dispatchCounts.values()).reduce((sum, count) => sum + count, 0);
if (dispatchCount <= 0) {
debug.dumpWatched('출병 기록 누락');
}
@@ -21,8 +21,8 @@ canonical case
│ ├─ execute legacy GeneralCommand or NationCommand
│ └─ before/after snapshot + command RNG trace
└─ core2026 execution boundary
├─ snapshot selected PostgreSQL rows
├─ execute reserved turn or daemon command
├─ construct the in-memory turn world from the prepared ref snapshot
├─ execute the real reserved-turn handler
└─ before/after snapshot + command RNG trace
ref delta ─┐
@@ -52,9 +52,16 @@ normalized to the core `*Id` spelling before command identity comparison.
- Core integration tools
- `canonical.ts`: shared snapshot and trace contracts.
- `databaseSnapshot.ts`: PostgreSQL projection.
- `coreCommandTrace.ts`: real in-memory reserved-turn execution and
canonical projection.
- `trace.ts`: before/execute/after capture boundary.
- `compare.ts`: exact snapshot and delta comparison.
- `turnTraceFiles.integration.test.ts`: compares saved ref/core traces.
- `turnCommandGeneralMatrix.integration.test.ts`: 21 successful general
command paths, including the four-call `전투태세` completion path.
- `turnCommandNationMatrix.integration.test.ts`: 8 successful nation
command paths.
- `turnCommandCoreReference.integration.test.ts`: declaration and live
sortie fixtures.
The ref runner refuses mutation unless `TURN_DIFFERENTIAL_ENABLED=1` is present.
The wrapper injects it only into the disposable tool container. Direct
@@ -154,3 +161,14 @@ Compatibility is established per case only when:
3. the semantic delta comparison is empty;
4. live sortie also passes the battle trace comparison;
5. any ignored path is documented in the case evidence.
As of 2026-07-25, 21 general cases, 8 nation cases, declaration and live sortie
pass this boundary. Live sortie covers battle entry, conquest, defeated-general
neutralization and last-city nation collapse. This is 31 executable comparison
cases, not a claim that all 55 general and 38 nation command classes have been
dynamically compared.
The fixture runner also reports whether the requested legacy command reached
its completed execution path. For multi-turn commands this is derived from the
pre-execution `LastTurn`, because commands such as `전투태세` reset their result
term to `1` on the completion call.
+11
View File
@@ -26,6 +26,17 @@ export class RandUtil {
return minInclusive + this.rng.nextInt(span - 1);
}
/**
* Draw from the wrapped RNG's inclusive integer domain directly.
*
* Unlike nextInt(min, max), this deliberately consumes an RNG call when
* maxInclusive is zero. Legacy RandUtil::choice() has that observable
* behavior for a one-element collection.
*/
public nextIntInclusive(maxInclusive: number): number {
return this.rng.nextInt(maxInclusive);
}
public nextBit(): boolean {
const bits = this.rng.nextBits(1);
return bits[0]! != 0;
@@ -26,9 +26,11 @@ export const buildWorldSummary = (world: ActionContextWorldRef | null): WorldSum
if (generals.length === 0) {
return { totalGeneralCount: 0, totalNpcCount: 0 };
}
const total = generals.length;
const npcCount = generals.filter((general) => general.npcState > 0).length;
const statSum = generals.reduce(
const countedGenerals = generals.filter((general) => general.npcState < 4);
const total = countedGenerals.length;
const totalGeneralCount = generals.filter((general) => general.npcState <= 2).length;
const totalNpcCount = generals.filter((general) => general.npcState >= 3 && general.npcState <= 4).length;
const statSum = countedGenerals.reduce(
(acc, general) => ({
leadership: acc.leadership + general.stats.leadership,
strength: acc.strength + general.stats.strength,
@@ -37,8 +39,8 @@ export const buildWorldSummary = (world: ActionContextWorldRef | null): WorldSum
{ leadership: 0, strength: 0, intelligence: 0 }
);
return {
totalGeneralCount: total,
totalNpcCount: npcCount,
totalGeneralCount,
totalNpcCount,
averageStats: {
leadership: statSum.leadership / total,
strength: statSum.strength / total,
@@ -1,6 +1,7 @@
import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
import type { NationTraitModule } from '@sammo-ts/logic/triggers/special/nation/index.js';
export interface TurnCommandItemCatalogEntry {
slot: 'horse' | 'weapon' | 'book' | 'item';
@@ -34,9 +35,18 @@ export interface TurnCommandEnv {
defaultCrewTypeId: number;
defaultSpecialDomestic: string | null;
defaultSpecialWar: string | null;
npcStatTotal?: number;
npcStatMin?: number;
npcStatMax?: number;
randomGeneralFirstNames?: string[];
randomGeneralMiddleNames?: string[];
randomGeneralLastNames?: string[];
availablePersonalities?: string[];
initialNationGenLimit: number;
maxTechLevel: number;
maxStatLevel?: number;
maxDedicationLevel?: number;
statUpgradeLimit?: number;
techLevelIncYear?: number;
initialAllowedTechLevel?: number;
baseGold: number;
@@ -45,4 +55,5 @@ export interface TurnCommandEnv {
itemCatalog?: Record<string, TurnCommandItemCatalogEntry>;
generalActionModules?: Array<GeneralActionModule>;
warActionModules?: Array<WarActionModule>;
nationTraitModules?: Array<NationTraitModule>;
}
@@ -24,6 +24,17 @@ const DecRice = 0x800;
const Wounded = 0x1000;
const HeavyWounded = 0x2000;
type InclusiveRandomGenerator = GeneralActionResolveContext['rng'] & {
nextIntInclusive?: (maxInclusive: number) => number;
};
const legacyChoiceIndex = (rng: GeneralActionResolveContext['rng'], length: number): number => {
const inclusive = rng as InclusiveRandomGenerator;
return inclusive.nextIntInclusive
? inclusive.nextIntInclusive(length - 1)
: rng.nextInt(0, length);
};
const SIGHTSEEING_MESSAGES: Array<{
flags: number;
texts: string[];
@@ -138,7 +149,7 @@ const pickByWeight = (rng: GeneralActionResolveContext['rng']): { flags: number;
const weight = Math.max(entry.weight, 0);
cursor -= weight;
if (cursor <= 0) {
const index = rng.nextInt(0, entry.texts.length);
const index = legacyChoiceIndex(rng, entry.texts.length);
const text = entry.texts[index] ?? entry.texts[0] ?? '';
return { flags: entry.flags, text };
}
@@ -147,7 +158,7 @@ const pickByWeight = (rng: GeneralActionResolveContext['rng']): { flags: number;
if (!fallback) {
return { flags: 0, text: '' };
}
const index = rng.nextInt(0, fallback.texts.length);
const index = legacyChoiceIndex(rng, fallback.texts.length);
const text = fallback.texts[index] ?? fallback.texts[0] ?? '';
return { flags: fallback.flags, text };
};
@@ -140,7 +140,7 @@ export class ActionDefinition<
setMetaNumber(general.meta, dexKey, nextDex);
const expGain = general.crew / 400;
general.experience += expGain;
general.experience = Math.round(general.experience + expGain);
const statKey = pickByWeight(context.rng, {
leadership_exp: general.stats.leadership,
@@ -49,7 +49,17 @@ export class ActionResolver<
const resKey = picked === 'gold' ? 'gold' : 'rice';
// 2. Base Score
let score = general.stats.leadership + general.stats.strength + general.stats.intelligence;
const injuryMultiplier = (100 - general.injury) / 100;
const rawLeadership = general.stats.leadership * injuryMultiplier;
const rawStrength = general.stats.strength * injuryMultiplier;
const rawIntelligence = general.stats.intelligence * injuryMultiplier;
const maxStat = 255;
const legacyStat = (stat: 'leadership' | 'strength' | 'intelligence', value: number): number =>
Math.trunc(Math.max(0, Math.min(maxStat, this.pipeline.onCalcStat(context, stat, value))));
let score =
legacyStat('leadership', rawLeadership) +
legacyStat('strength', rawStrength + Math.round(rawIntelligence / 4)) +
legacyStat('intelligence', rawIntelligence + Math.round(rawStrength / 4));
const expLevel = typeof general.meta.explevel === 'number' ? general.meta.explevel : 0;
score *= 1 + expLevel / 500;
score *= context.rng.nextFloat1() * 0.4 + 0.8;
@@ -29,7 +29,7 @@ export interface BoostMoraleEnvironment {
generalActionModules?: TurnCommandEnv['generalActionModules'];
}
const ACTION_NAME = '사기 진작';
const ACTION_NAME = '사기진작';
const DEFAULT_ATMOS_DELTA = 5;
const DEFAULT_MAX_ATMOS = 100;
@@ -43,6 +43,7 @@ export interface TalentScoutWorldSummary {
totalGeneralCount: number;
totalNpcCount: number;
averageStats?: StatBlock;
averageDex?: [number, number, number, number, number];
}
export interface TalentScoutResolveContext<
@@ -54,6 +55,7 @@ export interface TalentScoutResolveContext<
generalPool?: TalentScoutCandidate[];
cityPool?: City[];
createGeneralId: () => number;
turnTermMinutes: number;
}
export interface TalentScoutEnvironment {
@@ -76,6 +78,13 @@ export interface TalentScoutEnvironment {
rng: RandomGenerator,
candidate: TalentScoutCandidate
) => StatBlock;
npcStatTotal?: number;
npcStatMin?: number;
npcStatMax?: number;
randomGeneralFirstNames?: string[];
randomGeneralMiddleNames?: string[];
randomGeneralLastNames?: string[];
availablePersonalities?: string[];
}
type StatExpKey = 'leadership_exp' | 'strength_exp' | 'intel_exp';
@@ -88,20 +97,6 @@ const DEFAULT_MAX_AGE = 25;
const DEFAULT_DEATH_MIN = 10;
const DEFAULT_DEATH_MAX = 50;
const resolveKillturnFromDeathYear = (
currentYear: number,
currentMonth: number,
deathYear: number,
rng: RandomGenerator
): number => {
if (!Number.isFinite(deathYear) || deathYear <= 0) {
return 0;
}
const deathMonth = randomRangeInt(rng, 1, 12);
const diff = (deathYear - currentYear) * 12 + (deathMonth - currentMonth);
return Math.max(diff, 0);
};
const addMetaValue = (
meta: Record<string, TriggerValue>,
key: string,
@@ -158,7 +153,7 @@ const calcFoundProp = (maxGeneral: number, totalGeneralCount: number, totalNpcCo
if (maxGeneral <= 0) {
return 0;
}
const current = totalGeneralCount + totalNpcCount / 2;
const current = Math.trunc(totalGeneralCount + totalNpcCount / 2);
const remainSlot = Math.max(maxGeneral - current, 0);
const main = Math.pow(remainSlot / maxGeneral, 6);
const small = 1 / (totalNpcCount / 3 + 1);
@@ -171,6 +166,22 @@ const calcFoundProp = (maxGeneral: number, totalGeneralCount: number, totalNpcCo
const randomRangeInt = (rng: RandomGenerator, min: number, max: number): number => rng.nextInt(min, max + 1);
type InclusiveRandomGenerator = RandomGenerator & {
nextIntInclusive?: (maxInclusive: number) => number;
};
const legacyChoiceIndex = (rng: RandomGenerator, length: number): number => {
if (length <= 0) {
throw new Error('Empty items');
}
const inclusive = rng as InclusiveRandomGenerator;
return inclusive.nextIntInclusive
? inclusive.nextIntInclusive(length - 1)
: rng.nextInt(0, length);
};
const legacyChoice = <T>(rng: RandomGenerator, values: readonly T[]): T => values[legacyChoiceIndex(rng, values.length)]!;
const resolveCandidate = (
context: TalentScoutResolveContext,
rng: RandomGenerator,
@@ -183,7 +194,7 @@ const resolveCandidate = (
if (pool.length === 0) {
return null;
}
const idx = rng.nextInt(0, pool.length);
const idx = legacyChoiceIndex(rng, pool.length);
return pool[idx] ?? null;
};
@@ -200,7 +211,7 @@ const resolveSpawnCityId = (
}
const pool = context.cityPool ?? [];
if (pool.length > 0) {
const idx = rng.nextInt(0, pool.length);
const idx = legacyChoiceIndex(rng, pool.length);
return pool[idx]!.id;
}
return context.general.cityId;
@@ -273,13 +284,6 @@ export class ActionResolver<
const prop = this.command.calcFoundProp(context);
const found = context.rng.nextBool(prop);
const statKey = pickStatExpKey(context.rng, general);
const metaAfter = found ? addMetaNumber(general.meta, statKey, 3) : addMetaNumber(general.meta, statKey, 1);
if (found) {
const active = typeof metaAfter.inherit_active_action === 'number' ? metaAfter.inherit_active_action : 0;
metaAfter.inherit_active_action = active + Math.max(Math.sqrt(1 / prop), 1);
}
const nextGold = Math.max(0, general.gold - reqGold);
const nextRice = Math.max(0, general.rice - reqRice);
const expGain = found ? 200 : 100;
@@ -290,9 +294,10 @@ export class ActionResolver<
general.rice = nextRice;
general.experience += expGain;
general.dedication += dedGain;
general.meta = metaAfter;
if (!found) {
const statKey = pickStatExpKey(context.rng, general);
general.meta = addMetaNumber(general.meta, statKey, 1);
context.addLog('인재를 찾을 수 없었습니다.', {
category: LogCategory.ACTION,
format: LogFormat.MONTH,
@@ -301,10 +306,6 @@ export class ActionResolver<
return { effects: [] };
}
const candidate = resolveCandidate(context, context.rng, this.env);
const newGeneralId = context.createGeneralId();
const resolvedCandidate: TalentScoutCandidate = candidate ?? { name: `NPC_${newGeneralId}` };
const age = randomRangeInt(
context.rng,
this.env.minNpcAge ?? DEFAULT_MIN_AGE,
@@ -318,45 +319,108 @@ export class ActionResolver<
this.env.minDeathYears ?? DEFAULT_DEATH_MIN,
this.env.maxDeathYears ?? DEFAULT_DEATH_MAX
);
const stats = resolveStats(context, context.rng, this.env, resolvedCandidate);
const candidate = resolveCandidate(context, context.rng, this.env);
const firstNames = this.env.randomGeneralFirstNames ?? ['가'];
const middleNames = this.env.randomGeneralMiddleNames ?? [''];
const lastNames = this.env.randomGeneralLastNames ?? ['가'];
const generatedName = `${legacyChoice(context.rng, firstNames)}${legacyChoice(
context.rng,
middleNames
)}${legacyChoice(context.rng, lastNames)}`;
const newGeneralId = context.createGeneralId();
const resolvedCandidate: TalentScoutCandidate = candidate ?? { name: generatedName };
const affinity = randomRangeInt(context.rng, 1, 150);
const npcStatTotal = this.env.npcStatTotal ?? 150;
const npcStatMin = this.env.npcStatMin ?? 10;
const npcStatMax = this.env.npcStatMax ?? 50;
const pickType = pickByWeight(context.rng, { : 6, : 6, 무지: 3 });
const mainStat = npcStatMax - randomRangeInt(context.rng, 0, npcStatMin);
const otherStat = npcStatMin + randomRangeInt(context.rng, 0, Math.trunc(npcStatMin / 2));
const subStat = npcStatTotal - mainStat - otherStat;
let generatedStats: StatBlock;
if (pickType === '무') {
generatedStats = { leadership: subStat, strength: mainStat, intelligence: otherStat };
} else if (pickType === '지') {
generatedStats = { leadership: subStat, strength: otherStat, intelligence: mainStat };
} else {
generatedStats = { leadership: otherStat, strength: subStat, intelligence: mainStat };
}
const stats = candidate?.stats
? resolveStats(context, context.rng, this.env, resolvedCandidate)
: generatedStats;
const averageDex = context.worldSummary.averageDex ?? [0, 0, 0, 0, 0];
const dexTotal = averageDex[0] + averageDex[1] + averageDex[2] + averageDex[3];
let dex: [number, number, number, number, number];
if (pickType === '무') {
const distributions = [
[dexTotal * 5 / 8, dexTotal / 8, dexTotal / 8, dexTotal / 8],
[dexTotal / 8, dexTotal * 5 / 8, dexTotal / 8, dexTotal / 8],
[dexTotal / 8, dexTotal / 8, dexTotal * 5 / 8, dexTotal / 8],
] as const;
const picked = legacyChoice(context.rng, distributions);
dex = [picked[0], picked[1], picked[2], picked[3], averageDex[4]];
} else if (pickType === '지') {
dex = [dexTotal / 8, dexTotal / 8, dexTotal / 8, dexTotal * 5 / 8, averageDex[4]];
} else {
dex = [dexTotal / 4, dexTotal / 4, dexTotal / 4, dexTotal / 4, averageDex[4]];
}
const personality =
resolvedCandidate.personality ??
legacyChoice(context.rng, this.env.availablePersonalities ?? ['che_안전']);
const name = this.env.decorateName
? this.env.decorateName(resolvedCandidate.name, NPC_TYPE)
: resolvedCandidate.name;
: `${resolvedCandidate.name}`;
const cityId = resolveSpawnCityId(context, context.rng, this.env);
const turnSecond = randomRangeInt(context.rng, 0, context.turnTermMinutes * 60 - 1);
const turnFraction = randomRangeInt(context.rng, 0, 999_999);
const killturn =
(deathYear - context.currentYear) * 12 +
randomRangeInt(context.rng, 0, 11) +
context.currentMonth -
1;
const meta: GeneralMeta = {
killturn: resolveKillturnFromDeathYear(context.currentYear, context.currentMonth, deathYear, context.rng),
killturn,
npcType: NPC_TYPE,
crewTypeId: this.env.defaultCrewTypeId,
affinity,
birthYear,
deathYear,
dex1: dex[0],
dex2: dex[1],
dex3: dex[2],
dex4: dex[3],
dex5: dex[4],
turnSecond,
turnFraction,
};
addMetaValue(meta, 'affinity', resolvedCandidate.affinity ?? null);
addMetaValue(meta, 'picture', resolvedCandidate.picture ?? null);
addMetaValue(meta, 'birthYear', birthYear);
addMetaValue(meta, 'text', resolvedCandidate.text ?? null);
const newGeneral = buildRecruitmentGeneral<TriggerState>({
id: newGeneralId,
name,
nationId: 0,
cityId: resolveSpawnCityId(context, context.rng, this.env),
cityId,
stats,
officerLevel: 0,
age,
npcState: NPC_TYPE,
gold: this.env.defaultNpcGold,
rice: this.env.defaultNpcRice,
experience: 0,
dedication: 0,
experience: age * 100,
dedication: age * 100,
crewTypeId: this.env.defaultCrewTypeId,
role: {
personality: resolvedCandidate.personality ?? null,
specialDomestic: this.env.defaultSpecialDomestic,
specialWar: this.env.defaultSpecialWar,
personality,
specialDomestic: null,
specialWar: null,
},
meta,
});
const nameObjJosa = JosaUtil.pick(name, '을');
const nameSubjJosa = JosaUtil.pick(name, '이');
const recruitVerb = randomRangeInt(context.rng, 0, 1) === 0 ? '발견' : '등용';
const recruitVerb = '발견';
const nameRa = JosaUtil.pick(name, '라');
context.addLog(`<Y>${name}</>${nameRa}는 <C>인재</>를 ${recruitVerb}하였습니다!`, {
category: LogCategory.ACTION,
@@ -374,6 +438,12 @@ export class ActionResolver<
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
const statKey = pickStatExpKey(context.rng, general);
const metaAfter = addMetaNumber(general.meta, statKey, 3);
const active = typeof metaAfter.inherit_active_action === 'number' ? metaAfter.inherit_active_action : 0;
metaAfter.inherit_active_action = active + Math.max(Math.sqrt(1 / prop), 1);
general.meta = metaAfter;
return {
effects: [createGeneralAddEffect(newGeneral)],
};
@@ -418,8 +488,25 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => ({
...base,
currentYear: options.world.currentYear,
currentMonth: options.world.currentMonth,
worldSummary: buildWorldSummary(options.worldRef),
worldSummary: {
...buildWorldSummary(options.worldRef),
averageDex: (() => {
const generals = options.worldRef?.listGenerals().filter((general) => general.npcState < 4) ?? [];
if (generals.length === 0) {
return [0, 0, 0, 0, 0] as [number, number, number, number, number];
}
return [1, 2, 3, 4, 5].map(
(armType) =>
generals.reduce((sum, general) => {
const value = general.meta[`dex${armType}`];
return sum + (typeof value === 'number' ? value : 0);
}, 0) / generals.length
) as [number, number, number, number, number];
})(),
},
cityPool: options.worldRef?.listCities() ?? [],
createGeneralId: options.createGeneralId,
turnTermMinutes: Math.max(1, Math.round(options.world.tickSeconds / 60)),
});
export const commandSpec: GeneralTurnCommandSpec = {
@@ -427,5 +514,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
category: '인사',
reqArg: false,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? [], env),
createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition(env.generalActionModules ?? [], env),
};
@@ -31,6 +31,7 @@ import type { WarAftermathConfig, WarEngineConfig, WarTimeContext } from '@sammo
import { resolveWarAftermath } from '@sammo-ts/logic/war/aftermath.js';
import { resolveWarBattle } from '@sammo-ts/logic/war/engine.js';
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
import type { NationTraitModule } from '@sammo-ts/logic/triggers/special/nation/index.js';
import { increaseMetaNumber, simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
@@ -157,6 +158,15 @@ const pickCandidateCity = (
distanceList: Map<number, Array<[number, number]>>,
attackerNationId: number
): { cityId: number; isEnemy: boolean; minDist: number } | null => {
const pickLegacyChoice = <T>(items: T[]): T => {
const legacyCompatibleRng = rng as typeof rng & {
nextIntInclusive?: (maxInclusive: number) => number;
};
const index =
legacyCompatibleRng.nextIntInclusive?.(items.length - 1) ??
rng.nextInt(0, items.length);
return items[index]!;
};
const distances = Array.from(distanceList.keys()).sort((a, b) => a - b);
const minDist = distances[0];
if (minDist === undefined) {
@@ -174,8 +184,9 @@ const pickCandidateCity = (
}
}
if (candidates.length > 0) {
const index = rng.nextInt(0, candidates.length);
const [cityId] = candidates[index] ?? candidates[0]!;
// Legacy RandUtil::choice() consumes nextInt(0) even when there is a
// single candidate. Keep that observable RNG step for seed parity.
const [cityId] = pickLegacyChoice(candidates);
return { cityId, isEnemy: true, minDist };
}
const fallback = distanceList.get(minDist) ?? [];
@@ -183,8 +194,7 @@ const pickCandidateCity = (
if (friendly.length === 0) {
return null;
}
const index = rng.nextInt(0, friendly.length);
const [cityId] = friendly[index] ?? friendly[0]!;
const [cityId] = pickLegacyChoice(friendly);
return { cityId, isEnemy: false, minDist };
};
@@ -245,9 +255,14 @@ export class ActionDefinition<
return 1;
}
private readonly warModules: Array<WarActionModule<TriggerState>>;
private readonly nationTraitModules: Map<string, NationTraitModule>;
constructor(modules: Array<WarActionModule<TriggerState> | null | undefined> = []) {
constructor(
modules: Array<WarActionModule<TriggerState> | null | undefined> = [],
nationTraitModules: NationTraitModule[] = []
) {
this.warModules = modules.filter(Boolean) as Array<WarActionModule<TriggerState>>;
this.nationTraitModules = new Map(nationTraitModules.map((module) => [module.key, module]));
}
parseArgs(raw: unknown): DispatchArgs | null {
@@ -400,6 +415,11 @@ export class ActionDefinition<
const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
const defenderCity = cityMap.get(destCity.id) ?? cloneCity(destCity);
// Legacy marks the destination as an active battle for three turns
// before processWar(), including when the attack immediately conquers
// the city. ConquerCity resets term but intentionally leaves state 43.
defenderCity.state = 43;
defenderCity.meta.term = 3;
const defenderNation = defenderCity.nationId > 0 ? (nationMap.get(defenderCity.nationId) ?? null) : null;
const defenderGenerals = generals.filter(
@@ -444,6 +464,17 @@ export class ActionDefinition<
config: context.aftermathConfig,
time,
hiddenSeed: context.seedBase,
calcNationTechGain: ({ nation, baseGain }) => {
const module = this.nationTraitModules.get(nation.typeCode);
return (
module?.onCalcDomestic?.(
{ general: context.general, nation },
'기술',
'score',
baseGain
) ?? baseGain
);
},
});
const effects: Array<GeneralActionEffect<TriggerState>> = [];
@@ -559,5 +590,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
reqArg: true,
availabilityArgs: { destCityId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.warActionModules ?? []),
createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition(env.warActionModules ?? [], env.nationTraitModules ?? []),
};
@@ -15,6 +15,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/triggers/general-action.js';
const ACTION_NAME = '맹훈련';
const ACTION_KEY = 'cr_맹훈련';
@@ -36,8 +37,11 @@ export class ActionDefinition<
> implements GeneralActionDefinition<TriggerState, FierceTrainingArgs> {
public readonly key = ACTION_KEY;
public readonly name = ACTION_NAME;
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(private readonly env: TurnCommandEnv) {}
constructor(private readonly env: TurnCommandEnv) {
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
}
parseArgs(_raw: unknown): FierceTrainingArgs | null {
return {};
@@ -67,7 +71,8 @@ export class ActionDefinition<
const maxTrain = this.env.maxTrainByCommand > 0 ? this.env.maxTrainByCommand : 100;
const maxAtmos = this.env.maxAtmosByCommand > 0 ? this.env.maxAtmosByCommand : 100;
const score = Math.round((general.stats.leadership * 100 * trainDelta * 2) / (Math.max(general.crew, 1) * 3));
const leadership = this.pipeline.onCalcStat(context, 'leadership', general.stats.leadership);
const score = Math.round((leadership * 100 * trainDelta * 2) / (Math.max(general.crew, 1) * 3));
const scoreText = score.toLocaleString('en-US');
context.addLog(`훈련, 사기치가 <C>${scoreText}</> 상승했습니다.`, {
@@ -81,11 +86,13 @@ export class ActionDefinition<
const nextTrain = clamp(general.train + score, 0, maxTrain);
const nextAtmos = clamp(general.atmos + score, 0, maxAtmos);
const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0;
const crewType = this.env.unitSet?.crewTypes?.find((entry) => entry.id === general.crewTypeId);
const dexKey = crewType ? `dex${crewType.armType}` : null;
const currentDex = dexKey && typeof general.meta[dexKey] === 'number' ? general.meta[dexKey] : 0;
return {
effects: [
createGeneralPatchEffect<TriggerState>({
rice: Math.max(0, general.rice - 500),
train: nextTrain,
atmos: nextAtmos,
experience: general.experience + 150,
@@ -93,6 +100,7 @@ export class ActionDefinition<
meta: {
...general.meta,
leadership_exp: leadershipExp + 1,
...(dexKey ? { [dexKey]: currentDex + score * 2 } : {}),
},
}),
],
@@ -93,7 +93,7 @@ export class ActionDefinition<
}
resolve(context: SeizureResolveContext<TriggerState>, args: SeizureArgs): GeneralActionOutcome<TriggerState> {
const { general, nation, destGeneral } = context;
const { nation, destGeneral } = context;
if (!nation) {
return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] };
}
@@ -147,9 +147,6 @@ export class ActionDefinition<
}),
];
general.experience += 5;
general.dedication += 5;
return { effects };
}
}
+168 -17
View File
@@ -2,20 +2,173 @@ import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { simpleSerialize } from '../war/utils.js';
const DEFAULT_FIRST_NAMES = [
'가', '간', '감', '강', '고', '공', '공손', '곽', '관', '괴', '교', '금', '노', '뇌', '능', '도', '동',
'두', '등', '마', '맹', '문', '미', '반', '방', '부', '비', '사', '사마', '서', '설', '성', '소', '손',
'송', '순', '신', '심', '악', '안', '양', '엄', '여', '염', '오', '왕', '요', '우', '원', '위', '유',
'육', '윤', '이', '장', '저', '전', '정', '제갈', '조', '종', '주', '진', '채', '태사', '하', '하후',
'학', '한', '향', '허', '호', '화', '황', '공손', '손', '왕', '유', '장', '조',
export const LEGACY_RANDOM_GENERAL_FIRST_NAMES = [
'가',
'',
'',
'',
'',
'공',
'공손',
'곽',
'관',
'괴',
'교',
'금',
'노',
'뇌',
'능',
'도',
'동',
'두',
'등',
'마',
'맹',
'문',
'미',
'반',
'방',
'부',
'비',
'사',
'사마',
'서',
'설',
'성',
'소',
'손',
'송',
'순',
'신',
'심',
'악',
'안',
'양',
'엄',
'여',
'염',
'오',
'왕',
'요',
'우',
'원',
'위',
'유',
'육',
'윤',
'이',
'장',
'저',
'전',
'정',
'제갈',
'조',
'종',
'주',
'진',
'채',
'태사',
'하',
'하후',
'학',
'한',
'향',
'허',
'호',
'화',
'황',
'공손',
'손',
'왕',
'유',
'장',
'조',
] as const;
const DEFAULT_LAST_NAMES = [
'가', '간', '강', '거', '건', '검', '견', '경', '공', '광', '권', '규', '녕', '단', '대', '도', '등',
'람', '량', '례', '로', '료', '모', '민', '박', '범', '보', '비', '사', '상', '색', '서', '소', '속',
'송', '수', '순', '습', '승', '양', '연', '영', '온', '옹', '완', '우', '웅', '월', '위', '유', '윤',
'융', '이', '익', '임', '정', '제', '조', '주', '준', '지', '찬', '책', '충', '탁', '택', '통', '패',
'평', '포', '합', '해', '혁', '현', '화', '환', '회', '횡', '후', '훈', '휴', '흠', '흥',
export const LEGACY_RANDOM_GENERAL_LAST_NAMES = [
'가',
'',
'',
'',
'',
'검',
'견',
'경',
'공',
'광',
'권',
'규',
'녕',
'단',
'대',
'도',
'등',
'람',
'량',
'례',
'로',
'료',
'모',
'민',
'박',
'범',
'보',
'비',
'사',
'상',
'색',
'서',
'소',
'속',
'송',
'수',
'순',
'습',
'승',
'양',
'연',
'영',
'온',
'옹',
'완',
'우',
'웅',
'월',
'위',
'유',
'윤',
'융',
'이',
'익',
'임',
'정',
'제',
'조',
'주',
'준',
'지',
'찬',
'책',
'충',
'탁',
'택',
'통',
'패',
'평',
'포',
'합',
'해',
'혁',
'현',
'화',
'환',
'회',
'횡',
'후',
'훈',
'휴',
'흠',
'흥',
] as const;
const readNameParts = (value: unknown, fallback: readonly string[]): string[] => {
@@ -31,9 +184,9 @@ export const buildAuctionAlias = (
hiddenSeed: string | number,
configConst: Record<string, unknown> = {}
): string => {
const firstNames = readNameParts(configConst.randGenFirstName, DEFAULT_FIRST_NAMES);
const firstNames = readNameParts(configConst.randGenFirstName, LEGACY_RANDOM_GENERAL_FIRST_NAMES);
const middleNames = readNameParts(configConst.randGenMiddleName, ['']);
const lastNames = readNameParts(configConst.randGenLastName, DEFAULT_LAST_NAMES);
const lastNames = readNameParts(configConst.randGenLastName, LEGACY_RANDOM_GENERAL_LAST_NAMES);
const pool: string[] = [];
for (const first of firstNames) {
for (const middle of middleNames) {
@@ -42,9 +195,7 @@ export const buildAuctionAlias = (
}
}
}
const shuffled = new RandUtil(
new LiteHashDRBG(simpleSerialize(hiddenSeed, 'obfuscatedNamePool'))
).shuffle(pool);
const shuffled = new RandUtil(new LiteHashDRBG(simpleSerialize(hiddenSeed, 'obfuscatedNamePool'))).shuffle(pool);
const normalizedId = Math.max(0, Math.floor(generalId));
const duplicateIndex = Math.floor(normalizedId / shuffled.length);
const name = shuffled[normalizedId % shuffled.length] ?? `익명${normalizedId}`;
@@ -0,0 +1,127 @@
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
import type { UniqueItemPool } from './uniqueLottery.js';
const LEGACY_UNIQUE_ITEM_KEYS: Readonly<Record<ItemModule['slot'], readonly string[]>> = {
horse: [
'che_명마_07_백마',
'che_명마_07_기주마',
'che_명마_07_오환마',
'che_명마_07_백상',
'che_명마_08_양주마',
'che_명마_08_흉노마',
'che_명마_09_과하마',
'che_명마_09_의남백마',
'che_명마_10_대완마',
'che_명마_10_옥추마',
'che_명마_11_서량마',
'che_명마_11_화종마',
'che_명마_12_사륜거',
'che_명마_12_옥란백용구',
'che_명마_13_절영',
'che_명마_13_적로',
'che_명마_14_적란마',
'che_명마_14_조황비전',
'che_명마_15_한혈마',
'che_명마_15_적토마',
],
weapon: [
'che_무기_07_동추',
'che_무기_07_철편',
'che_무기_07_철쇄',
'che_무기_07_맥궁',
'che_무기_08_유성추',
'che_무기_08_철질여골',
'che_무기_09_쌍철극',
'che_무기_09_동호비궁',
'che_무기_10_삼첨도',
'che_무기_10_대부',
'che_무기_11_고정도',
'che_무기_11_이광궁',
'che_무기_12_철척사모',
'che_무기_12_칠성검',
'che_무기_13_사모',
'che_무기_13_양유기궁',
'che_무기_14_언월도',
'che_무기_14_방천화극',
'che_무기_15_청홍검',
'che_무기_15_의천검',
],
book: [
'che_서적_07_위료자',
'che_서적_07_사마법',
'che_서적_07_한서',
'che_서적_07_논어',
'che_서적_08_전론',
'che_서적_08_사기',
'che_서적_09_장자',
'che_서적_09_역경',
'che_서적_10_시경',
'che_서적_10_구국론',
'che_서적_11_상군서',
'che_서적_11_춘추전',
'che_서적_12_산해경',
'che_서적_12_맹덕신서',
'che_서적_13_관자',
'che_서적_13_병법24편',
'che_서적_14_한비자',
'che_서적_14_오자병법',
'che_서적_15_노자',
'che_서적_15_손자병법',
],
item: [
'che_의술_정력견혈산',
'che_의술_청낭서',
'che_의술_태평청령',
'che_의술_상한잡병론',
'che_보물_도기',
'che_조달_주판',
'che_내정_납금박산로',
'che_전략_평만지장도',
'che_숙련_동작',
'che_명성_구석',
'che_척사_오악진형도',
'che_격노_구정신단경',
'che_징병_낙주',
'che_저격_매화수전',
'che_저격_비도',
'che_위압_조목삭',
'che_공성_묵자',
'che_집중_전국책',
'che_환술_논어집해',
'che_진압_박혁론',
'che_부적_태현청생부',
'che_저지_삼황내문',
'che_행동_서촉지형도',
'che_간파_노군입산부',
'che_불굴_상편',
'che_약탈_옥벽',
'che_농성_주서음부',
'che_농성_위공자병법',
'che_계략_육도',
'che_계략_삼략',
'che_상성보정_과실주',
'che_능력치_지력_이강주',
'che_능력치_무력_두강주',
'che_능력치_통솔_보령압주',
'che_훈련_철벽서',
'che_훈련_단결도',
'che_사기_춘화첩',
'che_사기_초선화',
'che_회피_태평요술',
'che_필살_둔갑천서',
],
};
export const buildLegacyDefaultUniqueItemPool = (itemRegistry: Map<string, ItemModule>): UniqueItemPool => {
const pool: UniqueItemPool = { horse: {}, weapon: {}, book: {}, item: {} };
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
const count = slot === 'item' ? 1 : 2;
for (const itemKey of LEGACY_UNIQUE_ITEM_KEYS[slot]) {
const item = itemRegistry.get(itemKey);
if (item?.slot === slot && !item.buyable) {
pool[slot]![itemKey] = count;
}
}
}
return pool;
};
@@ -26,17 +26,19 @@ import {
TraitWarActionRouter,
WAR_TRAIT_KEYS,
} from './special/index.js';
import type { NationTraitModule } from './special/nation/index.js';
export interface ActionModuleBundle<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
general: GeneralActionModule<TriggerState>[];
war: WarActionModule<TriggerState>[];
itemModules: ItemModule<TriggerState>[];
nationTraitModules: NationTraitModule[];
}
// General::getActionList와 같은 소유권 순서로 실제 턴과 시뮬레이터의 모듈을 조립한다.
export const loadActionModuleBundle = async <
TriggerState extends GeneralTriggerState = GeneralTriggerState,
>(unitSet?: UnitSetDefinition): Promise<ActionModuleBundle<TriggerState>> => {
export const loadActionModuleBundle = async <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
unitSet?: UnitSetDefinition
): Promise<ActionModuleBundle<TriggerState>> => {
const [domestic, war, personality, nation, itemModules] = await Promise.all([
loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS]),
loadWarTraitModules([...WAR_TRAIT_KEYS]),
@@ -74,5 +76,6 @@ export const loadActionModuleBundle = async <
...items.war,
],
itemModules,
nationTraitModules: nation,
};
};
+23 -6
View File
@@ -17,6 +17,17 @@ import { clamp, clampMin, getMetaNumber, round, simpleSerialize } from './utils.
const META_DEAD = 'dead';
const META_CONFLICT = 'conflict';
const MAX_EXP_LEVEL = 255;
const MAX_DEDICATION_LEVEL = 30;
const updateLegacyProgressionLevels = (general: General): void => {
const expLevel =
general.experience < 1_000
? Math.trunc(general.experience / 100)
: Math.trunc(Math.sqrt(general.experience / 10));
general.meta.explevel = clamp(expLevel, 0, MAX_EXP_LEVEL);
general.meta.dedlevel = clamp(Math.ceil(Math.sqrt(general.dedication) / 10), 0, MAX_DEDICATION_LEVEL);
};
const findReport = (reports: WarUnitReport[], predicate: (report: WarUnitReport) => boolean): WarUnitReport | null => {
for (const report of reports) {
@@ -78,7 +89,7 @@ const applyNationTechGain = <TriggerState extends GeneralTriggerState>(
nation: Nation,
baseGain: number,
input: WarAftermathInput<TriggerState>,
context: WarAftermathTechContext
context: Omit<WarAftermathTechContext, 'baseGain'>
): void => {
const config = input.config;
let gain = baseGain;
@@ -102,7 +113,10 @@ const applyNationTechGain = <TriggerState extends GeneralTriggerState>(
const divisor = Math.max(config.initialNationGenLimit, total);
const tech = getMetaNumber(nation.meta, 'tech', 0) + gain / divisor;
nation.meta.tech = round(tech);
// Legacy MySQL FLOAT values are read back at the command boundary with
// two-decimal precision. Preserve fractional accumulation without
// converting the gain to an integer.
nation.meta.tech = Math.round(tech * 100) / 100;
};
const resolveConquerNation = (city: City, attackerNationId: number, nations: Nation[]): number => {
@@ -241,12 +255,14 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
let totalRiceLoss = 0;
for (const general of defenderGenerals) {
const loseGold = round(general.gold * rng.nextRange(0.2, 0.5));
const loseRice = round(general.rice * rng.nextRange(0.2, 0.5));
// Legacy Util::toInt truncates these losses rather than rounding.
const loseGold = Math.trunc(general.gold * rng.nextRange(0.2, 0.5));
const loseRice = Math.trunc(general.rice * rng.nextRange(0.2, 0.5));
general.gold = clampMin(general.gold - loseGold, 0);
general.rice = clampMin(general.rice - loseRice, 0);
general.experience = round(general.experience * 0.9);
general.dedication = round(general.dedication * 0.5);
updateLegacyProgressionLevels(general);
totalGoldLoss += loseGold;
totalRiceLoss += loseRice;
@@ -263,8 +279,8 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
affectedGenerals.add(general);
}
collapseRewardGold = Math.max(0, defenderNation.gold - config.baseGold) * 0.5 + totalGoldLoss * 0.5;
collapseRewardRice = Math.max(0, defenderNation.rice - config.baseRice) * 0.5 + totalRiceLoss * 0.5;
collapseRewardGold = Math.floor((Math.max(0, defenderNation.gold - config.baseGold) + totalGoldLoss) / 2);
collapseRewardRice = Math.floor((Math.max(0, defenderNation.rice - config.baseRice) + totalRiceLoss) / 2);
attackerNation.gold = round(attackerNation.gold + collapseRewardGold);
attackerNation.rice = round(attackerNation.rice + collapseRewardRice);
@@ -329,6 +345,7 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
// 점령 후 도시 상태를 방어 기본 상태로 되돌린다.
defenderCity.supplyState = 1;
defenderCity.frontState = 0;
defenderCity.meta.term = 0;
defenderCity.agriculture = round(defenderCity.agriculture * 0.7);
defenderCity.commerce = round(defenderCity.commerce * 0.7);
defenderCity.security = round(defenderCity.security * 0.7);
+1 -1
View File
@@ -143,7 +143,7 @@ export interface WarAftermathTechContext {
side: 'attacker' | 'defender';
nation: Nation;
attackerReport: WarUnitReport;
baseGain?: number;
baseGain: number;
}
export interface WarDiplomacyDelta {
+3 -3
View File
@@ -27,9 +27,9 @@ const META_TURN_TIME = 'turnTime';
const META_RECENT_WAR = 'recentWar';
const META_DEX_PREFIX = 'dex';
const META_RANK_PREFIX = 'rank_';
const META_INTEL_EXP = 'intelExp';
const META_STRENGTH_EXP = 'strengthExp';
const META_LEADERSHIP_EXP = 'leadershipExp';
const META_INTEL_EXP = 'intel_exp';
const META_STRENGTH_EXP = 'strength_exp';
const META_LEADERSHIP_EXP = 'leadership_exp';
const MAX_EXP_LEVEL = 255;
const RANK_WARNUM = `${META_RANK_PREFIX}warnum`;
+2 -2
View File
@@ -168,8 +168,8 @@ describe('war aftermath', () => {
},
});
expect(attackerNation.meta.tech).toBe(1001);
expect(defenderNation.meta.tech).toBe(1001);
expect(attackerNation.meta.tech).toBe(1000.6);
expect(defenderNation.meta.tech).toBe(1000.9);
expect(outcome.diplomacyDeltas).toHaveLength(2);
expect(attackerCity.meta.dead).toBe(60);
expect(defenderCity.meta.dead).toBe(90);
@@ -113,6 +113,13 @@ export const projectCoreDatabaseSnapshot = (rows: {
experience: row.experience,
dedication: row.dedication,
officerLevel: row.officerLevel,
personality: row.personality ?? null,
specialDomestic: row.specialDomestic ?? null,
specialWar: row.specialWar ?? null,
itemHorse: row.itemHorse ?? null,
itemWeapon: row.itemWeapon ?? null,
itemBook: row.itemBook ?? null,
itemExtra: row.itemExtra ?? null,
injury: row.injury,
gold: row.gold,
rice: row.rice,
@@ -129,6 +136,11 @@ export const projectCoreDatabaseSnapshot = (rows: {
leadershipExp: readNumber(meta, 'leadership_exp'),
strengthExp: readNumber(meta, 'strength_exp'),
intelExp: readNumber(meta, 'intel_exp'),
dex1: readNumber(meta, 'dex1'),
dex2: readNumber(meta, 'dex2'),
dex3: readNumber(meta, 'dex3'),
dex4: readNumber(meta, 'dex4'),
dex5: readNumber(meta, 'dex5'),
killTurn: readNumber(meta, 'killturn'),
mySet: readNumber(meta, 'myset'),
};
@@ -175,6 +187,7 @@ export const projectCoreDatabaseSnapshot = (rows: {
generalCount: readNumber(meta, 'gennum'),
power: readNumber(meta, 'power'),
war: readNumber(meta, 'war'),
diplomacyLimit: readNumber(meta, 'surlimit'),
meta,
};
});
@@ -63,6 +63,18 @@ const valuesEqual = (left: unknown, right: unknown, numericTolerance: number): b
if (typeof left === 'number' && typeof right === 'number') {
return Math.abs(left - right) <= numericTolerance;
}
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
return false;
}
return left.every((value, index) => valuesEqual(value, right[index], numericTolerance));
}
if (typeof left === 'object' && left !== null && typeof right === 'object' && right !== null) {
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const keys = [...new Set([...Object.keys(leftRecord), ...Object.keys(rightRecord)])].sort();
return keys.every((key) => valuesEqual(leftRecord[key], rightRecord[key], numericTolerance));
}
return Object.is(left, right);
};
@@ -0,0 +1,663 @@
import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
import {
GENERAL_TURN_COMMAND_KEYS,
NATION_TURN_COMMAND_KEYS,
type MapDefinition,
type Nation,
type TurnCommandProfile,
type UnitSetDefinition,
} from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '@sammo-ts/game-engine/turn/inMemoryWorld.js';
import { createReservedTurnHandler } from '@sammo-ts/game-engine/turn/reservedTurnHandler.js';
import { InMemoryReservedTurnStore } from '@sammo-ts/game-engine/turn/reservedTurnStore.js';
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
import { loadMapDefinitionByName } from '@sammo-ts/game-engine/scenario/mapLoader.js';
import type {
TurnDiplomacy,
TurnGeneral,
TurnWorldSnapshot,
TurnWorldState,
} from '@sammo-ts/game-engine/turn/types.js';
import {
canonicalizeTurnCommandArgs,
type CanonicalTurnCommandTrace,
type CanonicalTurnSnapshot,
} from './canonical.js';
export interface TurnCommandFixtureRequest {
kind: 'general' | 'nation';
actorGeneralId: number;
action: string;
args?: unknown;
coreArgs?: unknown;
setup?: {
world?: {
startYear?: number;
year?: number;
month?: number;
hiddenSeed?: string;
};
isolateWorld?: boolean;
generals?: Array<Record<string, unknown>>;
nations?: Array<Record<string, unknown>>;
cities?: Array<Record<string, unknown>>;
diplomacy?: Array<Record<string, unknown>>;
};
observe?: {
generalIds?: number[];
cityIds?: number[];
nationIds?: number[];
logAfterId?: number;
messageAfterId?: number;
};
}
interface RandomCall {
seq: number;
operation: string;
arguments: Record<string, unknown>;
result: unknown;
}
class TracingRng implements RNG {
public readonly calls: RandomCall[] = [];
public constructor(private readonly inner: RNG) {}
public getMaxInt(): number {
return this.inner.getMaxInt();
}
public nextBytes(bytes: number): Uint8Array<ArrayBuffer> {
const result = this.inner.nextBytes(bytes);
this.record('nextBytes', { bytes }, Buffer.from(result).toString('hex'));
return result;
}
public nextBits(bits: number): Uint8Array<ArrayBuffer> {
const result = this.inner.nextBits(bits);
this.record('nextBits', { bits }, Buffer.from(result).toString('hex'));
return result;
}
public nextInt(max?: number): number {
const result = this.inner.nextInt(max);
this.record('nextInt', { maxInclusive: max ?? null }, result);
return result;
}
public nextFloat1(): number {
const result = this.inner.nextFloat1();
this.record('nextFloat1', {}, result);
return result;
}
private record(operation: string, args: Record<string, unknown>, result: unknown): void {
this.calls.push({
seq: this.calls.length,
operation,
arguments: args,
result,
});
}
}
const asRecord = (value: unknown): Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
const readNumber = (record: Record<string, unknown>, key: string, fallback = 0): number => {
const value = record[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const readString = (record: Record<string, unknown>, key: string, fallback: string): string => {
const value = record[key];
return typeof value === 'string' ? value : fallback;
};
const readNullableString = (record: Record<string, unknown>, key: string): string | null => {
const value = record[key];
return typeof value === 'string' && value !== '' && value !== 'None' ? value : null;
};
const toDatabaseInt = (value: number): number => Math.round(value);
const COMMANDS_WITH_LEGACY_CORE_ARG_KEYS = new Set([
'che_장수대상임관',
'che_선양',
'che_증여',
'che_천도',
'che_몰수',
]);
const resolveCoreArgs = (request: TurnCommandFixtureRequest): Record<string, unknown> => {
const explicit = request.coreArgs;
if (explicit !== undefined) {
return asRecord(explicit);
}
if (COMMANDS_WITH_LEGACY_CORE_ARG_KEYS.has(request.action)) {
return asRecord(request.args);
}
return asRecord(canonicalizeTurnCommandArgs(request.args ?? {}));
};
const createCommandProfile = (request: TurnCommandFixtureRequest): TurnCommandProfile => {
if (request.kind === 'general') {
if (!GENERAL_TURN_COMMAND_KEYS.includes(request.action as (typeof GENERAL_TURN_COMMAND_KEYS)[number])) {
throw new Error(`Unknown general command: ${request.action}`);
}
return {
general: [request.action as (typeof GENERAL_TURN_COMMAND_KEYS)[number], '휴식'],
nation: ['휴식'],
};
}
if (!NATION_TURN_COMMAND_KEYS.includes(request.action as (typeof NATION_TURN_COMMAND_KEYS)[number])) {
throw new Error(`Unknown nation command: ${request.action}`);
}
return {
general: ['휴식'],
nation: [request.action as (typeof NATION_TURN_COMMAND_KEYS)[number], '휴식'],
};
};
const buildGeneral = (row: Record<string, unknown>, turnTime: Date): TurnGeneral => {
const meta = asRecord(row.meta);
const rawLastTurn = asRecord(row.lastTurn);
const lastTurn =
typeof rawLastTurn.command === 'string'
? {
command: rawLastTurn.command,
...(typeof rawLastTurn.term === 'number' ? { term: rawLastTurn.term } : {}),
...(typeof rawLastTurn.seq === 'number' ? { seq: rawLastTurn.seq } : {}),
...(Object.keys(asRecord(rawLastTurn.arg)).length > 0 ? { arg: asRecord(rawLastTurn.arg) } : {}),
}
: undefined;
return {
id: readNumber(row, 'id'),
name: readString(row, 'name', '장수'),
nationId: readNumber(row, 'nationId'),
cityId: readNumber(row, 'cityId'),
troopId: readNumber(row, 'troopId'),
stats: {
leadership: readNumber(row, 'leadership', 80),
strength: readNumber(row, 'strength', 70),
intelligence: readNumber(row, 'intelligence', 60),
},
experience: readNumber(row, 'experience'),
dedication: readNumber(row, 'dedication'),
officerLevel: readNumber(row, 'officerLevel', 1),
role: {
personality: readNullableString(row, 'personality'),
specialDomestic: readNullableString(row, 'specialDomestic'),
specialWar: readNullableString(row, 'specialWar'),
items: {
horse: readNullableString(row, 'itemHorse'),
weapon: readNullableString(row, 'itemWeapon'),
book: readNullableString(row, 'itemBook'),
item: readNullableString(row, 'itemExtra'),
},
},
injury: readNumber(row, 'injury'),
gold: readNumber(row, 'gold'),
rice: readNumber(row, 'rice'),
crew: readNumber(row, 'crew'),
crewTypeId: readNumber(row, 'crewTypeId', 1100),
train: readNumber(row, 'train'),
atmos: readNumber(row, 'atmos'),
age: readNumber(row, 'age', 30),
npcState: readNumber(row, 'npcState'),
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: {
...meta,
killturn: readNumber(row, 'killTurn', readNumber(meta, 'killturn', 24)),
leadership_exp: readNumber(row, 'leadershipExp', readNumber(meta, 'leadership_exp')),
strength_exp: readNumber(row, 'strengthExp', readNumber(meta, 'strength_exp')),
intel_exp: readNumber(row, 'intelExp', readNumber(meta, 'intel_exp')),
dex1: readNumber(row, 'dex1', readNumber(meta, 'dex1')),
dex2: readNumber(row, 'dex2', readNumber(meta, 'dex2')),
dex3: readNumber(row, 'dex3', readNumber(meta, 'dex3')),
dex4: readNumber(row, 'dex4', readNumber(meta, 'dex4')),
dex5: readNumber(row, 'dex5', readNumber(meta, 'dex5')),
explevel: readNumber(row, 'expLevel', readNumber(meta, 'explevel')),
officerCityId: readNumber(row, 'officerCityId', readNumber(meta, 'officerCityId')),
block: readNumber(row, 'blockState', readNumber(meta, 'block')),
},
...(lastTurn ? { lastTurn } : {}),
turnTime,
recentWarTime: null,
};
};
const buildNation = (row: Record<string, unknown>, generals: TurnGeneral[]): Nation => {
const id = readNumber(row, 'id');
const meta = asRecord(row.meta);
return {
id,
name: readString(row, 'name', `국가${id}`),
color: readString(row, 'color', '#777777'),
capitalCityId: readNumber(row, 'capitalCityId') || null,
chiefGeneralId: generals.find((general) => general.nationId === id && general.officerLevel === 12)?.id ?? null,
gold: readNumber(row, 'gold'),
rice: readNumber(row, 'rice'),
power: readNumber(row, 'power'),
level: readNumber(row, 'level', 1),
typeCode: readString(row, 'typeCode', 'che_중립'),
meta: {
...meta,
tech: readNumber(row, 'tech', readNumber(meta, 'tech')),
gennum: readNumber(row, 'generalCount', readNumber(meta, 'gennum')),
war: readNumber(row, 'war', readNumber(meta, 'war')),
surlimit: readNumber(row, 'diplomacyLimit', readNumber(meta, 'surlimit')),
},
};
};
const buildWorldInput = (
request: TurnCommandFixtureRequest,
referenceBefore: CanonicalTurnSnapshot,
unitSet: UnitSetDefinition,
map: MapDefinition
): { state: TurnWorldState; snapshot: TurnWorldSnapshot; map: MapDefinition } => {
const year = readNumber(referenceBefore.world, 'year', request.setup?.world?.year ?? 185);
const month = readNumber(referenceBefore.world, 'month', request.setup?.world?.month ?? 1);
const turnTime = new Date(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00.000Z`);
const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime));
const nations = referenceBefore.nations.map((row) => buildNation(row, generals));
const observedCityRows = new Map(referenceBefore.cities.map((row) => [readNumber(row, 'id'), row] as const));
const diplomacy: TurnDiplomacy[] = referenceBefore.diplomacy.map((row) => ({
fromNationId: readNumber(row, 'fromNationId'),
toNationId: readNumber(row, 'toNationId'),
state: readNumber(row, 'state', 3),
term: readNumber(row, 'term'),
dead: readNumber(row, 'dead'),
meta: {},
}));
const snapshot: TurnWorldSnapshot = {
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {
develCost: readNumber(referenceBefore.world, 'develCost'),
trainDelta: 30,
atmosDelta: 30,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
openingPartYear: 3,
initialNationGenLimit: 10,
maxGeneral: 500,
baseGold: 0,
baseRice: 2_000,
maxResourceActionAmount: 10_000,
maxTechLevel: 12,
maxLevel: 255,
maxDedLevel: 30,
upgradeLimit: 30,
},
environment: { mapName: map.id, unitSet: unitSet.id },
},
scenarioMeta: {
title: '턴 명령 차등',
startYear: request.setup?.world?.startYear ?? Math.max(1, year - 5),
life: null,
fiction: 0,
history: [],
ignoreDefaultEvents: false,
},
map,
unitSet,
nations,
cities: map.cities.map((definition) => {
const row = observedCityRows.get(definition.id) ?? {};
return {
id: definition.id,
name: readString(row, 'name', definition.name),
nationId: readNumber(row, 'nationId'),
level: readNumber(row, 'level', definition.level),
state: readNumber(row, 'state'),
population: readNumber(row, 'population', definition.initial.population),
populationMax: readNumber(row, 'populationMax', definition.max.population),
agriculture: readNumber(row, 'agriculture', definition.initial.agriculture),
agricultureMax: readNumber(row, 'agricultureMax', definition.max.agriculture),
commerce: readNumber(row, 'commerce', definition.initial.commerce),
commerceMax: readNumber(row, 'commerceMax', definition.max.commerce),
security: readNumber(row, 'security', definition.initial.security),
securityMax: readNumber(row, 'securityMax', definition.max.security),
supplyState: readNumber(row, 'supplyState', map.defaults?.supplyState ?? 1),
frontState: readNumber(row, 'frontState', map.defaults?.frontState ?? 0),
defence: readNumber(row, 'defence', definition.initial.defence),
defenceMax: readNumber(row, 'defenceMax', definition.max.defence),
wall: readNumber(row, 'wall', definition.initial.wall),
wallMax: readNumber(row, 'wallMax', definition.max.wall),
meta: {
trust: readNumber(row, 'trust', map.defaults?.trust ?? 50),
trade: readNumber(row, 'trade', map.defaults?.trade ?? 100),
term: readNumber(row, 'term'),
},
};
}),
generals,
troops: [],
diplomacy,
events: [],
initialEvents: [],
};
return {
state: {
id: 1,
currentYear: year,
currentMonth: month,
tickSeconds: readNumber(referenceBefore.world, 'tickMinutes', 10) * 60,
lastTurnTime: turnTime,
meta: {
hiddenSeed: request.setup?.world?.hiddenSeed ?? 'turn-command-differential-seed',
killturn: 24,
isUnited: readNumber(referenceBefore.world, 'isUnited'),
scenarioId: readNumber(referenceBefore.world, 'scenarioId'),
initYear: readNumber(referenceBefore.world, 'initYear', request.setup?.world?.startYear ?? year),
initMonth: readNumber(referenceBefore.world, 'initMonth', 1),
},
},
snapshot,
map,
};
};
const projectWorld = (
world: InMemoryTurnWorld,
reservedTurns: InMemoryReservedTurnStore,
logs: CanonicalTurnSnapshot['logs'],
messages: CanonicalTurnSnapshot['messages'],
selector: {
generalIds: Set<number>;
cityIds: Set<number>;
nationIds: Set<number>;
}
): CanonicalTurnSnapshot => {
const state = world.getState();
const generals = world
.listGenerals()
.filter((general) => selector.generalIds.has(general.id))
.map((general) => ({
id: general.id,
name: general.name,
nationId: general.nationId,
cityId: general.cityId,
troopId: general.troopId,
leadership: general.stats.leadership,
strength: general.stats.strength,
intelligence: general.stats.intelligence,
experience: toDatabaseInt(general.experience),
dedication: toDatabaseInt(general.dedication),
expLevel: readNumber(general.meta, 'explevel'),
officerLevel: general.officerLevel,
personality: general.role.personality,
specialDomestic: general.role.specialDomestic,
specialWar: general.role.specialWar,
itemHorse: general.role.items.horse,
itemWeapon: general.role.items.weapon,
itemBook: general.role.items.book,
itemExtra: general.role.items.item,
injury: general.injury,
gold: toDatabaseInt(general.gold),
rice: toDatabaseInt(general.rice),
crew: toDatabaseInt(general.crew),
crewTypeId: general.crewTypeId,
train: toDatabaseInt(general.train),
atmos: toDatabaseInt(general.atmos),
age: general.age,
npcState: general.npcState,
turnTime: general.turnTime.toISOString(),
recentWarTime: general.recentWarTime?.toISOString() ?? null,
lastTurn: general.lastTurn ?? null,
meta: general.meta,
leadershipExp: toDatabaseInt(readNumber(general.meta, 'leadership_exp')),
strengthExp: toDatabaseInt(readNumber(general.meta, 'strength_exp')),
intelExp: toDatabaseInt(readNumber(general.meta, 'intel_exp')),
dex1: toDatabaseInt(readNumber(general.meta, 'dex1')),
dex2: toDatabaseInt(readNumber(general.meta, 'dex2')),
dex3: toDatabaseInt(readNumber(general.meta, 'dex3')),
dex4: toDatabaseInt(readNumber(general.meta, 'dex4')),
dex5: toDatabaseInt(readNumber(general.meta, 'dex5')),
killTurn: readNumber(general.meta, 'killturn'),
mySet: readNumber(general.meta, 'myset'),
}));
const nations = world.listNations();
return {
schemaVersion: 1,
engine: 'core2026',
world: {
year: state.currentYear,
month: state.currentMonth,
tickMinutes: Math.max(1, Math.round(state.tickSeconds / 60)),
turnTime: state.lastTurnTime.toISOString(),
isUnited: readNumber(state.meta, 'isUnited'),
},
generals,
cities: world
.listCities()
.filter((city) => selector.cityIds.has(city.id))
.map((city) => ({
id: city.id,
name: city.name,
nationId: city.nationId,
level: city.level,
population: toDatabaseInt(city.population),
populationMax: city.populationMax,
agriculture: toDatabaseInt(city.agriculture),
agricultureMax: city.agricultureMax,
commerce: toDatabaseInt(city.commerce),
commerceMax: city.commerceMax,
security: toDatabaseInt(city.security),
securityMax: city.securityMax,
supplyState: city.supplyState,
frontState: city.frontState,
defence: toDatabaseInt(city.defence),
defenceMax: city.defenceMax,
wall: toDatabaseInt(city.wall),
wallMax: city.wallMax,
state: city.state,
term: readNumber(city.meta, 'term'),
trust: readNumber(city.meta, 'trust'),
trade: readNumber(city.meta, 'trade'),
})),
nations: nations
.filter((nation) => selector.nationIds.has(nation.id))
.map((nation) => ({
id: nation.id,
name: nation.name,
color: nation.color,
capitalCityId: nation.capitalCityId,
gold: toDatabaseInt(nation.gold),
rice: toDatabaseInt(nation.rice),
tech: readNumber(nation.meta, 'tech'),
level: nation.level,
typeCode: nation.typeCode,
generalCount: world.listGenerals().filter((general) => general.nationId === nation.id).length,
power: nation.power,
war: readNumber(nation.meta, 'war'),
diplomacyLimit: readNumber(nation.meta, 'surlimit'),
meta: nation.meta,
})),
diplomacy: world
.listDiplomacy()
.filter((entry) => selector.nationIds.has(entry.fromNationId) && selector.nationIds.has(entry.toNationId))
.map((entry) => ({ ...entry })),
generalTurns: generals.flatMap((general) =>
reservedTurns.getGeneralTurns(Number(general.id)).map((turn, turnIndex) => ({
generalId: general.id,
turnIndex,
action: turn.action,
args: turn.args,
}))
),
nationTurns: nations.flatMap((nation) =>
[12, 11, 10, 9, 8, 7, 6, 5].flatMap((officerLevel) =>
reservedTurns.getNationTurns(nation.id, officerLevel).map((turn, turnIndex) => ({
nationId: nation.id,
officerLevel,
turnIndex,
action: turn.action,
args: turn.args,
}))
)
),
logs,
messages,
watermarks: { logId: logs.length, messageId: messages.length },
};
};
const emptyDatabaseClient = {
generalTurn: {
findMany: async () => [],
deleteMany: async () => ({ count: 0 }),
createMany: async () => ({ count: 0 }),
},
nationTurn: {
findMany: async () => [],
deleteMany: async () => ({ count: 0 }),
createMany: async () => ({ count: 0 }),
},
};
export const runCoreTurnCommandTrace = async (
request: TurnCommandFixtureRequest,
referenceBefore: CanonicalTurnSnapshot
): Promise<CanonicalTurnCommandTrace> => {
const unitSet = await loadUnitSetDefinitionByName('che');
const map = await loadMapDefinitionByName('che');
const worldInput = buildWorldInput(request, referenceBefore, unitSet, map);
const { state, snapshot } = worldInput;
const selector = {
generalIds: new Set(referenceBefore.generals.map((row) => readNumber(row, 'id'))),
cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))),
nationIds: new Set(referenceBefore.nations.map((row) => readNumber(row, 'id'))),
};
const reservedTurns = new InMemoryReservedTurnStore(emptyDatabaseClient as never, {
maxGeneralTurns: 10,
maxNationTurns: 12,
});
await reservedTurns.loadAll();
const actor = snapshot.generals.find((general) => general.id === request.actorGeneralId);
if (!actor) {
throw new Error(`Missing actor general ${request.actorGeneralId}`);
}
const args = resolveCoreArgs(request);
if (request.kind === 'general') {
reservedTurns.getGeneralTurns(actor.id)[0] = { action: request.action, args };
} else {
reservedTurns.getNationTurns(actor.nationId, actor.officerLevel)[0] = {
action: request.action,
args,
};
}
let world: InMemoryTurnWorld | null = null;
let resolution:
| {
kind: 'nation' | 'general';
actionKey: string;
requestedAction: string;
usedFallback: boolean;
blockedReason?: string;
}
| undefined;
const commandRngCalls: RandomCall[] = [];
const handler = await createReservedTurnHandler({
reservedTurns,
scenarioConfig: snapshot.scenarioConfig,
scenarioMeta: snapshot.scenarioMeta,
map,
unitSet,
getWorld: () => world,
commandProfile: createCommandProfile(request),
commandRngFactory: ({ kind, actionKey, seed }) => {
const tracing = new TracingRng(new LiteHashDRBG(seed));
if (kind === request.kind && actionKey === request.action) {
commandRngCalls.push(...tracing.calls);
return new RandUtil({
getMaxInt: () => tracing.getMaxInt(),
nextBytes: (bytes) => {
const result = tracing.nextBytes(bytes);
commandRngCalls.splice(0, commandRngCalls.length, ...tracing.calls);
return result;
},
nextBits: (bits) => {
const result = tracing.nextBits(bits);
commandRngCalls.splice(0, commandRngCalls.length, ...tracing.calls);
return result;
},
nextInt: (max) => {
const result = tracing.nextInt(max);
commandRngCalls.splice(0, commandRngCalls.length, ...tracing.calls);
return result;
},
nextFloat1: () => {
const result = tracing.nextFloat1();
commandRngCalls.splice(0, commandRngCalls.length, ...tracing.calls);
return result;
},
});
}
return new RandUtil(new LiteHashDRBG(seed));
},
onActionResolved: (payload) => {
if (payload.kind === request.kind && payload.requestedAction === request.action) {
resolution = payload;
}
},
});
world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
generalTurnHandler: handler,
});
const before = projectWorld(world, reservedTurns, [], [], selector);
world.executeGeneralTurn(actor);
const dirty = world.peekDirtyState();
const after = projectWorld(
world,
reservedTurns,
dirty.logs.map((log, index) => ({
id: index + 1,
scope: log.scope,
category: log.category,
generalId: log.generalId ?? actor.id,
nationId: log.nationId ?? actor.nationId,
year: state.currentYear,
month: state.currentMonth,
text: log.text,
})),
dirty.messages.map((message, index) => ({
id: index + 1,
payload: message,
})),
selector
);
return {
schemaVersion: 1,
engine: 'core2026',
execution: {
kind: request.kind,
actorGeneralId: request.actorGeneralId,
action: request.action,
args,
seedDomain: request.kind === 'general' ? 'generalCommand' : 'nationCommand',
outcome: resolution,
},
before,
after,
rng: commandRngCalls,
};
};
@@ -62,3 +62,22 @@ export const runReferenceTurnCommandTrace = (workspaceRoot: string, fixturePath:
});
return JSON.parse(stdout) as CanonicalTurnCommandTrace;
};
export const runReferenceTurnCommandTraceRequest = (
workspaceRoot: string,
request: Record<string, unknown>
): CanonicalTurnCommandTrace => {
const stackDirectory = path.join(workspaceRoot, 'docker_compose_files/reference');
const runner = process.env.TURN_DIFFERENTIAL_RUNNER_SCRIPT ?? './scripts/run-turn-differential-case.sh';
const stdout = execFileSync(runner, ['-'], {
cwd: stackDirectory,
input: JSON.stringify(request),
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
TURN_DIFFERENTIAL_STACK_DIR: stackDirectory,
},
});
return JSON.parse(stdout) as CanonicalTurnCommandTrace;
};
@@ -0,0 +1,84 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js';
import {
findTurnDifferentialWorkspaceRoot,
runReferenceTurnCommandTraceRequest,
} from '../src/turn-differential/referenceSnapshot.js';
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
const ignoredLifecyclePaths = [
/^generalTurns/,
/^nationTurns/,
/^logs/,
/^messages/,
/^world\.turnTime$/,
/^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet)(?:\.|$)/,
/^generals\[[^\]]+\]\.meta(?:\.|$)/,
/^nations\[[^\]]+\]\.meta(?:\.|$)/,
];
const readFixture = (relativePath: string): TurnCommandFixtureRequest => {
const stackRoot = path.join(workspaceRoot!, 'docker_compose_files/reference');
const fixture = JSON.parse(
fs.readFileSync(path.join(stackRoot, relativePath), 'utf8')
) as TurnCommandFixtureRequest;
return {
...fixture,
setup: {
...fixture.setup,
world: {
...fixture.setup?.world,
hiddenSeed: 'turn-command-differential-seed',
},
generals: fixture.setup?.generals?.map((general) => ({
...general,
personality: 'None',
specialDomestic: 'None',
specialWar: 'None',
itemHorse: 'None',
itemWeapon: 'None',
itemBook: 'None',
itemExtra: 'None',
})),
},
};
};
integration('core ↔ legacy command-boundary differential', () => {
it.each([
['nation declaration', 'fixtures/turn-differential/nation-declaration.json'],
['live sortie conquest', 'fixtures/turn-differential/live-sortie-conquest.json'],
])(
'%s matches command RNG and canonical state delta',
async (_label, fixturePath) => {
const request = readFixture(fixturePath);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(core.execution.outcome).toMatchObject({
requestedAction: request.action,
actionKey: request.action,
usedFallback: false,
});
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});
@@ -0,0 +1,241 @@
import { describe, expect, it } from 'vitest';
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js';
import {
findTurnDifferentialWorkspaceRoot,
runReferenceTurnCommandTraceRequest,
} from '../src/turn-differential/referenceSnapshot.js';
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
const ignoredLifecyclePaths = [
/^generalTurns/,
/^nationTurns/,
/^logs/,
/^messages/,
/^world\.turnTime$/,
/^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet)(?:\.|$)/,
/^generals\[[^\]]+\]\.meta(?:\.|$)/,
/^nations\[[^\]]+\]\.meta(?:\.|$)/,
];
const general = (id: number, nationId: number, cityId: number, officerLevel: number): Record<string, unknown> => ({
id,
nationId,
cityId,
troopId: 0,
leadership: 90,
strength: 80,
intelligence: 70,
leadershipExp: 0,
strengthExp: 0,
intelExp: 0,
experience: 1000,
dedication: 1000,
expLevel: 0,
officerLevel,
officerCityId: officerLevel >= 5 ? cityId : 0,
injury: 0,
age: 30,
gold: 100_000,
rice: 100_000,
crew: 1_000,
crewTypeId: 1100,
train: 50,
atmos: 50,
dex1: 0,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
killTurn: 24,
npcState: 0,
blockState: 0,
personality: 'None',
specialDomestic: 'None',
specialWar: 'None',
itemHorse: 'None',
itemWeapon: 'None',
itemBook: 'None',
itemExtra: 'None',
meta: {},
});
const buildRequest = (
action: string,
args?: Record<string, unknown>,
actorPatch: Record<string, unknown> = {}
): TurnCommandFixtureRequest => ({
kind: 'general',
actorGeneralId: 1,
action,
...(args ? { args } : {}),
setup: {
isolateWorld: true,
world: {
startYear: 180,
year: 190,
month: 1,
hiddenSeed: 'turn-command-general-matrix-v1',
},
nations: [
{
id: 1,
name: '아국',
capitalCityId: 3,
gold: 1_000_000,
rice: 1_000_000,
tech: 1000,
level: 1,
typeCode: 'che_중립',
war: 0,
generalCount: 2,
meta: {},
},
{
id: 2,
name: '타국',
capitalCityId: 70,
gold: 1_000_000,
rice: 1_000_000,
tech: 1000,
level: 1,
typeCode: 'che_중립',
war: 0,
generalCount: 1,
meta: {},
},
],
cities: [
{
id: 3,
nationId: 1,
population: 100_000,
agriculture: 1_000,
commerce: 1_000,
security: 1_000,
defence: 1_000,
wall: 1_000,
supplyState: 1,
frontState: 0,
state: 0,
term: 0,
trust: 80,
trade: 100,
},
{
id: 70,
nationId: 2,
population: 100_000,
agriculture: 1_000,
commerce: 1_000,
security: 1_000,
defence: 1_000,
wall: 1_000,
supplyState: 1,
frontState: 1,
state: 0,
term: 0,
trust: 80,
trade: 100,
},
],
generals: [{ ...general(1, 1, 3, 12), ...actorPatch }, general(2, 2, 70, 12), general(3, 1, 3, 1)],
diplomacy: [
{ fromNationId: 1, toNationId: 2, state: 0, term: 12, dead: 0 },
{ fromNationId: 2, toNationId: 1, state: 0, term: 12, dead: 0 },
],
},
observe: {
generalIds: [1, 2, 3],
cityIds: [3, 70],
nationIds: [1, 2],
logAfterId: 0,
messageAfterId: 0,
},
});
const cases: Array<[string, Record<string, unknown> | undefined, Record<string, unknown> | undefined]> = [
['휴식', undefined, undefined],
['che_훈련', undefined, undefined],
['cr_맹훈련', undefined, undefined],
['che_전투태세', undefined, { lastTurn: { command: '전투태세', term: 3 } }],
['che_단련', undefined, undefined],
['che_사기진작', undefined, undefined],
['che_요양', undefined, { injury: 30 }],
['che_견문', undefined, undefined],
['che_주민선정', undefined, undefined],
['che_정착장려', undefined, undefined],
['che_농지개간', undefined, undefined],
['che_상업투자', undefined, undefined],
['che_기술연구', undefined, undefined],
['che_치안강화', undefined, undefined],
['che_수비강화', undefined, undefined],
['che_성벽보수', undefined, undefined],
['che_인재탐색', undefined, undefined],
['che_소집해제', undefined, undefined],
['che_군량매매', { buyRice: true, amount: 100 }, undefined],
['che_물자조달', undefined, undefined],
['che_헌납', { isGold: true, amount: 100 }, undefined],
];
integration('general command success matrix', () => {
it.each(cases)(
'%s matches the legacy state delta and command RNG',
async (action, args, actorPatch) => {
const request = buildRequest(action, args, actorPatch);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
if (process.env.TURN_DIFFERENTIAL_DEBUG === '1') {
process.stderr.write(
`${JSON.stringify(
{
action,
referenceRng: reference.rng,
coreRng: core.rng,
referenceGeneralDelta: compareTurnSnapshotDeltas(
reference.before,
reference.after,
reference.before,
reference.before,
{ ignoredPathPatterns: ignoredLifecyclePaths }
).filter((entry) => entry.path.startsWith('generals')),
coreGeneralDelta: compareTurnSnapshotDeltas(
core.before,
core.after,
core.before,
core.before,
{ ignoredPathPatterns: ignoredLifecyclePaths }
).filter((entry) => entry.path.startsWith('generals')),
referenceGenerals: reference.after.generals,
coreGenerals: core.after.generals,
},
null,
2
)}\n`
);
}
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey: action,
usedFallback: false,
});
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).not.toHaveProperty('blockedReason');
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});
@@ -0,0 +1,212 @@
import { describe, expect, it } from 'vitest';
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js';
import {
findTurnDifferentialWorkspaceRoot,
runReferenceTurnCommandTraceRequest,
} from '../src/turn-differential/referenceSnapshot.js';
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
const ignoredLifecyclePaths = [
/^generalTurns/,
/^nationTurns/,
/^logs/,
/^messages/,
/^world\.turnTime$/,
/^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet)(?:\.|$)/,
/^generals\[[^\]]+\]\.meta(?:\.|$)/,
/^nations\[[^\]]+\]\.meta(?:\.|$)/,
];
const general = (id: number, nationId: number, cityId: number, officerLevel: number): Record<string, unknown> => ({
id,
nationId,
cityId,
troopId: 0,
leadership: 90,
strength: 80,
intelligence: 70,
leadershipExp: 0,
strengthExp: 0,
intelExp: 0,
experience: 1000,
dedication: 1000,
expLevel: 0,
officerLevel,
officerCityId: officerLevel >= 5 ? cityId : 0,
injury: 0,
age: 30,
gold: 100_000,
rice: 100_000,
crew: 1_000,
crewTypeId: 1100,
train: 50,
atmos: 50,
killTurn: 24,
npcState: 0,
blockState: 0,
personality: 'None',
specialDomestic: 'None',
specialWar: 'None',
itemHorse: 'None',
itemWeapon: 'None',
itemBook: 'None',
itemExtra: 'None',
meta: {},
});
const buildRequest = (action: string, args?: Record<string, unknown>): TurnCommandFixtureRequest => ({
kind: 'nation',
actorGeneralId: 1,
action,
...(args ? { args } : {}),
setup: {
isolateWorld: true,
world: {
startYear: 180,
year: 190,
month: 1,
hiddenSeed: 'turn-command-nation-matrix-v1',
},
nations: [
{
id: 1,
name: '아국',
capitalCityId: 3,
gold: 1_000_000,
rice: 1_000_000,
tech: 1000,
level: 1,
typeCode: 'che_명가',
war: 0,
diplomacyLimit: 0,
generalCount: 2,
meta: { can_국호변경: 1, can_국기변경: 1, surlimit: 0 },
},
{
id: 2,
name: '타국',
capitalCityId: 70,
gold: 1_000_000,
rice: 1_000_000,
tech: 1000,
level: 1,
typeCode: 'che_명가',
war: 0,
diplomacyLimit: 0,
generalCount: 1,
meta: { surlimit: 0 },
},
],
cities: [
{
id: 3,
nationId: 1,
population: 100_000,
agriculture: 1_000,
commerce: 1_000,
security: 1_000,
defence: 1_000,
wall: 1_000,
supplyState: 1,
frontState: 0,
state: 0,
term: 0,
trust: 80,
trade: 100,
},
{
id: 70,
nationId: 2,
population: 100_000,
agriculture: 1_000,
commerce: 1_000,
security: 1_000,
defence: 1_000,
wall: 1_000,
supplyState: 1,
frontState: 1,
state: 0,
term: 0,
trust: 80,
trade: 100,
},
],
generals: [general(1, 1, 3, 12), general(2, 2, 70, 12), general(3, 1, 3, 1)],
diplomacy: [
{ fromNationId: 1, toNationId: 2, state: 3, term: 0, dead: 0 },
{ fromNationId: 2, toNationId: 1, state: 3, term: 0, dead: 0 },
],
},
observe: {
generalIds: [1, 2, 3],
cityIds: [3, 70],
nationIds: [1, 2],
logAfterId: 0,
messageAfterId: 0,
},
});
const cases: Array<[string, Record<string, unknown> | undefined]> = [
['휴식', undefined],
['che_포상', { isGold: true, amount: 100, destGeneralID: 3 }],
['che_선전포고', { destNationID: 2 }],
['che_국호변경', { nationName: '신아국' }],
['che_국기변경', { colorType: 1 }],
['che_몰수', { isGold: true, amount: 100, destGeneralID: 3 }],
['che_물자원조', { destNationID: 2, amountList: [100, 200] }],
['che_불가침제의', { destNationID: 2, year: 191, month: 1 }],
];
integration('nation command success matrix', () => {
it.each(cases)(
'%s matches the legacy state delta and command RNG',
async (action, args) => {
const request = buildRequest(action, args);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
if (process.env.TURN_DIFFERENTIAL_DEBUG === '1') {
process.stderr.write(
`${JSON.stringify(
{
action,
referenceOutcome: reference.execution.outcome,
coreOutcome: core.execution.outcome,
differences: compareTurnSnapshotDeltas(
reference.before,
reference.after,
core.before,
core.after,
{ ignoredPathPatterns: ignoredLifecyclePaths }
),
},
null,
2
)}\n`
);
}
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey: action,
usedFallback: false,
});
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).not.toHaveProperty('blockedReason');
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});