Merge branch 'main' into feature/main-signup-kakao-gate

# Conflicts:
#	app/gateway-frontend/src/views/LobbyView.vue
This commit is contained in:
2026-07-26 10:17:37 +00:00
22 changed files with 2313 additions and 43 deletions
@@ -308,6 +308,19 @@ const buildRequest = (
dead: 0,
...fixturePatches.diplomacy?.['2:1'],
},
...Object.entries(fixturePatches.diplomacy ?? {})
.filter(([key]) => !['1:2', '2:1'].includes(key))
.map(([key, patch]) => {
const [fromNationId, toNationId] = key.split(':').map(Number);
return {
fromNationId,
toNationId,
state: 3,
term: 0,
dead: 0,
...patch,
};
}),
],
},
observe: {
@@ -331,12 +344,35 @@ const buildRequest = (
...(fixturePatches.randomFoundingCandidateCityIds ?? []).filter((id) => id !== 3 && id !== 70),
],
nationIds: [1, 2],
...(action === 'che_백성동원' || action === 'che_이호경식'
...(fixturePatches.diplomacy
? {
diplomacyPairs: Object.keys(fixturePatches.diplomacy)
.filter((key) => !['1:2', '2:1'].includes(key))
.map((key) => {
const [fromNationId, toNationId] = key.split(':').map(Number);
return { fromNationId, toNationId };
}),
}
: {}),
...(action === 'che_백성동원' ||
action === 'che_이호경식' ||
action === 'che_급습' ||
action === 'che_필사즉생' ||
action === 'che_허보'
? {
nationCooldowns: [
{
nationId: 1,
actionName: action === 'che_백성동원' ? '백성동원' : '이호경식',
actionName:
action === 'che_백성동원'
? '백성동원'
: action === 'che_이호경식'
? '이호경식'
: action === 'che_급습'
? '급습'
: action === 'che_필사즉생'
? '필사즉생'
: '허보',
},
],
}
@@ -3265,3 +3301,933 @@ integration('nation degrade-relations target, diplomacy, front, and cooldown bou
120_000
);
});
const surpriseAttackBoundaryCases: Array<{
name: string;
destNationId: unknown;
fixturePatches?: FixturePatches;
completed?: boolean;
coreResolution?: boolean;
expectedForwardState?: number;
expectedReverseState?: number;
expectedForwardTerm?: number;
expectedReverseTerm?: number;
expectedSourceFront?: number;
expectedDestFront?: number;
expectedStrategicCommandLimit?: number;
expectedPostReqTurn?: number;
expectedDestLogGeneralId?: number;
}> = [
{
name: 'rejects a missing destination nation',
destNationId: 99,
},
{
name: 'rejects a numeric string destination nation ID',
destNationId: '2',
},
{
name: 'rejects a fractional destination nation ID',
destNationId: 2.9,
},
{
name: 'rejects a source city occupied by another nation',
destNationId: 2,
fixturePatches: {
cities: { 3: { nationId: 2 } },
diplomacy: { '1:2': { state: 1, term: 12 } },
},
},
{
name: 'rejects an actor below chief rank',
destNationId: 2,
fixturePatches: {
generals: { 1: { officerLevel: 4, officerCityId: 0 } },
diplomacy: { '1:2': { state: 1, term: 12 } },
},
coreResolution: false,
},
{
name: 'rejects a remaining strategic-command delay',
destNationId: 2,
fixturePatches: {
nations: { 1: { strategicCommandLimit: 1 } },
diplomacy: { '1:2': { state: 1, term: 12 } },
},
},
{
name: 'rejects a forward diplomacy state other than declaration',
destNationId: 2,
fixturePatches: {
diplomacy: { '1:2': { state: 0, term: 12 } },
},
},
{
name: 'rejects declaration term eleven at the lower boundary',
destNationId: 2,
fixturePatches: {
diplomacy: { '1:2': { state: 1, term: 11 } },
},
},
{
name: 'allows an unsupplied source city and preserves both fronts',
destNationId: 2,
fixturePatches: {
cities: {
3: { supplyState: 0, frontState: 2 },
70: { frontState: 3 },
},
diplomacy: {
'1:2': { state: 1, term: 12 },
'2:1': { state: 1, term: 12 },
},
},
completed: true,
expectedForwardState: 1,
expectedReverseState: 1,
expectedForwardTerm: 9,
expectedReverseTerm: 9,
expectedSourceFront: 2,
expectedDestFront: 3,
expectedPostReqTurn: 126,
},
{
name: 'subtracts three from a disallowed reverse state into a negative term',
destNationId: 2,
fixturePatches: {
diplomacy: {
'1:2': { state: 1, term: 15 },
'2:1': { state: 2, term: 2 },
},
},
completed: true,
expectedForwardState: 1,
expectedReverseState: 2,
expectedForwardTerm: 12,
expectedReverseTerm: -1,
expectedSourceFront: 0,
expectedDestFront: 1,
expectedPostReqTurn: 126,
},
{
name: 'allows the source nation when a self-diplomacy row is present',
destNationId: 1,
fixturePatches: {
diplomacy: {
'1:1': { state: 1, term: 12 },
},
},
completed: true,
expectedForwardState: 1,
expectedReverseState: 1,
expectedForwardTerm: 9,
expectedReverseTerm: 9,
expectedSourceFront: 0,
expectedDestFront: 0,
expectedPostReqTurn: 126,
expectedDestLogGeneralId: 1,
},
{
name: 'applies the strategist command and global-delay modifiers',
destNationId: 2,
fixturePatches: {
nations: { 1: { typeCode: 'che_종횡가' } },
diplomacy: {
'1:2': { state: 1, term: 12 },
'2:1': { state: 1, term: 12 },
},
},
completed: true,
expectedForwardState: 1,
expectedReverseState: 1,
expectedForwardTerm: 9,
expectedReverseTerm: 9,
expectedSourceFront: 0,
expectedDestFront: 1,
expectedStrategicCommandLimit: 5,
expectedPostReqTurn: 95,
},
{
name: 'uses the actual eleven-general count for the nation cooldown',
destNationId: 2,
fixturePatches: {
nations: { 1: { generalCount: 11 } },
generals: {
4: { nationId: 1 },
5: { nationId: 1 },
6: { nationId: 1 },
7: { nationId: 1 },
8: { nationId: 1 },
9: { nationId: 1 },
10: { nationId: 1 },
11: { nationId: 1 },
12: { nationId: 1 },
},
diplomacy: {
'1:2': { state: 1, term: 12 },
'2:1': { state: 1, term: 12 },
},
},
completed: true,
expectedForwardState: 1,
expectedReverseState: 1,
expectedForwardTerm: 9,
expectedReverseTerm: 9,
expectedSourceFront: 0,
expectedDestFront: 1,
expectedPostReqTurn: 133,
},
];
integration('nation surprise-attack target, diplomacy-term, front, and cooldown boundaries', () => {
it.each(surpriseAttackBoundaryCases)(
'$name matches legacy fallback, diplomacy, fronts, cooldown, logs, RNG, and semantic delta',
async ({
destNationId,
fixturePatches,
completed = false,
coreResolution = true,
expectedForwardState,
expectedReverseState,
expectedForwardTerm,
expectedReverseTerm,
expectedSourceFront,
expectedDestFront,
expectedStrategicCommandLimit = 9,
expectedPostReqTurn,
expectedDestLogGeneralId = 2,
}) => {
const request = buildRequest('che_급습', { destNationID: destNationId }, fixturePatches);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed });
if (coreResolution) {
expect(core.execution.outcome).toMatchObject({
requestedAction: 'che_급습',
actionKey: completed ? 'che_급습' : '휴식',
usedFallback: !completed,
});
} else {
expect(core.execution.outcome).toBeUndefined();
}
for (const snapshot of [reference, core]) {
const actualDestNationId = destNationId === 1 ? 1 : 2;
const actualDestCityId = actualDestNationId === 1 ? 3 : 70;
const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1);
const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1);
const generalBefore = snapshot.before.generals.find((entry) => entry.id === 1);
const generalAfter = snapshot.after.generals.find((entry) => entry.id === 1);
const sourceCityBefore = snapshot.before.cities.find((entry) => entry.id === 3);
const sourceCityAfter = snapshot.after.cities.find((entry) => entry.id === 3);
const destCityBefore = snapshot.before.cities.find((entry) => entry.id === actualDestCityId);
const destCityAfter = snapshot.after.cities.find((entry) => entry.id === actualDestCityId);
const forwardBefore = snapshot.before.diplomacy.find(
(entry) => entry.fromNationId === 1 && entry.toNationId === actualDestNationId
);
const forwardAfter = snapshot.after.diplomacy.find(
(entry) => entry.fromNationId === 1 && entry.toNationId === actualDestNationId
);
const reverseBefore = snapshot.before.diplomacy.find(
(entry) => entry.fromNationId === actualDestNationId && entry.toNationId === 1
);
const reverseAfter = snapshot.after.diplomacy.find(
(entry) => entry.fromNationId === actualDestNationId && entry.toNationId === 1
);
expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0);
expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0);
expect(
readNumericField(generalAfter, 'experience') - readNumericField(generalBefore, 'experience')
).toBe(completed ? 5 : 0);
expect(
readNumericField(generalAfter, 'dedication') - readNumericField(generalBefore, 'dedication')
).toBe(completed ? 5 : 0);
if (completed) {
expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(expectedStrategicCommandLimit);
expect(readNumericField(forwardAfter, 'state')).toBe(expectedForwardState);
expect(readNumericField(reverseAfter, 'state')).toBe(expectedReverseState);
expect(readNumericField(forwardAfter, 'term')).toBe(expectedForwardTerm);
expect(readNumericField(reverseAfter, 'term')).toBe(expectedReverseTerm);
expect(readNumericField(sourceCityAfter, 'frontState')).toBe(expectedSourceFront);
expect(readNumericField(destCityAfter, 'frontState')).toBe(expectedDestFront);
const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length);
expect(addedLogs.map((entry) => entry.text)).toEqual(
expect.arrayContaining([expect.stringContaining('급습 발동')])
);
expect(
addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('급습'))
).toBe(true);
expect(
addedLogs.some(
(entry) =>
entry.generalId === expectedDestLogGeneralId &&
String(entry.text).includes('아국에 <M>급습</>이 발동되었습니다.')
)
).toBe(true);
} else {
expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(
readNumericField(nationBefore, 'strategicCommandLimit')
);
expect(forwardAfter).toEqual(forwardBefore);
expect(reverseAfter).toEqual(reverseBefore);
expect(readNumericField(sourceCityAfter, 'frontState')).toBe(
readNumericField(sourceCityBefore, 'frontState')
);
expect(readNumericField(destCityAfter, 'frontState')).toBe(
readNumericField(destCityBefore, 'frontState')
);
}
}
if (completed) {
const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!;
expect(reference.after.world.nationCooldowns).toEqual([
{
nationId: 1,
actionName: '급습',
nextAvailableTurn: expectedNextAvailableTurn,
},
]);
expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns);
}
expect(reference.rng).toEqual([]);
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});
const desperateSurvivalBoundaryCases: Array<{
name: string;
fixturePatches?: FixturePatches;
outcome: 'fallback' | 'intermediate' | 'completed';
coreResolution?: boolean;
expectedLastTerm?: number;
expectedStrategicCommandLimit?: number;
expectedPostReqTurn?: number;
}> = [
{
name: 'rejects a nation without an outgoing war',
outcome: 'fallback',
},
{
name: 'rejects a reverse-only war',
fixturePatches: {
diplomacy: {
'1:2': { state: 3 },
'2:1': { state: 0 },
},
},
outcome: 'fallback',
},
{
name: 'rejects a source city occupied by another nation',
fixturePatches: {
cities: { 3: { nationId: 2 } },
diplomacy: { '1:2': { state: 0 } },
},
outcome: 'fallback',
},
{
name: 'rejects an actor below chief rank',
fixturePatches: {
generals: { 1: { officerLevel: 4, officerCityId: 0 } },
diplomacy: { '1:2': { state: 0 } },
},
outcome: 'fallback',
coreResolution: false,
},
{
name: 'rejects a remaining strategic-command delay',
fixturePatches: {
nations: { 1: { strategicCommandLimit: 1 } },
diplomacy: { '1:2': { state: 0 } },
},
outcome: 'fallback',
},
{
name: 'allows an unsupplied city at the first intermediate turn',
fixturePatches: {
cities: { 3: { supplyState: 0 } },
diplomacy: { '1:2': { state: 0 } },
},
outcome: 'intermediate',
expectedLastTerm: 1,
},
{
name: 'advances the second intermediate turn without side effects',
fixturePatches: {
nations: {
1: {
turnLastByOfficerLevel: {
12: { command: '필사즉생', arg: {}, term: 1 },
},
},
},
diplomacy: { '1:2': { state: 0 } },
},
outcome: 'intermediate',
expectedLastTerm: 2,
},
{
name: 'completes with an outgoing war even when the reverse relation is trade',
fixturePatches: {
nations: {
1: {
turnLastByOfficerLevel: {
12: { command: '필사즉생', arg: {}, term: 2 },
},
},
},
diplomacy: {
'1:2': { state: 0, term: 0 },
'2:1': { state: 3, term: 0 },
},
},
outcome: 'completed',
expectedPostReqTurn: 87,
},
{
name: 'preserves training and morale values already above one hundred',
fixturePatches: {
generals: {
1: { train: 120, atmos: 110 },
3: { train: 105, atmos: 130 },
},
nations: {
1: {
turnLastByOfficerLevel: {
12: { command: '필사즉생', arg: {}, term: 2 },
},
},
},
diplomacy: { '1:2': { state: 0 } },
},
outcome: 'completed',
expectedPostReqTurn: 87,
},
{
name: 'accepts an explicit self-war as the outgoing war relation',
fixturePatches: {
nations: {
1: {
turnLastByOfficerLevel: {
12: { command: '필사즉생', arg: {}, term: 2 },
},
},
},
diplomacy: {
'1:1': { state: 0 },
},
},
outcome: 'completed',
expectedPostReqTurn: 87,
},
{
name: 'restarts at term one after a different command interrupted the stack',
fixturePatches: {
nations: {
1: {
turnLastByOfficerLevel: {
12: { command: '급습', arg: { destNationID: 2 }, term: 2 },
},
},
},
diplomacy: { '1:2': { state: 0 } },
},
outcome: 'intermediate',
expectedLastTerm: 1,
},
{
name: 'applies the strategist command and global-delay modifiers',
fixturePatches: {
nations: {
1: {
typeCode: 'che_종횡가',
turnLastByOfficerLevel: {
12: { command: '필사즉생', arg: {}, term: 2 },
},
},
},
diplomacy: { '1:2': { state: 0 } },
},
outcome: 'completed',
expectedStrategicCommandLimit: 5,
expectedPostReqTurn: 65,
},
{
name: 'uses the actual eleven-general count for the nation cooldown',
fixturePatches: {
nations: {
1: {
generalCount: 11,
turnLastByOfficerLevel: {
12: { command: '필사즉생', arg: {}, term: 2 },
},
},
},
generals: {
4: { nationId: 1 },
5: { nationId: 1 },
6: { nationId: 1 },
7: { nationId: 1 },
8: { nationId: 1 },
9: { nationId: 1 },
10: { nationId: 1 },
11: { nationId: 1 },
12: { nationId: 1 },
},
diplomacy: { '1:2': { state: 0 } },
},
outcome: 'completed',
expectedPostReqTurn: 92,
},
];
integration('nation desperate-survival multistep, diplomacy, effects, and cooldown boundaries', () => {
it.each(desperateSurvivalBoundaryCases)(
'$name matches legacy progress, effects, cooldown, logs, RNG, and semantic delta',
async ({
fixturePatches,
outcome,
coreResolution = true,
expectedLastTerm,
expectedStrategicCommandLimit = 9,
expectedPostReqTurn,
}) => {
const request = buildRequest('che_필사즉생', undefined, fixturePatches);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
const completed = outcome === 'completed';
const fallback = outcome === 'fallback';
expect(reference.execution.outcome).toMatchObject({ completed });
if (coreResolution) {
expect(core.execution.outcome).toMatchObject({
requestedAction: 'che_필사즉생',
actionKey: fallback ? '휴식' : 'che_필사즉생',
usedFallback: fallback,
...(fallback ? {} : { completed }),
});
} else {
expect(core.execution.outcome).toBeUndefined();
}
for (const snapshot of [reference, core]) {
const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1);
const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1);
const actorBefore = snapshot.before.generals.find((entry) => entry.id === 1);
const actorAfter = snapshot.after.generals.find((entry) => entry.id === 1);
const teammateBefore = snapshot.before.generals.find((entry) => entry.id === 3);
const teammateAfter = snapshot.after.generals.find((entry) => entry.id === 3);
const foreignBefore = snapshot.before.generals.find((entry) => entry.id === 2);
const foreignAfter = snapshot.after.generals.find((entry) => entry.id === 2);
expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0);
expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0);
expect(readNumericField(actorAfter, 'experience') - readNumericField(actorBefore, 'experience')).toBe(
completed ? 15 : 0
);
expect(readNumericField(actorAfter, 'dedication') - readNumericField(actorBefore, 'dedication')).toBe(
completed ? 15 : 0
);
expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(
completed ? expectedStrategicCommandLimit : readNumericField(nationBefore, 'strategicCommandLimit')
);
for (const [before, after] of [
[actorBefore, actorAfter],
[teammateBefore, teammateAfter],
] as const) {
expect(readNumericField(after, 'train')).toBe(
completed ? Math.max(100, readNumericField(before, 'train')) : readNumericField(before, 'train')
);
expect(readNumericField(after, 'atmos')).toBe(
completed ? Math.max(100, readNumericField(before, 'atmos')) : readNumericField(before, 'atmos')
);
}
expect(readNumericField(foreignAfter, 'train')).toBe(readNumericField(foreignBefore, 'train'));
expect(readNumericField(foreignAfter, 'atmos')).toBe(readNumericField(foreignBefore, 'atmos'));
if (completed) {
const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length);
expect(addedLogs.map((entry) => entry.text)).toEqual(
expect.arrayContaining([expect.stringContaining('필사즉생 발동')])
);
expect(
addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('필사즉생'))
).toBe(true);
}
}
if (outcome === 'intermediate') {
expect(reference.execution.outcome).toMatchObject({
lastTurn: {
command: '필사즉생',
term: expectedLastTerm,
},
});
expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({
command: '필사즉생',
term: expectedLastTerm,
});
}
if (completed) {
const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!;
expect(reference.after.world.nationCooldowns).toEqual([
{
nationId: 1,
actionName: '필사즉생',
nextAvailableTurn: expectedNextAvailableTurn,
},
]);
expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns);
}
expect(reference.rng).toEqual([]);
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});
const deceptionBoundaryCases: Array<{
name: string;
destCityId: unknown;
fixturePatches?: FixturePatches;
outcome: 'fallback' | 'intermediate' | 'completed';
coreResolution?: boolean;
expectedPostReqTurn?: number;
expectedStrategicCommandLimit?: number;
expectedTargetCityId?: number;
expectedRngCalls?: number;
}> = [
{
name: 'rejects a missing destination city',
destCityId: 99,
outcome: 'fallback',
},
{
name: 'accepts a numeric string destination city ID',
destCityId: '70',
fixturePatches: { diplomacy: { '1:2': { state: 0 } } },
outcome: 'completed',
expectedPostReqTurn: 62,
expectedTargetCityId: 70,
expectedRngCalls: 2,
},
{
name: 'truncates a fractional destination city ID',
destCityId: 70.9,
fixturePatches: { diplomacy: { '1:2': { state: 0 } } },
outcome: 'completed',
expectedPostReqTurn: 62,
expectedTargetCityId: 70,
expectedRngCalls: 2,
},
{
name: 'rejects a neutral destination city',
destCityId: 70,
fixturePatches: { cities: { 70: { nationId: 0 } } },
outcome: 'fallback',
},
{
name: 'rejects a destination city occupied by the source nation',
destCityId: 70,
fixturePatches: { cities: { 70: { nationId: 1 } } },
outcome: 'fallback',
},
{
name: 'rejects a trade relation to the destination nation',
destCityId: 70,
fixturePatches: { diplomacy: { '1:2': { state: 3 } } },
outcome: 'fallback',
},
{
name: 'rejects a reverse-only war relation',
destCityId: 70,
fixturePatches: {
diplomacy: {
'1:2': { state: 3 },
'2:1': { state: 0 },
},
},
outcome: 'fallback',
},
{
name: 'rejects a source city occupied by another nation',
destCityId: 70,
fixturePatches: {
cities: { 3: { nationId: 2 } },
diplomacy: { '1:2': { state: 0 } },
},
outcome: 'fallback',
},
{
name: 'rejects an actor below chief rank',
destCityId: 70,
fixturePatches: {
generals: { 1: { officerLevel: 4, officerCityId: 0 } },
diplomacy: { '1:2': { state: 0 } },
},
outcome: 'fallback',
coreResolution: false,
},
{
name: 'rejects a remaining strategic-command delay',
destCityId: 70,
fixturePatches: {
nations: { 1: { strategicCommandLimit: 1 } },
diplomacy: { '1:2': { state: 0 } },
},
outcome: 'fallback',
},
{
name: 'allows an unsupplied source city on the intermediate turn',
destCityId: 70,
fixturePatches: {
cities: { 3: { supplyState: 0 } },
diplomacy: { '1:2': { state: 0 } },
},
outcome: 'intermediate',
},
{
name: 'completes against a nation under declaration',
destCityId: 70,
fixturePatches: { diplomacy: { '1:2': { state: 1 } } },
outcome: 'completed',
expectedPostReqTurn: 62,
expectedTargetCityId: 70,
expectedRngCalls: 2,
},
{
name: 'excludes supply state two and moves the target to the only supply-one city',
destCityId: 70,
fixturePatches: {
cities: {
70: { supplyState: 2 },
23: { nationId: 2, supplyState: 1 },
},
diplomacy: { '1:2': { state: 0 } },
},
outcome: 'completed',
expectedPostReqTurn: 62,
expectedTargetCityId: 23,
expectedRngCalls: 1,
},
{
name: 'completes without RNG when no general is in the destination city',
destCityId: 70,
fixturePatches: {
cities: { 23: { nationId: 2, supplyState: 1 } },
generals: { 2: { cityId: 23 } },
diplomacy: { '1:2': { state: 0 } },
},
outcome: 'completed',
expectedPostReqTurn: 62,
expectedTargetCityId: 23,
expectedRngCalls: 0,
},
{
name: 'restarts at term one after another command interrupted the stack',
destCityId: 70,
fixturePatches: {
nations: {
1: {
turnLastByOfficerLevel: {
12: { command: '필사즉생', arg: {}, term: 2 },
},
},
},
diplomacy: { '1:2': { state: 0 } },
},
outcome: 'intermediate',
},
{
name: 'applies the strategist command and global-delay modifiers',
destCityId: 70,
fixturePatches: {
nations: { 1: { typeCode: 'che_종횡가' } },
diplomacy: { '1:2': { state: 0 } },
},
outcome: 'completed',
expectedPostReqTurn: 46,
expectedStrategicCommandLimit: 5,
expectedTargetCityId: 70,
expectedRngCalls: 2,
},
{
name: 'uses the actual eleven-general count for the nation cooldown',
destCityId: 70,
fixturePatches: {
nations: { 1: { generalCount: 11 } },
generals: {
4: { nationId: 1 },
5: { nationId: 1 },
6: { nationId: 1 },
7: { nationId: 1 },
8: { nationId: 1 },
9: { nationId: 1 },
10: { nationId: 1 },
11: { nationId: 1 },
12: { nationId: 1 },
},
diplomacy: { '1:2': { state: 0 } },
},
outcome: 'completed',
expectedPostReqTurn: 65,
expectedTargetCityId: 70,
expectedRngCalls: 2,
},
];
integration('nation deception target, multistep, movement, RNG, and cooldown boundaries', () => {
it.each(deceptionBoundaryCases)(
'$name matches legacy progress, movement, cooldown, logs, RNG, and semantic delta',
async ({
destCityId,
fixturePatches,
outcome,
coreResolution = true,
expectedPostReqTurn,
expectedStrategicCommandLimit = 9,
expectedTargetCityId,
expectedRngCalls,
}) => {
const normalizedDestCityId =
typeof destCityId === 'string' ? Math.trunc(Number(destCityId)) : Math.trunc(Number(destCityId));
const completed = outcome === 'completed';
const fallback = outcome === 'fallback';
const completionNationPatch = completed
? {
turnLastByOfficerLevel: {
12: { command: '허보', arg: { destCityID: destCityId }, term: 1 },
},
coreTurnLastByOfficerLevel: {
12: { command: '허보', arg: { destCityId: normalizedDestCityId }, term: 1 },
},
}
: {};
const request = buildRequest(
'che_허보',
{ destCityID: destCityId },
{
...fixturePatches,
nations: {
...fixturePatches?.nations,
1: {
...fixturePatches?.nations?.[1],
...completionNationPatch,
},
},
}
);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed });
if (coreResolution) {
expect(core.execution.outcome).toMatchObject({
requestedAction: 'che_허보',
actionKey: fallback ? '휴식' : 'che_허보',
usedFallback: fallback,
...(fallback ? {} : { completed }),
});
} else {
expect(core.execution.outcome).toBeUndefined();
}
for (const snapshot of [reference, core]) {
const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1);
const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1);
const actorBefore = snapshot.before.generals.find((entry) => entry.id === 1);
const actorAfter = snapshot.after.generals.find((entry) => entry.id === 1);
const targetBefore = snapshot.before.generals.find((entry) => entry.id === 2);
const targetAfter = snapshot.after.generals.find((entry) => entry.id === 2);
expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0);
expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0);
expect(readNumericField(actorAfter, 'experience') - readNumericField(actorBefore, 'experience')).toBe(
completed ? 10 : 0
);
expect(readNumericField(actorAfter, 'dedication') - readNumericField(actorBefore, 'dedication')).toBe(
completed ? 10 : 0
);
expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(
completed ? expectedStrategicCommandLimit : readNumericField(nationBefore, 'strategicCommandLimit')
);
expect(readNumericField(targetAfter, 'cityId')).toBe(
completed && expectedTargetCityId !== undefined
? expectedTargetCityId
: readNumericField(targetBefore, 'cityId')
);
if (completed) {
const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length);
expect(addedLogs.map((entry) => entry.text)).toEqual(
expect.arrayContaining([expect.stringContaining('허보 발동')])
);
expect(
addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('허보'))
).toBe(true);
}
}
if (outcome === 'intermediate') {
expect(reference.execution.outcome).toMatchObject({
lastTurn: {
command: '허보',
term: 1,
},
});
expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({
command: '허보',
term: 1,
});
}
if (completed) {
const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!;
expect(reference.after.world.nationCooldowns).toEqual([
{
nationId: 1,
actionName: '허보',
nextAvailableTurn: expectedNextAvailableTurn,
},
]);
expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns);
}
if (expectedRngCalls !== undefined) {
expect(reference.rng).toHaveLength(expectedRngCalls);
}
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});