fix general self-state command parity
This commit is contained in:
@@ -30,9 +30,7 @@ type InclusiveRandomGenerator = GeneralActionResolveContext['rng'] & {
|
||||
|
||||
const legacyChoiceIndex = (rng: GeneralActionResolveContext['rng'], length: number): number => {
|
||||
const inclusive = rng as InclusiveRandomGenerator;
|
||||
return inclusive.nextIntInclusive
|
||||
? inclusive.nextIntInclusive(length - 1)
|
||||
: rng.nextInt(0, length);
|
||||
return inclusive.nextIntInclusive ? inclusive.nextIntInclusive(length - 1) : rng.nextInt(0, length);
|
||||
};
|
||||
|
||||
const SIGHTSEEING_MESSAGES: Array<{
|
||||
@@ -71,22 +69,25 @@ const SIGHTSEEING_MESSAGES: Array<{
|
||||
},
|
||||
{
|
||||
flags: IncExp | IncGold,
|
||||
texts: ['지나가는 행인에게서 금을 :goldAmount: 받았습니다.'],
|
||||
texts: ['지나가는 행인에게서 금을 <C>:goldAmount:</> 받았습니다.'],
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
flags: IncExp | IncRice,
|
||||
texts: ['지나가는 행인에게서 쌀을 :riceAmount: 받았습니다.'],
|
||||
texts: ['지나가는 행인에게서 쌀을 <C>:riceAmount:</> 받았습니다.'],
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
flags: IncExp | DecGold,
|
||||
texts: ['산적을 만나 금 :goldAmount:을 빼앗겼습니다.', '돈을 :goldAmount: 빌려주었다가 떼어먹혔습니다.'],
|
||||
texts: [
|
||||
'산적을 만나 금 <C>:goldAmount:</>을 빼앗겼습니다.',
|
||||
'돈을 <C>:goldAmount:</> 빌려주었다가 떼어먹혔습니다.',
|
||||
],
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
flags: IncExp | DecRice,
|
||||
texts: ['쌀을 :riceAmount: 빌려주었다가 떼어먹혔습니다.'],
|
||||
texts: ['쌀을 <C>:riceAmount:</> 빌려주었다가 떼어먹혔습니다.'],
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
@@ -111,22 +112,25 @@ const SIGHTSEEING_MESSAGES: Array<{
|
||||
},
|
||||
{
|
||||
flags: IncHeavyExp | IncStrength | IncGold,
|
||||
texts: ['산적과 싸워 금 :goldAmount:을 빼앗았습니다.'],
|
||||
texts: ['산적과 싸워 금 <C>:goldAmount:</>을 빼앗았습니다.'],
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
flags: IncHeavyExp | IncStrength | IncRice,
|
||||
texts: ['호랑이를 잡아 고기 :riceAmount:을 얻었습니다.', '곰을 잡아 고기 :riceAmount:을 얻었습니다.'],
|
||||
texts: [
|
||||
'호랑이를 잡아 고기 <C>:riceAmount:</>을 얻었습니다.',
|
||||
'곰을 잡아 고기 <C>:riceAmount:</>을 얻었습니다.',
|
||||
],
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
flags: IncHeavyExp | IncIntel | IncGold,
|
||||
texts: ['돈을 빌려주었다가 이자 :goldAmount:을 받았습니다.'],
|
||||
texts: ['돈을 빌려주었다가 이자 <C>:goldAmount:</>을 받았습니다.'],
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
flags: IncHeavyExp | IncIntel | IncRice,
|
||||
texts: ['쌀을 빌려주었다가 이자 :riceAmount:을 받았습니다.'],
|
||||
texts: ['쌀을 빌려주었다가 이자 <C>:riceAmount:</>을 받았습니다.'],
|
||||
weight: 1,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -12,6 +12,39 @@ 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 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 = [
|
||||
/^generalTurns/,
|
||||
/^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', () => {
|
||||
it('화계 increments firenum from the same seeded value as legacy', async () => {
|
||||
const request = buildRequest(
|
||||
|
||||
Reference in New Issue
Block a user