fix general self-state command parity

This commit is contained in:
2026-07-26 15:00:43 +00:00
parent 97f77ce96b
commit e6fe5cda63
2 changed files with 267 additions and 11 deletions
@@ -30,9 +30,7 @@ type InclusiveRandomGenerator = GeneralActionResolveContext['rng'] & {
const legacyChoiceIndex = (rng: GeneralActionResolveContext['rng'], length: number): number => { const legacyChoiceIndex = (rng: GeneralActionResolveContext['rng'], length: number): number => {
const inclusive = rng as InclusiveRandomGenerator; const inclusive = rng as InclusiveRandomGenerator;
return inclusive.nextIntInclusive return inclusive.nextIntInclusive ? inclusive.nextIntInclusive(length - 1) : rng.nextInt(0, length);
? inclusive.nextIntInclusive(length - 1)
: rng.nextInt(0, length);
}; };
const SIGHTSEEING_MESSAGES: Array<{ const SIGHTSEEING_MESSAGES: Array<{
@@ -71,22 +69,25 @@ const SIGHTSEEING_MESSAGES: Array<{
}, },
{ {
flags: IncExp | IncGold, flags: IncExp | IncGold,
texts: ['지나가는 행인에게서 금을 :goldAmount: 받았습니다.'], texts: ['지나가는 행인에게서 금을 <C>:goldAmount:</> 받았습니다.'],
weight: 1, weight: 1,
}, },
{ {
flags: IncExp | IncRice, flags: IncExp | IncRice,
texts: ['지나가는 행인에게서 쌀을 :riceAmount: 받았습니다.'], texts: ['지나가는 행인에게서 쌀을 <C>:riceAmount:</> 받았습니다.'],
weight: 1, weight: 1,
}, },
{ {
flags: IncExp | DecGold, flags: IncExp | DecGold,
texts: ['산적을 만나 금 :goldAmount:을 빼앗겼습니다.', '돈을 :goldAmount: 빌려주었다가 떼어먹혔습니다.'], texts: [
'산적을 만나 금 <C>:goldAmount:</>을 빼앗겼습니다.',
'돈을 <C>:goldAmount:</> 빌려주었다가 떼어먹혔습니다.',
],
weight: 1, weight: 1,
}, },
{ {
flags: IncExp | DecRice, flags: IncExp | DecRice,
texts: ['쌀을 :riceAmount: 빌려주었다가 떼어먹혔습니다.'], texts: ['쌀을 <C>:riceAmount:</> 빌려주었다가 떼어먹혔습니다.'],
weight: 1, weight: 1,
}, },
{ {
@@ -111,22 +112,25 @@ const SIGHTSEEING_MESSAGES: Array<{
}, },
{ {
flags: IncHeavyExp | IncStrength | IncGold, flags: IncHeavyExp | IncStrength | IncGold,
texts: ['산적과 싸워 금 :goldAmount:을 빼앗았습니다.'], texts: ['산적과 싸워 금 <C>:goldAmount:</>을 빼앗았습니다.'],
weight: 1, weight: 1,
}, },
{ {
flags: IncHeavyExp | IncStrength | IncRice, flags: IncHeavyExp | IncStrength | IncRice,
texts: ['호랑이를 잡아 고기 :riceAmount:을 얻었습니다.', '곰을 잡아 고기 :riceAmount:을 얻었습니다.'], texts: [
'호랑이를 잡아 고기 <C>:riceAmount:</>을 얻었습니다.',
'곰을 잡아 고기 <C>:riceAmount:</>을 얻었습니다.',
],
weight: 1, weight: 1,
}, },
{ {
flags: IncHeavyExp | IncIntel | IncGold, flags: IncHeavyExp | IncIntel | IncGold,
texts: ['돈을 빌려주었다가 이자 :goldAmount:을 받았습니다.'], texts: ['돈을 빌려주었다가 이자 <C>:goldAmount:</>을 받았습니다.'],
weight: 1, weight: 1,
}, },
{ {
flags: IncHeavyExp | IncIntel | IncRice, flags: IncHeavyExp | IncIntel | IncRice,
texts: ['쌀을 빌려주었다가 이자 :riceAmount:을 받았습니다.'], texts: ['쌀을 빌려주었다가 이자 <C>:riceAmount:</>을 받았습니다.'],
weight: 1, weight: 1,
}, },
]; ];
@@ -12,6 +12,39 @@ const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd()); const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1'); const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
const normalizeStoredLogText = (value: unknown): string =>
String(value)
.replace(/^(?:<C>●<\/>|<S>◆<\/>|<R>★<\/>)(?:(?:\d+년 )?\d+월:|\d+년:)?/, '')
.replace(/ ?<1>\d{2}:\d{2}<\/>$/, '');
const semanticLogSignatures = (logs: Array<Record<string, unknown>>): string[] =>
logs
.filter((entry) => normalizeStoredLogText(entry.text) !== '아무것도 실행하지 않았습니다.')
.map((entry) =>
JSON.stringify({
scope: String(entry.scope).toLowerCase(),
category: String(entry.category).toLowerCase(),
generalId: Number(entry.generalId) || null,
nationId: Number(entry.nationId) || null,
text: normalizeStoredLogText(entry.text),
})
)
.sort();
const addedReferenceLogs = (
before: { watermarks: { logId: number; historyLogId: number } },
afterLogs: Array<Record<string, unknown>>
): Array<Record<string, unknown>> =>
afterLogs.filter((entry) => {
const scope = String(entry.scope).toLowerCase();
const category = String(entry.category).toLowerCase();
const watermark =
scope === 'nation' || (scope === 'system' && category === 'history')
? before.watermarks.historyLogId
: before.watermarks.logId;
return (Number(entry.id) || 0) > watermark;
});
const ignoredLifecyclePaths = [ const ignoredLifecyclePaths = [
/^generalTurns/, /^generalTurns/,
/^nationTurns/, /^nationTurns/,
@@ -385,6 +418,225 @@ integration('general command success matrix', () => {
); );
}); });
interface SelfStateBoundaryCase {
name: string;
action: 'che_단련' | 'che_숙련전환' | 'che_사기진작' | 'che_요양';
args?: Record<string, unknown>;
actorPatch?: Record<string, unknown>;
fixturePatches?: FixturePatches;
completed: boolean;
}
const selfStateBoundaryCases: SelfStateBoundaryCase[] = [
{ name: 'drill rejects a neutral actor', action: 'che_단련', actorPatch: { nationId: 0 }, completed: false },
{ name: 'drill rejects zero crew', action: 'che_단련', actorPatch: { crew: 0 }, completed: false },
{
name: 'drill rejects training one below the floor',
action: 'che_단련',
actorPatch: { train: 39 },
completed: false,
},
{
name: 'drill rejects morale one below the floor',
action: 'che_단련',
actorPatch: { atmos: 39 },
completed: false,
},
{ name: 'drill rejects insufficient gold', action: 'che_단련', actorPatch: { gold: 0 }, completed: false },
{ name: 'drill rejects insufficient rice', action: 'che_단련', actorPatch: { rice: 0 }, completed: false },
{
name: 'drill accepts the exact training and morale floors',
action: 'che_단련',
actorPatch: { train: 40, atmos: 40, crew: 1001 },
completed: true,
},
{
name: 'dex transfer rejects numeric-string arm types',
action: 'che_숙련전환',
args: { srcArmType: '1', destArmType: 2 },
completed: false,
},
{
name: 'dex transfer rejects fractional arm types',
action: 'che_숙련전환',
args: { srcArmType: 1.9, destArmType: 2 },
completed: false,
},
{
name: 'dex transfer rejects the same source and destination',
action: 'che_숙련전환',
args: { srcArmType: 1, destArmType: 1 },
completed: false,
},
{
name: 'dex transfer rejects an unknown arm type',
action: 'che_숙련전환',
args: { srcArmType: 99, destArmType: 2 },
completed: false,
},
{
name: 'dex transfer rejects a neutral actor',
action: 'che_숙련전환',
args: { srcArmType: 1, destArmType: 2 },
actorPatch: { nationId: 0 },
completed: false,
},
{
name: 'dex transfer rejects a foreign-occupied city',
action: 'che_숙련전환',
args: { srcArmType: 1, destArmType: 2 },
fixturePatches: { cities: { 3: { nationId: 2 } } },
completed: false,
},
{
name: 'dex transfer rejects insufficient gold',
action: 'che_숙련전환',
args: { srcArmType: 1, destArmType: 2 },
actorPatch: { gold: 0 },
completed: false,
},
{
name: 'dex transfer rejects insufficient rice',
action: 'che_숙련전환',
args: { srcArmType: 1, destArmType: 2 },
actorPatch: { rice: 0 },
completed: false,
},
{
name: 'dex transfer preserves integer truncation at a low source value',
action: 'che_숙련전환',
args: { srcArmType: 1, destArmType: 2 },
actorPatch: { dex1: 3, dex2: 7 },
completed: true,
},
{
name: 'morale boost rejects a neutral actor',
action: 'che_사기진작',
actorPatch: { nationId: 0 },
completed: false,
},
{
name: 'morale boost rejects a wandering nation',
action: 'che_사기진작',
fixturePatches: { nations: { 1: { level: 0 } } },
completed: false,
},
{
name: 'morale boost rejects a foreign-occupied city',
action: 'che_사기진작',
fixturePatches: { cities: { 3: { nationId: 2 } } },
completed: false,
},
{ name: 'morale boost rejects zero crew', action: 'che_사기진작', actorPatch: { crew: 0 }, completed: false },
{
name: 'morale boost rejects insufficient gold',
action: 'che_사기진작',
actorPatch: { gold: 0 },
completed: false,
},
{
name: 'morale boost rejects the command maximum',
action: 'che_사기진작',
actorPatch: { atmos: 100 },
completed: false,
},
{
name: 'morale boost clamps at the command maximum',
action: 'che_사기진작',
actorPatch: { atmos: 99, train: 51, crew: 1001 },
completed: true,
},
{
name: 'recovery always clears injury without resource cost',
action: 'che_요양',
actorPatch: { nationId: 0, injury: 80, gold: 0, rice: 0 },
completed: true,
},
];
integration('general self-state command boundary parity', () => {
it.each(selfStateBoundaryCases)(
'$name',
async ({ name, action, args, actorPatch, fixturePatches, completed }) => {
const request = buildRequest(action, args, actorPatch, fixturePatches);
request.setup!.world!.hiddenSeed = `general-self-state-${name}`;
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed });
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey: completed ? action : '휴식',
usedFallback: !completed,
});
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
if (completed) {
const referenceActor = reference.after.generals.find((entry) => entry.id === 1);
const coreActor = core.after.generals.find((entry) => entry.id === 1);
expect(coreActor).toMatchObject({
dex1: referenceActor?.dex1,
dex2: referenceActor?.dex2,
leadershipExp: referenceActor?.leadershipExp,
experience: referenceActor?.experience,
dedication: referenceActor?.dedication,
injury: referenceActor?.injury,
gold: referenceActor?.gold,
rice: referenceActor?.rice,
train: referenceActor?.train,
atmos: referenceActor?.atmos,
});
}
},
120_000
);
});
integration('general sightseeing event, RNG, value, and log parity', () => {
it.each(Array.from({ length: 20 }, (_, index) => index))(
'matches legacy sightseeing seed %i',
async (seedIndex) => {
const request = buildRequest(
'che_견문',
undefined,
{ injury: seedIndex % 2 === 0 ? 0 : 75, gold: seedIndex % 3 === 0 ? 100 : 100_000, rice: 100 },
{ world: { hiddenSeed: `general-sightseeing-${seedIndex}` } }
);
request.observe!.includeGlobalHistoryLogs = true;
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).toMatchObject({
requestedAction: 'che_견문',
actionKey: 'che_견문',
usedFallback: false,
});
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
expect(semanticLogSignatures(core.after.logs)).toEqual(
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
);
},
120_000
);
});
integration('명장일람 rank_data command parity', () => { integration('명장일람 rank_data command parity', () => {
it('화계 increments firenum from the same seeded value as legacy', async () => { it('화계 increments firenum from the same seeded value as legacy', async () => {
const request = buildRequest( const request = buildRequest(