화계에 묶여 있던 공통 확률·난수·비용·성장 처리를 계략 기반 명령으로 분리한다. 선동·탈취·파괴·화계를 기본 프로필과 계략 분류에 등록하고 Ref 실제 실행, 능력치 상승, 모바일 Chromium 검증을 추가한다.
4212 lines
155 KiB
TypeScript
4212 lines
155 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { asRecord, LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
|
|
|
|
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 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/,
|
|
/^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,
|
|
belong: 10,
|
|
permission: 'normal',
|
|
personality: 'None',
|
|
specialDomestic: 'None',
|
|
specialWar: 'None',
|
|
itemHorse: 'None',
|
|
itemWeapon: 'None',
|
|
itemBook: 'None',
|
|
itemExtra: 'None',
|
|
meta: {},
|
|
});
|
|
|
|
interface FixturePatches {
|
|
world?: NonNullable<NonNullable<TurnCommandFixtureRequest['setup']>['world']>;
|
|
generals?: Record<number, Record<string, unknown>>;
|
|
nations?: Record<number, Record<string, unknown>>;
|
|
cities?: Record<number, Record<string, unknown>>;
|
|
troops?: Array<Record<string, unknown>>;
|
|
diplomacy?: Record<string, Record<string, unknown>>;
|
|
randomFoundingCandidateCityIds?: number[];
|
|
rankData?: Array<{ generalId: number; type: string; value: number }>;
|
|
additionalCities?: Array<Record<string, unknown>>;
|
|
}
|
|
|
|
const buildRequest = (
|
|
action: string,
|
|
args?: Record<string, unknown>,
|
|
actorPatch: Record<string, unknown> = {},
|
|
fixturePatches: FixturePatches = {}
|
|
): TurnCommandFixtureRequest => ({
|
|
kind: 'general',
|
|
actorGeneralId: 1,
|
|
action,
|
|
...(args ? { args } : {}),
|
|
setup: {
|
|
isolateWorld: true,
|
|
world: {
|
|
startYear: 180,
|
|
year: 190,
|
|
month: 1,
|
|
hiddenSeed: 'turn-command-general-matrix-v1',
|
|
...fixturePatches.world,
|
|
},
|
|
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: {},
|
|
...fixturePatches.nations?.[1],
|
|
},
|
|
{
|
|
id: 2,
|
|
name: '타국',
|
|
capitalCityId: 70,
|
|
gold: 1_000_000,
|
|
rice: 1_000_000,
|
|
tech: 1000,
|
|
level: 1,
|
|
typeCode: 'che_중립',
|
|
war: 0,
|
|
generalCount: 1,
|
|
meta: {},
|
|
...fixturePatches.nations?.[2],
|
|
},
|
|
],
|
|
cities: [
|
|
{
|
|
id: 3,
|
|
nationId: 1,
|
|
population: 100_000,
|
|
populationMax: 200_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,
|
|
...fixturePatches.cities?.[3],
|
|
},
|
|
{
|
|
id: 70,
|
|
nationId: 2,
|
|
population: 100_000,
|
|
populationMax: 200_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,
|
|
...fixturePatches.cities?.[70],
|
|
},
|
|
...(fixturePatches.additionalCities ?? []),
|
|
],
|
|
generals: [
|
|
{ ...general(1, 1, 3, 12), ...actorPatch, ...fixturePatches.generals?.[1] },
|
|
{ ...general(2, 2, 70, 12), ...fixturePatches.generals?.[2] },
|
|
{ ...general(3, 1, 3, 1), ...fixturePatches.generals?.[3] },
|
|
],
|
|
...(fixturePatches.rankData ? { rankData: fixturePatches.rankData } : {}),
|
|
...(fixturePatches.troops ? { troops: fixturePatches.troops } : {}),
|
|
...(fixturePatches.randomFoundingCandidateCityIds
|
|
? { randomFoundingCandidateCityIds: fixturePatches.randomFoundingCandidateCityIds }
|
|
: {}),
|
|
diplomacy: [
|
|
{
|
|
fromNationId: 1,
|
|
toNationId: 2,
|
|
state: 0,
|
|
term: 12,
|
|
dead: 0,
|
|
...fixturePatches.diplomacy?.['1:2'],
|
|
},
|
|
{
|
|
fromNationId: 2,
|
|
toNationId: 1,
|
|
state: 0,
|
|
term: 12,
|
|
dead: 0,
|
|
...fixturePatches.diplomacy?.['2:1'],
|
|
},
|
|
],
|
|
},
|
|
observe: {
|
|
generalIds: [1, 2, 3],
|
|
cityIds: [
|
|
3,
|
|
70,
|
|
...(fixturePatches.additionalCities?.map((city) => Number(city.id)).filter(Number.isFinite) ?? []),
|
|
],
|
|
nationIds: [1, 2],
|
|
logAfterId: 0,
|
|
messageAfterId: 0,
|
|
},
|
|
});
|
|
|
|
const cases: Array<
|
|
[string, Record<string, unknown> | undefined, Record<string, unknown> | undefined, FixturePatches?]
|
|
> = [
|
|
['휴식', 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],
|
|
['che_이동', { destCityID: 70 }, undefined],
|
|
['che_강행', { destCityID: 70 }, undefined],
|
|
['che_귀환', undefined, { cityId: 70 }],
|
|
['che_접경귀환', undefined, { cityId: 70 }],
|
|
['che_증여', { isGold: true, amount: 100, destGeneralID: 3 }, undefined],
|
|
['che_첩보', { destCityID: 70 }, undefined],
|
|
['che_화계', { destCityID: 70 }, undefined],
|
|
['che_파괴', { destCityID: 70 }, undefined],
|
|
['che_선동', { destCityID: 70 }, undefined],
|
|
['che_탈취', { destCityID: 70 }, undefined],
|
|
['che_모병', { crewType: 1100, amount: 100 }, undefined],
|
|
['che_징병', { crewType: 1100, amount: 100 }, undefined],
|
|
['che_숙련전환', { srcArmType: 1, destArmType: 2 }, { dex1: 100 }],
|
|
[
|
|
'che_내정특기초기화',
|
|
undefined,
|
|
{ specialDomestic: 'che_인덕', lastTurn: { command: '내정 특기 초기화', term: 1 } },
|
|
],
|
|
['che_전투특기초기화', undefined, { specialWar: 'che_귀병', lastTurn: { command: '전투 특기 초기화', term: 1 } }],
|
|
['che_장비매매', { itemType: 'weapon', itemCode: 'che_무기_01_단도' }, undefined],
|
|
['che_하야', undefined, { officerLevel: 1 }],
|
|
['che_은퇴', undefined, { age: 60, lastTurn: { command: '은퇴', term: 1 } }],
|
|
[
|
|
'che_임관',
|
|
{ destNationID: 1 },
|
|
{ nationId: 0, officerLevel: 0 },
|
|
{ generals: { 3: { officerLevel: 12, officerCityId: 3 } } },
|
|
],
|
|
[
|
|
'che_랜덤임관',
|
|
undefined,
|
|
{ nationId: 0, officerLevel: 0 },
|
|
{ generals: { 3: { officerLevel: 12, officerCityId: 3 } } },
|
|
],
|
|
['che_장수대상임관', { destGeneralID: 2 }, { nationId: 0, officerLevel: 0 }],
|
|
['che_등용', { destGeneralID: 2 }, undefined, { generals: { 2: { officerLevel: 1 } } }],
|
|
['che_등용수락', { destNationID: 2, destGeneralID: 2 }, { nationId: 0, officerLevel: 0 }],
|
|
['che_선양', { destGeneralID: 3 }, undefined],
|
|
['che_NPC능동', { optionText: '순간이동', destCityID: 70 }, { npcState: 2 }],
|
|
['che_방랑', undefined, undefined, { diplomacy: { '1:2': { state: 2 }, '2:1': { state: 2 } } }],
|
|
[
|
|
'che_해산',
|
|
undefined,
|
|
undefined,
|
|
{
|
|
nations: { 1: { name: '조조', level: 0, capitalCityId: 0, typeCode: 'None' } },
|
|
cities: { 3: { nationId: 0, supplyState: 0, frontState: 0 } },
|
|
},
|
|
],
|
|
[
|
|
'che_집합',
|
|
undefined,
|
|
{ troopId: 1 },
|
|
{
|
|
generals: { 3: { cityId: 70, troopId: 1 } },
|
|
troops: [{ id: 1, nationId: 1, name: '조조군' }],
|
|
},
|
|
],
|
|
['che_거병', undefined, { nationId: 0, officerLevel: 0 }, { world: { startYear: 180, year: 181 } }],
|
|
[
|
|
'che_모반시도',
|
|
undefined,
|
|
{ officerLevel: 11 },
|
|
{ generals: { 3: { officerLevel: 12, officerCityId: 3, killTurn: 0 } } },
|
|
],
|
|
[
|
|
'che_건국',
|
|
{ nationName: '신국', nationType: 'che_도적', colorType: 1 },
|
|
undefined,
|
|
{
|
|
world: { startYear: 180, initYear: 180, initMonth: 1, year: 181 },
|
|
nations: { 1: { name: '조조', level: 0, capitalCityId: 0, typeCode: 'None' } },
|
|
cities: { 3: { nationId: 0, level: 5 } },
|
|
},
|
|
],
|
|
[
|
|
'cr_건국',
|
|
{ nationName: '신국', nationType: 'che_도적', colorType: 1 },
|
|
undefined,
|
|
{
|
|
world: { startYear: 180, initYear: 180, initMonth: 1, year: 181 },
|
|
nations: { 1: { name: '조조', level: 0, capitalCityId: 0, typeCode: 'None' } },
|
|
cities: { 3: { nationId: 0, level: 5 } },
|
|
},
|
|
],
|
|
[
|
|
'che_무작위건국',
|
|
{ nationName: '신국', nationType: 'che_도적', colorType: 1 },
|
|
undefined,
|
|
{
|
|
world: { startYear: 180, initYear: 180, initMonth: 1, year: 181 },
|
|
nations: { 1: { name: '조조', level: 0, capitalCityId: 0, typeCode: 'None' } },
|
|
cities: {
|
|
3: { nationId: 0, level: 5 },
|
|
70: { nationId: 0, level: 5 },
|
|
},
|
|
randomFoundingCandidateCityIds: [3, 70],
|
|
},
|
|
],
|
|
];
|
|
|
|
integration('general command success matrix', () => {
|
|
it.each(cases)(
|
|
'%s matches the legacy state delta and command RNG',
|
|
async (action, args, actorPatch, fixturePatches) => {
|
|
const request = buildRequest(action, args, actorPatch, fixturePatches);
|
|
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,
|
|
coreOutcome: core.execution.outcome,
|
|
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
|
|
);
|
|
});
|
|
|
|
type GeneralActiveActionInheritanceCase = {
|
|
name: string;
|
|
action: string;
|
|
args?: Record<string, unknown>;
|
|
expectedPointDelta?: number;
|
|
};
|
|
|
|
const generalActiveActionInheritanceCases: GeneralActiveActionInheritanceCase[] = [
|
|
{
|
|
name: 'ordinary training does not count as a legacy active action',
|
|
action: 'che_훈련',
|
|
expectedPointDelta: 0,
|
|
},
|
|
{
|
|
name: 'spy contributes the legacy half active action',
|
|
action: 'che_첩보',
|
|
args: { destCityID: 70 },
|
|
expectedPointDelta: 1.5,
|
|
},
|
|
{
|
|
name: 'abdication contributes one active action',
|
|
action: 'che_선양',
|
|
args: { destGeneralID: 3 },
|
|
expectedPointDelta: 3,
|
|
},
|
|
{
|
|
name: 'rest does not count as a legacy active action',
|
|
action: '휴식',
|
|
expectedPointDelta: 0,
|
|
},
|
|
{
|
|
name: 'talent scouting preserves its probability-weighted active action',
|
|
action: 'che_인재탐색',
|
|
},
|
|
];
|
|
|
|
const readActiveActionPoints = (snapshot: { generals: Array<Record<string, unknown>> }, generalId: number): number => {
|
|
const general = snapshot.generals.find((entry) => entry.id === generalId);
|
|
const value = general?.inheritActiveActionPoints;
|
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
};
|
|
|
|
integration('general active-action inheritance point parity', () => {
|
|
it.each(generalActiveActionInheritanceCases)(
|
|
'$name',
|
|
async ({ name, action, args, expectedPointDelta }) => {
|
|
const request = buildRequest(action, args);
|
|
request.setup!.world!.hiddenSeed = `general-active-action-${name}`;
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
const referencePointDelta =
|
|
readActiveActionPoints(reference.after, 1) - readActiveActionPoints(reference.before, 1);
|
|
const corePointDelta = readActiveActionPoints(core.after, 1) - readActiveActionPoints(core.before, 1);
|
|
|
|
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: action,
|
|
actionKey: action,
|
|
usedFallback: false,
|
|
});
|
|
if (expectedPointDelta !== undefined) {
|
|
expect(referencePointDelta).toBe(expectedPointDelta);
|
|
} else {
|
|
expect(referencePointDelta).toBeGreaterThan(0);
|
|
}
|
|
expect(corePointDelta).toBe(referencePointDelta);
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
interface NpcActiveBoundaryCase {
|
|
name: string;
|
|
args: Record<string, unknown>;
|
|
actorPatch: Record<string, unknown>;
|
|
completed: boolean;
|
|
}
|
|
|
|
const npcActiveBoundaryCases: NpcActiveBoundaryCase[] = [
|
|
{
|
|
name: 'execution does not reapply the reservation-only NPC permission',
|
|
args: { optionText: '순간이동', destCityID: 70 },
|
|
actorPatch: { npcState: 0 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'a missing destination city is rejected before execution',
|
|
args: { optionText: '순간이동', destCityID: 999_999 },
|
|
actorPatch: { npcState: 2 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'a numeric-string destination follows the legacy weak integer argument',
|
|
args: { optionText: '순간이동', destCityID: '70' },
|
|
actorPatch: { npcState: 2 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'a fractional destination preserves the legacy split lookup and storage coercion',
|
|
args: { optionText: '순간이동', destCityID: 70.9 },
|
|
actorPatch: { npcState: 2 },
|
|
completed: true,
|
|
},
|
|
];
|
|
|
|
integration('NPC active command boundary parity', () => {
|
|
it.each(npcActiveBoundaryCases)(
|
|
'$name',
|
|
async ({ name, args, actorPatch, completed }) => {
|
|
const request = buildRequest('che_NPC능동', args, actorPatch);
|
|
request.setup!.world!.hiddenSeed = `general-npc-active-${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: 'che_NPC능동',
|
|
actionKey: completed ? 'che_NPC능동' : '휴식',
|
|
usedFallback: !completed,
|
|
});
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
interface DisbandFactionBoundaryCase {
|
|
name: string;
|
|
actorPatch?: Record<string, unknown>;
|
|
fixturePatches: FixturePatches;
|
|
completed: boolean;
|
|
compareLogs?: boolean;
|
|
}
|
|
|
|
const disbandFactionBoundaryCases: DisbandFactionBoundaryCase[] = [
|
|
{
|
|
name: 'a non-lord cannot disband the wandering nation',
|
|
actorPatch: { officerLevel: 11 },
|
|
fixturePatches: {
|
|
nations: { 1: { level: 0, capitalCityId: 0, typeCode: 'None' } },
|
|
cities: { 3: { nationId: 0, supplyState: 0, frontState: 0 } },
|
|
},
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'a settled nation cannot be disbanded',
|
|
fixturePatches: {
|
|
nations: { 1: { level: 1 } },
|
|
},
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'disbanding preserves the legacy member state, resources, and destruction logs',
|
|
actorPatch: {
|
|
troopId: 3,
|
|
belong: 5,
|
|
permission: 'ambassador',
|
|
gold: 2_000,
|
|
rice: 3_000,
|
|
meta: { max_belong: 2 },
|
|
},
|
|
fixturePatches: {
|
|
nations: { 1: { name: '방랑군', level: 0, capitalCityId: 0, typeCode: 'None' } },
|
|
cities: { 3: { nationId: 0, supplyState: 0, frontState: 0 } },
|
|
generals: {
|
|
3: {
|
|
troopId: 3,
|
|
belong: 7,
|
|
permission: 'auditor',
|
|
gold: 2_500,
|
|
rice: 4_000,
|
|
meta: { max_belong: 3 },
|
|
},
|
|
},
|
|
troops: [{ id: 3, nationId: 1, name: '방랑군 부대' }],
|
|
},
|
|
completed: true,
|
|
compareLogs: true,
|
|
},
|
|
];
|
|
|
|
integration('general faction disband boundary, state, and log parity', () => {
|
|
it.each(disbandFactionBoundaryCases)(
|
|
'$name',
|
|
async ({ name, actorPatch, fixturePatches, completed, compareLogs }) => {
|
|
const request = buildRequest('che_해산', undefined, actorPatch, fixturePatches);
|
|
request.setup!.world!.hiddenSeed = `general-disband-${name}`;
|
|
request.observe!.includeGlobalHistoryLogs = true;
|
|
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(
|
|
{
|
|
name,
|
|
referenceGenerals: reference.after.generals,
|
|
coreGenerals: core.after.generals,
|
|
referenceLogs: addedReferenceLogs(reference.before, reference.after.logs),
|
|
coreLogs: core.after.logs,
|
|
},
|
|
null,
|
|
2
|
|
)}\n`
|
|
);
|
|
}
|
|
|
|
expect(reference.execution.outcome).toMatchObject({ completed });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: 'che_해산',
|
|
actionKey: completed ? 'che_해산' : '휴식',
|
|
usedFallback: !completed,
|
|
});
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
|
|
if (compareLogs) {
|
|
expect(semanticLogSignatures(core.after.logs)).toEqual(
|
|
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
|
|
);
|
|
}
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
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
|
|
);
|
|
});
|
|
|
|
interface MovementBoundaryCase {
|
|
name: string;
|
|
action: 'che_강행' | 'che_귀환' | 'che_접경귀환' | 'che_집합' | 'che_방랑';
|
|
args?: Record<string, unknown>;
|
|
actorPatch?: Record<string, unknown>;
|
|
fixturePatches?: FixturePatches;
|
|
completed: boolean;
|
|
fallback?: boolean;
|
|
}
|
|
|
|
const movementBoundaryCases: MovementBoundaryCase[] = [
|
|
{
|
|
name: 'forced move accepts a numeric-string city ID',
|
|
action: 'che_강행',
|
|
args: { destCityID: '70' },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'forced move truncates a fractional city ID',
|
|
action: 'che_강행',
|
|
args: { destCityID: 70.9 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'forced move rejects the current city',
|
|
action: 'che_강행',
|
|
args: { destCityID: 3 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'forced move rejects an unknown city',
|
|
action: 'che_강행',
|
|
args: { destCityID: 999 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'forced move rejects insufficient gold',
|
|
action: 'che_강행',
|
|
args: { destCityID: 70 },
|
|
actorPatch: { gold: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'forced move preserves the training and morale floors',
|
|
action: 'che_강행',
|
|
args: { destCityID: 70 },
|
|
actorPatch: { train: 20, atmos: 20 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'forced move carries every wandering-nation general',
|
|
action: 'che_강행',
|
|
args: { destCityID: 70 },
|
|
fixturePatches: { nations: { 1: { level: 0 } } },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'return rejects a neutral actor',
|
|
action: 'che_귀환',
|
|
actorPatch: { nationId: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'return rejects a wandering nation',
|
|
action: 'che_귀환',
|
|
fixturePatches: { nations: { 1: { level: 0 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'return rejects a lord already in the capital',
|
|
action: 'che_귀환',
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'return lets a local officer leave the capital for the assigned city',
|
|
action: 'che_귀환',
|
|
actorPatch: { officerLevel: 2, officerCityId: 70 },
|
|
fixturePatches: { cities: { 70: { nationId: 1 } } },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'return rewards an officer already in the assigned non-capital city',
|
|
action: 'che_귀환',
|
|
actorPatch: { officerLevel: 4, officerCityId: 70, cityId: 70 },
|
|
fixturePatches: { cities: { 70: { nationId: 1 } } },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'return sends a normal officer to the capital',
|
|
action: 'che_귀환',
|
|
actorPatch: { officerLevel: 1, cityId: 70 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'border return rejects a neutral actor',
|
|
action: 'che_접경귀환',
|
|
actorPatch: { nationId: 0, cityId: 70 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'border return rejects a wandering nation',
|
|
action: 'che_접경귀환',
|
|
actorPatch: { cityId: 70 },
|
|
fixturePatches: { nations: { 1: { level: 0 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'border return rejects an actor already in an owned city',
|
|
action: 'che_접경귀환',
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'border return fails without a supplied owned city in range',
|
|
action: 'che_접경귀환',
|
|
actorPatch: { cityId: 70 },
|
|
fixturePatches: { cities: { 3: { supplyState: 0 } } },
|
|
completed: false,
|
|
fallback: false,
|
|
},
|
|
{
|
|
name: 'border return consumes the single-candidate choice and moves',
|
|
action: 'che_접경귀환',
|
|
actorPatch: { cityId: 70 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'assembly rejects a neutral actor',
|
|
action: 'che_집합',
|
|
actorPatch: { nationId: 0, troopId: 1 },
|
|
fixturePatches: { troops: [{ id: 1, nationId: 1, name: '조조군' }] },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'assembly rejects a foreign-occupied city',
|
|
action: 'che_집합',
|
|
actorPatch: { troopId: 1 },
|
|
fixturePatches: {
|
|
cities: { 3: { nationId: 2 } },
|
|
troops: [{ id: 1, nationId: 1, name: '조조군' }],
|
|
},
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'assembly rejects an unsupplied city',
|
|
action: 'che_집합',
|
|
actorPatch: { troopId: 1 },
|
|
fixturePatches: {
|
|
cities: { 3: { supplyState: 0 } },
|
|
troops: [{ id: 1, nationId: 1, name: '조조군' }],
|
|
},
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'assembly rejects a non-leader',
|
|
action: 'che_집합',
|
|
actorPatch: { troopId: 3 },
|
|
fixturePatches: {
|
|
generals: { 3: { troopId: 3 } },
|
|
troops: [{ id: 3, nationId: 1, name: '조조군' }],
|
|
},
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'assembly rejects a troop without another member',
|
|
action: 'che_집합',
|
|
actorPatch: { troopId: 1 },
|
|
fixturePatches: { troops: [{ id: 1, nationId: 1, name: '조조군' }] },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'assembly completes when every member is already present',
|
|
action: 'che_집합',
|
|
actorPatch: { troopId: 1 },
|
|
fixturePatches: {
|
|
generals: { 3: { troopId: 1 } },
|
|
troops: [{ id: 1, nationId: 1, name: '조조군' }],
|
|
},
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'assembly moves a remote member and records its plain log',
|
|
action: 'che_집합',
|
|
actorPatch: { troopId: 1 },
|
|
fixturePatches: {
|
|
generals: { 3: { cityId: 70, troopId: 1 } },
|
|
troops: [{ id: 1, nationId: 1, name: '조조군' }],
|
|
},
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'wander rejects a non-lord',
|
|
action: 'che_방랑',
|
|
actorPatch: { officerLevel: 11 },
|
|
fixturePatches: { diplomacy: { '1:2': { state: 2 }, '2:1': { state: 2 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'wander rejects an already wandering nation',
|
|
action: 'che_방랑',
|
|
fixturePatches: {
|
|
nations: { 1: { level: 0 } },
|
|
diplomacy: { '1:2': { state: 2 }, '2:1': { state: 2 } },
|
|
},
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'wander rejects the opening phase',
|
|
action: 'che_방랑',
|
|
fixturePatches: {
|
|
world: { year: 180 },
|
|
diplomacy: { '1:2': { state: 2 }, '2:1': { state: 2 } },
|
|
},
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'wander rejects a disallowed diplomacy state',
|
|
action: 'che_방랑',
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'wander accepts neutral diplomacy',
|
|
action: 'che_방랑',
|
|
fixturePatches: { diplomacy: { '1:2': { state: 2 }, '2:1': { state: 2 } } },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'wander accepts non-aggression diplomacy',
|
|
action: 'che_방랑',
|
|
fixturePatches: { diplomacy: { '1:2': { state: 7 }, '2:1': { state: 7 } } },
|
|
completed: true,
|
|
},
|
|
];
|
|
|
|
integration('general movement command boundary and log parity', () => {
|
|
it.each(movementBoundaryCases)(
|
|
'$name',
|
|
async ({ name, action, args, actorPatch, fixturePatches, completed, fallback }) => {
|
|
const request = buildRequest(action, args, actorPatch, fixturePatches);
|
|
request.setup!.world!.hiddenSeed = `general-movement-${name}`;
|
|
request.observe!.includeGlobalHistoryLogs = true;
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
const usedFallback = fallback ?? !completed;
|
|
|
|
expect(reference.execution.outcome).toMatchObject({ completed });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: action,
|
|
actionKey: usedFallback ? '휴식' : action,
|
|
usedFallback,
|
|
});
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
|
|
if (completed) {
|
|
expect(semanticLogSignatures(core.after.logs)).toEqual(
|
|
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
|
|
);
|
|
}
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
interface DomesticBoundaryCase {
|
|
name: string;
|
|
action: 'che_성벽보수' | 'che_수비강화' | 'che_치안강화' | 'che_기술연구' | 'che_정착장려' | 'che_물자조달';
|
|
actorPatch?: Record<string, unknown>;
|
|
fixturePatches?: FixturePatches;
|
|
completed: boolean;
|
|
}
|
|
|
|
const domesticBoundaryCases: DomesticBoundaryCase[] = [
|
|
{
|
|
name: 'wall repair rejects a neutral actor',
|
|
action: 'che_성벽보수',
|
|
actorPatch: { nationId: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'wall repair rejects a wandering nation',
|
|
action: 'che_성벽보수',
|
|
fixturePatches: { nations: { 1: { level: 0 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'wall repair rejects a foreign-occupied city',
|
|
action: 'che_성벽보수',
|
|
fixturePatches: { cities: { 3: { nationId: 2 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'wall repair rejects an unsupplied city',
|
|
action: 'che_성벽보수',
|
|
fixturePatches: { cities: { 3: { supplyState: 0 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'wall repair rejects insufficient gold',
|
|
action: 'che_성벽보수',
|
|
actorPatch: { gold: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'wall repair rejects a wall already at capacity',
|
|
action: 'che_성벽보수',
|
|
fixturePatches: { cities: { 3: { wall: 1000, wallMax: 1000 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'wall repair preserves the early-capital front debuff',
|
|
action: 'che_성벽보수',
|
|
fixturePatches: { cities: { 3: { wall: 0, wallMax: 10_000, frontState: 1 } } },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'defence reinforcement rejects a defence already at capacity',
|
|
action: 'che_수비강화',
|
|
fixturePatches: { cities: { 3: { defence: 1000, defenceMax: 1000 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'defence reinforcement preserves the early-capital front debuff',
|
|
action: 'che_수비강화',
|
|
fixturePatches: { cities: { 3: { defence: 0, defenceMax: 10_000, frontState: 1 } } },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'security reinforcement rejects security already at capacity',
|
|
action: 'che_치안강화',
|
|
fixturePatches: { cities: { 3: { security: 1000, securityMax: 1000 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'security reinforcement preserves the early-capital front rule',
|
|
action: 'che_치안강화',
|
|
fixturePatches: { cities: { 3: { security: 0, securityMax: 10_000, frontState: 1 } } },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'tech research rejects a neutral actor',
|
|
action: 'che_기술연구',
|
|
actorPatch: { nationId: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'tech research rejects a wandering nation',
|
|
action: 'che_기술연구',
|
|
fixturePatches: { nations: { 1: { level: 0 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'tech research rejects a foreign-occupied city',
|
|
action: 'che_기술연구',
|
|
fixturePatches: { cities: { 3: { nationId: 2 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'tech research rejects an unsupplied city',
|
|
action: 'che_기술연구',
|
|
fixturePatches: { cities: { 3: { supplyState: 0 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'tech research rejects insufficient gold',
|
|
action: 'che_기술연구',
|
|
actorPatch: { gold: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'tech research uses stored nation general count at the level cap',
|
|
action: 'che_기술연구',
|
|
fixturePatches: { nations: { 1: { tech: 3000, generalCount: 11 } } },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'settlement encouragement rejects insufficient rice',
|
|
action: 'che_정착장려',
|
|
actorPatch: { rice: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'settlement encouragement rejects a city at population capacity',
|
|
action: 'che_정착장려',
|
|
fixturePatches: { cities: { 3: { population: 200_000, populationMax: 200_000 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'settlement encouragement clamps at population capacity',
|
|
action: 'che_정착장려',
|
|
fixturePatches: { cities: { 3: { population: 199_999, populationMax: 200_000 } } },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'procurement rejects a neutral actor',
|
|
action: 'che_물자조달',
|
|
actorPatch: { nationId: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'procurement rejects a wandering nation',
|
|
action: 'che_물자조달',
|
|
fixturePatches: { nations: { 1: { level: 0 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'procurement rejects a foreign-occupied city',
|
|
action: 'che_물자조달',
|
|
fixturePatches: { cities: { 3: { nationId: 2 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'procurement rejects an unsupplied city',
|
|
action: 'che_물자조달',
|
|
fixturePatches: { cities: { 3: { supplyState: 0 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'procurement succeeds without owned gold or rice',
|
|
action: 'che_물자조달',
|
|
actorPatch: { gold: 0, rice: 0 },
|
|
completed: true,
|
|
},
|
|
];
|
|
|
|
integration('general domestic command boundary, value, and log parity', () => {
|
|
it.each(domesticBoundaryCases)(
|
|
'$name',
|
|
async ({ name, action, actorPatch, fixturePatches, completed }) => {
|
|
const request = buildRequest(action, undefined, actorPatch, fixturePatches);
|
|
request.setup!.world!.hiddenSeed = `general-domestic-${name}`;
|
|
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 });
|
|
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) {
|
|
expect(semanticLogSignatures(core.after.logs)).toEqual(
|
|
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
|
|
);
|
|
}
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
interface CoreDomesticBoundaryCase {
|
|
name: string;
|
|
action: 'che_농지개간' | 'che_상업투자' | 'che_주민선정' | 'che_정착장려' | 'che_기술연구';
|
|
actorPatch?: Record<string, unknown>;
|
|
fixturePatches?: FixturePatches;
|
|
hiddenSeed: string;
|
|
completed: boolean;
|
|
}
|
|
|
|
const coreDomesticBoundaryCases: CoreDomesticBoundaryCase[] = [
|
|
{
|
|
name: 'agriculture rejects a city at its exact capacity',
|
|
action: 'che_농지개간',
|
|
fixturePatches: { cities: { 3: { agriculture: 1_000, agricultureMax: 1_000 } } },
|
|
hiddenSeed: 'general-core-domestic-agriculture-capacity',
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'agriculture preserves the early capital front adjustment',
|
|
action: 'che_농지개간',
|
|
fixturePatches: {
|
|
world: { year: 183 },
|
|
cities: { 3: { frontState: 1, agriculture: 1_000, agricultureMax: 2_000 } },
|
|
},
|
|
hiddenSeed: 'turn-command-general-matrix-v1',
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'commerce preserves the non-capital front debuff and integer city storage',
|
|
action: 'che_상업투자',
|
|
fixturePatches: {
|
|
nations: { 1: { capitalCityId: 70 } },
|
|
cities: { 3: { frontState: 1, commerce: 1_000, commerceMax: 2_000 } },
|
|
},
|
|
hiddenSeed: 'turn-command-general-matrix-v1',
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'resident selection preserves fractional FLOAT storage and accumulates critical score by half',
|
|
action: 'che_주민선정',
|
|
actorPatch: { meta: { max_domestic_critical: 10 } },
|
|
fixturePatches: {
|
|
cities: { 3: { trust: 10 } },
|
|
},
|
|
hiddenSeed: 'turn-command-general-matrix-v1',
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'settlement critical success uses the shared half-score accumulator',
|
|
action: 'che_정착장려',
|
|
actorPatch: { meta: { max_domestic_critical: 10 } },
|
|
hiddenSeed: 'turn-command-general-matrix-v1',
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'technology critical success uses the shared half-score accumulator',
|
|
action: 'che_기술연구',
|
|
actorPatch: { meta: { max_domestic_critical: 10 } },
|
|
hiddenSeed: 'turn-command-general-matrix-v1',
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'commerce failure resets the current domestic critical accumulator',
|
|
action: 'che_상업투자',
|
|
actorPatch: { meta: { max_domestic_critical: 10 } },
|
|
hiddenSeed: 'general-failure-che_상업투자-2',
|
|
completed: true,
|
|
},
|
|
];
|
|
|
|
integration('core domestic command critical, front, and storage boundary parity', () => {
|
|
it.each(coreDomesticBoundaryCases)(
|
|
'$name',
|
|
async ({ action, actorPatch, fixturePatches, hiddenSeed, completed }) => {
|
|
const request = buildRequest(action, undefined, actorPatch, fixturePatches);
|
|
request.setup!.world!.hiddenSeed = hiddenSeed;
|
|
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 });
|
|
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) {
|
|
expect(semanticLogSignatures(core.after.logs)).toEqual(
|
|
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
|
|
);
|
|
}
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
interface MilitaryPreparationBoundaryCase {
|
|
name: string;
|
|
action: 'che_모병' | 'che_징병' | 'che_장비매매' | 'che_소집해제' | 'cr_맹훈련';
|
|
args?: Record<string, unknown>;
|
|
actorPatch?: Record<string, unknown>;
|
|
fixturePatches?: FixturePatches;
|
|
completed: boolean;
|
|
}
|
|
|
|
const militaryPreparationBoundaryCases: MilitaryPreparationBoundaryCase[] = [
|
|
{
|
|
name: 'recruitment rejects a numeric-string crew type',
|
|
action: 'che_징병',
|
|
args: { crewType: '1100', amount: 100 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'recruitment rejects a fractional crew type',
|
|
action: 'che_징병',
|
|
args: { crewType: 1100.9, amount: 100 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'recruitment accepts a numeric-string amount',
|
|
action: 'che_징병',
|
|
args: { crewType: 1100, amount: '100' },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'recruitment rejects a non-decimal numeric-looking amount',
|
|
action: 'che_징병',
|
|
args: { crewType: 1100, amount: '0x64' },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'recruitment truncates a fractional amount',
|
|
action: 'che_징병',
|
|
args: { crewType: 1100, amount: 100.9 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'recruitment raises zero amount to the minimum',
|
|
action: 'che_징병',
|
|
args: { crewType: 1100, amount: 0 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'recruitment rejects a negative amount',
|
|
action: 'che_징병',
|
|
args: { crewType: 1100, amount: -1 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'recruitment rejects an unknown crew type',
|
|
action: 'che_징병',
|
|
args: { crewType: 9999, amount: 100 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'recruitment rejects a neutral actor',
|
|
action: 'che_징병',
|
|
args: { crewType: 1100, amount: 100 },
|
|
actorPatch: { nationId: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'recruitment rejects a foreign-occupied city',
|
|
action: 'che_징병',
|
|
args: { crewType: 1100, amount: 100 },
|
|
fixturePatches: { cities: { 3: { nationId: 2 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'recruitment rejects population below the fixed reserve',
|
|
action: 'che_징병',
|
|
args: { crewType: 1100, amount: 100 },
|
|
fixturePatches: { cities: { 3: { population: 30_099 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'recruitment accepts the exact population reserve',
|
|
action: 'che_징병',
|
|
args: { crewType: 1100, amount: 100 },
|
|
fixturePatches: { cities: { 3: { population: 30_100 } } },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'recruitment rejects trust below twenty',
|
|
action: 'che_징병',
|
|
args: { crewType: 1100, amount: 100 },
|
|
fixturePatches: { cities: { 3: { trust: 19.99 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'recruitment accepts trust exactly twenty',
|
|
action: 'che_징병',
|
|
args: { crewType: 1100, amount: 100 },
|
|
fixturePatches: { cities: { 3: { trust: 20 } } },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'recruitment rejects insufficient gold',
|
|
action: 'che_징병',
|
|
args: { crewType: 1100, amount: 100 },
|
|
actorPatch: { gold: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'recruitment rejects insufficient rice',
|
|
action: 'che_징병',
|
|
args: { crewType: 1100, amount: 100 },
|
|
actorPatch: { rice: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'recruitment clamps the same crew type at leadership capacity',
|
|
action: 'che_징병',
|
|
args: { crewType: 1100, amount: 99_999 },
|
|
fixturePatches: { cities: { 3: { population: 200_000 } } },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'mercenary recruitment keeps its doubled price and higher readiness',
|
|
action: 'che_모병',
|
|
args: { crewType: 1100, amount: 100 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'mercenary recruitment rejects one gold below its doubled rounded cost',
|
|
action: 'che_모병',
|
|
args: { crewType: 1100, amount: 100 },
|
|
actorPatch: { gold: 20 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'mercenary recruitment accepts its exact doubled rounded gold cost',
|
|
action: 'che_모병',
|
|
args: { crewType: 1100, amount: 100 },
|
|
actorPatch: { gold: 21 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'mercenary recruitment rejects one rice below maintenance plus command cost',
|
|
action: 'che_모병',
|
|
args: { crewType: 1100, amount: 100 },
|
|
actorPatch: { rice: 10 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'mercenary recruitment accepts exact maintenance plus command cost',
|
|
action: 'che_모병',
|
|
args: { crewType: 1100, amount: 100 },
|
|
actorPatch: { rice: 11 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'mercenary recruitment clamps the same crew type at leadership capacity',
|
|
action: 'che_모병',
|
|
args: { crewType: 1100, amount: 99_999 },
|
|
fixturePatches: { cities: { 3: { population: 200_000 } } },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'equipment trade rejects an unknown item type',
|
|
action: 'che_장비매매',
|
|
args: { itemType: 'armor', itemCode: 'che_무기_01_단도' },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'equipment trade rejects an item from a different slot',
|
|
action: 'che_장비매매',
|
|
args: { itemType: 'book', itemCode: 'che_무기_01_단도' },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'equipment trade rejects an unbuyable item',
|
|
action: 'che_장비매매',
|
|
args: { itemType: 'weapon', itemCode: 'che_무기_11_고정도' },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'equipment trade does not treat the city trade value as trader availability',
|
|
action: 'che_장비매매',
|
|
args: { itemType: 'weapon', itemCode: 'che_무기_01_단도' },
|
|
fixturePatches: { cities: { 3: { trade: 0 } } },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'equipment trade rejects insufficient gold',
|
|
action: 'che_장비매매',
|
|
args: { itemType: 'weapon', itemCode: 'che_무기_01_단도' },
|
|
actorPatch: { gold: 999 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'equipment trade rejects buying the equipped item',
|
|
action: 'che_장비매매',
|
|
args: { itemType: 'weapon', itemCode: 'che_무기_01_단도' },
|
|
actorPatch: { itemWeapon: 'che_무기_01_단도' },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'equipment trade rejects selling an empty slot',
|
|
action: 'che_장비매매',
|
|
args: { itemType: 'weapon', itemCode: 'None' },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'equipment trade sells a normal item for half price',
|
|
action: 'che_장비매매',
|
|
args: { itemType: 'weapon', itemCode: 'None' },
|
|
actorPatch: { itemWeapon: 'che_무기_01_단도' },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'equipment trade permits selling a unique item and records global logs',
|
|
action: 'che_장비매매',
|
|
args: { itemType: 'weapon', itemCode: 'None' },
|
|
actorPatch: { itemWeapon: 'che_무기_11_고정도' },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'equipment trade applies the dogi sale side effect before removing it',
|
|
action: 'che_장비매매',
|
|
args: { itemType: 'item', itemCode: 'None' },
|
|
actorPatch: { itemExtra: 'che_보물_도기' },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'disband rejects zero crew',
|
|
action: 'che_소집해제',
|
|
actorPatch: { crew: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'disband allows a neutral actor and exceeds population capacity',
|
|
action: 'che_소집해제',
|
|
actorPatch: { nationId: 0, crew: 1001 },
|
|
fixturePatches: { cities: { 3: { population: 200_000, populationMax: 200_000 } } },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'fierce training rejects a neutral actor',
|
|
action: 'cr_맹훈련',
|
|
actorPatch: { nationId: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'fierce training rejects a wandering nation',
|
|
action: 'cr_맹훈련',
|
|
fixturePatches: { nations: { 1: { level: 0 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'fierce training rejects a foreign-occupied city',
|
|
action: 'cr_맹훈련',
|
|
fixturePatches: { cities: { 3: { nationId: 2 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'fierce training rejects zero crew',
|
|
action: 'cr_맹훈련',
|
|
actorPatch: { crew: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'fierce training rejects training at the command maximum',
|
|
action: 'cr_맹훈련',
|
|
actorPatch: { train: 100 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'fierce training enforces the advertised rice cost before execution',
|
|
action: 'cr_맹훈련',
|
|
actorPatch: { rice: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'fierce training succeeds with morale already capped',
|
|
action: 'cr_맹훈련',
|
|
actorPatch: { train: 99, atmos: 100, crew: 1001 },
|
|
completed: true,
|
|
},
|
|
];
|
|
|
|
integration('general military preparation boundary, value, RNG, and log parity', () => {
|
|
it.each(militaryPreparationBoundaryCases)(
|
|
'$name',
|
|
async ({ name, action, args, actorPatch, fixturePatches, completed }) => {
|
|
const request = buildRequest(action, args, actorPatch, fixturePatches);
|
|
request.setup!.world!.hiddenSeed = `general-military-preparation-${name}`;
|
|
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 });
|
|
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) {
|
|
expect(semanticLogSignatures(core.after.logs)).toEqual(
|
|
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
|
|
);
|
|
}
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
interface TrainingScoutBoundaryCase {
|
|
name: string;
|
|
action: 'che_훈련' | 'che_전투태세' | 'che_인재탐색' | 'che_첩보';
|
|
args?: Record<string, unknown>;
|
|
actorPatch?: Record<string, unknown>;
|
|
fixturePatches?: FixturePatches;
|
|
hiddenSeed?: string;
|
|
completed: boolean;
|
|
fallback?: boolean;
|
|
}
|
|
|
|
const trainingScoutBoundaryCases: TrainingScoutBoundaryCase[] = [
|
|
{
|
|
name: 'training rejects a neutral actor',
|
|
action: 'che_훈련',
|
|
actorPatch: { nationId: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'training rejects a wandering nation',
|
|
action: 'che_훈련',
|
|
fixturePatches: { nations: { 1: { level: 0 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'training rejects a foreign-occupied city',
|
|
action: 'che_훈련',
|
|
fixturePatches: { cities: { 3: { nationId: 2 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'training rejects zero crew',
|
|
action: 'che_훈련',
|
|
actorPatch: { crew: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'training rejects the command maximum',
|
|
action: 'che_훈련',
|
|
actorPatch: { train: 100 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'training clamps one below the maximum and applies morale side effect',
|
|
action: 'che_훈련',
|
|
actorPatch: { train: 99, atmos: 99, crew: 1001 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'training preserves zero rounded score and morale side effect',
|
|
action: 'che_훈련',
|
|
actorPatch: { train: 50, atmos: 99, crew: 9_999_999 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'battle preparation rejects a neutral actor at completion',
|
|
action: 'che_전투태세',
|
|
actorPatch: { nationId: 0, lastTurn: { command: '전투태세', term: 3 } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'battle preparation rejects a wandering nation at completion',
|
|
action: 'che_전투태세',
|
|
actorPatch: { lastTurn: { command: '전투태세', term: 3 } },
|
|
fixturePatches: { nations: { 1: { level: 0 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'battle preparation rejects a foreign-occupied city at completion',
|
|
action: 'che_전투태세',
|
|
actorPatch: { lastTurn: { command: '전투태세', term: 3 } },
|
|
fixturePatches: { cities: { 3: { nationId: 2 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'battle preparation rejects zero crew at completion',
|
|
action: 'che_전투태세',
|
|
actorPatch: { crew: 0, lastTurn: { command: '전투태세', term: 3 } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'battle preparation rejects insufficient gold at completion',
|
|
action: 'che_전투태세',
|
|
actorPatch: { gold: 0, lastTurn: { command: '전투태세', term: 3 } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'battle preparation rejects training at ninety',
|
|
action: 'che_전투태세',
|
|
actorPatch: { train: 90, lastTurn: { command: '전투태세', term: 3 } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'battle preparation rejects morale at ninety',
|
|
action: 'che_전투태세',
|
|
actorPatch: { atmos: 90, lastTurn: { command: '전투태세', term: 3 } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'battle preparation accepts exact training and morale margins',
|
|
action: 'che_전투태세',
|
|
actorPatch: {
|
|
train: 89,
|
|
atmos: 89,
|
|
gold: 1_000_000,
|
|
rice: 100_000,
|
|
lastTurn: { command: '전투태세', term: 3 },
|
|
},
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'talent scout rejects insufficient development gold',
|
|
action: 'che_인재탐색',
|
|
actorPatch: { gold: 17 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'talent scout accepts the exact development gold',
|
|
action: 'che_인재탐색',
|
|
actorPatch: { gold: 18 },
|
|
hiddenSeed: 'general-talent-scout-exact-cost',
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'talent scout preserves a second fixed probability branch',
|
|
action: 'che_인재탐색',
|
|
hiddenSeed: 'general-talent-scout-probability-1',
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'spy accepts a numeric-string destination city ID',
|
|
action: 'che_첩보',
|
|
args: { destCityID: '70' },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'spy accepts a fractional destination city ID through PHP array-key coercion',
|
|
action: 'che_첩보',
|
|
args: { destCityID: 70.9 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'spy rejects an unknown destination city',
|
|
action: 'che_첩보',
|
|
args: { destCityID: 999 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'spy rejects the occupied city',
|
|
action: 'che_첩보',
|
|
args: { destCityID: 3 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'spy rejects a neutral actor',
|
|
action: 'che_첩보',
|
|
args: { destCityID: 70 },
|
|
actorPatch: { nationId: 0 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'spy rejects insufficient gold',
|
|
action: 'che_첩보',
|
|
args: { destCityID: 70 },
|
|
actorPatch: { gold: 53 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'spy rejects insufficient rice',
|
|
action: 'che_첩보',
|
|
args: { destCityID: 70 },
|
|
actorPatch: { rice: 53 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'spy reports full adjacent intelligence and tech advantage',
|
|
action: 'che_첩보',
|
|
args: { destCityID: 70 },
|
|
fixturePatches: { nations: { 1: { tech: 1000 }, 2: { tech: 2000 } } },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'spy reports medium intelligence at distance two',
|
|
action: 'che_첩보',
|
|
args: { destCityID: 1 },
|
|
fixturePatches: {
|
|
generals: { 2: { cityId: 1 } },
|
|
additionalCities: [
|
|
{
|
|
id: 1,
|
|
nationId: 2,
|
|
population: 123_456,
|
|
populationMax: 620_500,
|
|
agriculture: 1111,
|
|
commerce: 2222,
|
|
security: 3333,
|
|
defence: 4444,
|
|
wall: 5555,
|
|
supplyState: 1,
|
|
frontState: 1,
|
|
state: 0,
|
|
term: 0,
|
|
trust: 66.6,
|
|
trade: 100,
|
|
},
|
|
],
|
|
},
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'spy reports only rumors beyond distance two',
|
|
action: 'che_첩보',
|
|
args: { destCityID: 4 },
|
|
fixturePatches: {
|
|
generals: { 2: { cityId: 4 } },
|
|
additionalCities: [
|
|
{
|
|
id: 4,
|
|
nationId: 2,
|
|
population: 234_567,
|
|
populationMax: 655_700,
|
|
agriculture: 1111,
|
|
commerce: 2222,
|
|
security: 3333,
|
|
defence: 4444,
|
|
wall: 5555,
|
|
supplyState: 1,
|
|
frontState: 1,
|
|
state: 0,
|
|
term: 0,
|
|
trust: 55.5,
|
|
trade: 100,
|
|
},
|
|
],
|
|
},
|
|
completed: true,
|
|
},
|
|
];
|
|
|
|
integration('general training, talent scout, and spy boundary, value, RNG, and log parity', () => {
|
|
it.each(trainingScoutBoundaryCases)(
|
|
'$name',
|
|
async ({ name, action, args, actorPatch, fixturePatches, hiddenSeed, completed, fallback }) => {
|
|
const request = buildRequest(action, args, actorPatch, fixturePatches);
|
|
request.setup!.world!.hiddenSeed = hiddenSeed ?? `general-training-scout-${name}`;
|
|
request.observe!.includeGlobalHistoryLogs = true;
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
const usedFallback = fallback ?? !completed;
|
|
|
|
expect(reference.execution.outcome).toMatchObject({ completed });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: action,
|
|
actionKey: usedFallback ? '휴식' : action,
|
|
usedFallback,
|
|
});
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
|
|
if (completed) {
|
|
expect(semanticLogSignatures(core.after.logs)).toEqual(
|
|
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
|
|
);
|
|
}
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
type GeneralAppointmentBoundaryCase = {
|
|
name: string;
|
|
action: 'che_임관' | 'che_랜덤임관' | 'che_장수대상임관' | 'che_등용' | 'che_등용수락';
|
|
args?: Record<string, unknown>;
|
|
actorPatch?: Record<string, unknown>;
|
|
fixturePatches?: FixturePatches;
|
|
completed: boolean;
|
|
expectedActionKey?: string;
|
|
restoresWorldKillTurn?: boolean;
|
|
};
|
|
|
|
const generalAppointmentBoundaryCases: GeneralAppointmentBoundaryCase[] = [
|
|
{
|
|
name: 'direct appointment rejects a fractional nation ID',
|
|
action: 'che_임관',
|
|
args: { destNationID: 1.9 },
|
|
actorPatch: { nationId: 0, officerLevel: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'direct appointment rejects the cached opening general limit',
|
|
action: 'che_임관',
|
|
args: { destNationID: 1 },
|
|
actorPatch: { nationId: 0, officerLevel: 0 },
|
|
fixturePatches: {
|
|
world: { startYear: 180, year: 182 },
|
|
nations: { 1: { generalCount: 10 } },
|
|
},
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'direct appointment has no normal-era max-general constraint',
|
|
action: 'che_임관',
|
|
args: { destNationID: 1 },
|
|
actorPatch: { nationId: 0, officerLevel: 0 },
|
|
fixturePatches: {
|
|
world: { startYear: 180, year: 183 },
|
|
nations: { 1: { generalCount: 500 } },
|
|
generals: { 3: { officerLevel: 12, officerCityId: 3 } },
|
|
},
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'direct appointment blocks user generals from governor nations',
|
|
action: 'che_임관',
|
|
args: { destNationID: 1 },
|
|
actorPatch: { nationId: 0, officerLevel: 0, npcState: 0 },
|
|
fixturePatches: { nations: { 1: { name: 'ⓤ아국' } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'direct appointment blocks ordinary NPCs from outsider nations',
|
|
action: 'che_임관',
|
|
args: { destNationID: 1 },
|
|
actorPatch: { nationId: 0, officerLevel: 0, npcState: 2 },
|
|
fixturePatches: { nations: { 1: { name: 'ⓞ아국' } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'direct appointment permits outsider NPC type nine',
|
|
action: 'che_임관',
|
|
args: { destNationID: 1 },
|
|
actorPatch: { nationId: 0, officerLevel: 0, npcState: 9 },
|
|
fixturePatches: {
|
|
nations: { 1: { name: 'ⓞ아국' } },
|
|
generals: { 3: { officerLevel: 12, officerCityId: 3 } },
|
|
},
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'follow appointment uses cached general count at the opening limit',
|
|
action: 'che_장수대상임관',
|
|
args: { destGeneralID: 2 },
|
|
actorPatch: { nationId: 0, officerLevel: 0 },
|
|
fixturePatches: {
|
|
world: { startYear: 180, year: 182 },
|
|
nations: { 2: { generalCount: 10 } },
|
|
},
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'follow appointment blocks user generals from governor nations',
|
|
action: 'che_장수대상임관',
|
|
args: { destGeneralID: 2 },
|
|
actorPatch: { nationId: 0, officerLevel: 0, npcState: 0 },
|
|
fixturePatches: { nations: { 2: { name: 'ⓤ타국' } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'follow appointment blocks ordinary NPCs from outsider nations',
|
|
action: 'che_장수대상임관',
|
|
args: { destGeneralID: 2 },
|
|
actorPatch: { nationId: 0, officerLevel: 0, npcState: 2 },
|
|
fixturePatches: { nations: { 2: { name: 'ⓞ타국' } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'follow appointment preserves the legacy troop field',
|
|
action: 'che_장수대상임관',
|
|
args: { destGeneralID: 2 },
|
|
actorPatch: { nationId: 0, officerLevel: 0, troopId: 7 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'random appointment uses cached general counts during the opening',
|
|
action: 'che_랜덤임관',
|
|
actorPatch: { nationId: 0, officerLevel: 0 },
|
|
fixturePatches: {
|
|
world: { startYear: 180, year: 182 },
|
|
nations: { 1: { generalCount: 10 }, 2: { generalCount: 10 } },
|
|
},
|
|
completed: false,
|
|
expectedActionKey: 'che_인재탐색',
|
|
},
|
|
{
|
|
name: 'random appointment preserves the legacy troop field',
|
|
action: 'che_랜덤임관',
|
|
actorPatch: { nationId: 0, officerLevel: 0, troopId: 7 },
|
|
fixturePatches: { generals: { 3: { officerLevel: 12, officerCityId: 3 } } },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'random appointment uses cached general counts at the normal limit',
|
|
action: 'che_랜덤임관',
|
|
actorPatch: { nationId: 0, officerLevel: 0 },
|
|
fixturePatches: {
|
|
world: { startYear: 180, year: 183 },
|
|
nations: { 1: { generalCount: 500 }, 2: { generalCount: 500 } },
|
|
},
|
|
completed: false,
|
|
expectedActionKey: 'che_인재탐색',
|
|
},
|
|
{
|
|
name: 'employment rejects a same-nation target',
|
|
action: 'che_등용',
|
|
args: { destGeneralID: 3 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'employment rejects a target monarch',
|
|
action: 'che_등용',
|
|
args: { destGeneralID: 2 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'employment rejects a neutral actor',
|
|
action: 'che_등용',
|
|
args: { destGeneralID: 2 },
|
|
actorPatch: { nationId: 0, officerLevel: 0 },
|
|
fixturePatches: { generals: { 2: { officerLevel: 1 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'employment rejects an unsupplied actor city',
|
|
action: 'che_등용',
|
|
args: { destGeneralID: 2 },
|
|
fixturePatches: {
|
|
generals: { 2: { officerLevel: 1 } },
|
|
cities: { 3: { supplyState: 0 } },
|
|
},
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'accepting employment applies the opening cached limit',
|
|
action: 'che_등용수락',
|
|
args: { destNationID: 2, destGeneralID: 2 },
|
|
actorPatch: { nationId: 0, officerLevel: 0 },
|
|
fixturePatches: {
|
|
world: { startYear: 180, year: 182 },
|
|
nations: { 2: { generalCount: 10 } },
|
|
},
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'accepting employment has no normal-era max-general constraint',
|
|
action: 'che_등용수락',
|
|
args: { destNationID: 2, destGeneralID: 2 },
|
|
actorPatch: { nationId: 0, officerLevel: 0 },
|
|
fixturePatches: {
|
|
world: { startYear: 180, year: 183 },
|
|
nations: { 2: { generalCount: 500 } },
|
|
},
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'accepting employment rejects the actor as recruiter',
|
|
action: 'che_등용수락',
|
|
args: { destNationID: 2, destGeneralID: 1 },
|
|
actorPatch: { nationId: 0, officerLevel: 0 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'accepting employment rejects a wandering destination nation',
|
|
action: 'che_등용수락',
|
|
args: { destNationID: 2, destGeneralID: 2 },
|
|
actorPatch: { nationId: 0, officerLevel: 0 },
|
|
fixturePatches: { nations: { 2: { level: 0 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'accepting employment restores the user kill-turn allowance',
|
|
action: 'che_등용수락',
|
|
args: { destNationID: 2, destGeneralID: 2 },
|
|
actorPatch: { nationId: 0, officerLevel: 0, npcState: 0, killTurn: 5 },
|
|
completed: true,
|
|
restoresWorldKillTurn: true,
|
|
},
|
|
];
|
|
|
|
integration('general appointment and employment boundary, state, RNG, and log parity', () => {
|
|
it.each(generalAppointmentBoundaryCases)(
|
|
'$name',
|
|
async ({
|
|
name,
|
|
action,
|
|
args,
|
|
actorPatch,
|
|
fixturePatches,
|
|
completed,
|
|
expectedActionKey,
|
|
restoresWorldKillTurn,
|
|
}) => {
|
|
const request = buildRequest(action, args, actorPatch, fixturePatches);
|
|
request.setup!.world!.hiddenSeed = `general-appointment-${name}`;
|
|
request.observe!.includeGlobalHistoryLogs = true;
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
const actionKey = expectedActionKey ?? (completed ? action : '휴식');
|
|
|
|
expect(reference.execution.outcome).toMatchObject({ completed });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: action,
|
|
actionKey,
|
|
usedFallback: !completed && actionKey === '휴식',
|
|
});
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
|
|
if (completed) {
|
|
expect(semanticLogSignatures(core.after.logs)).toEqual(
|
|
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
|
|
);
|
|
}
|
|
if (restoresWorldKillTurn) {
|
|
const expectedKillTurn = reference.before.world.killTurn;
|
|
expect(reference.after.generals.find((entry) => entry.id === 1)?.killTurn).toBe(expectedKillTurn);
|
|
expect(core.after.generals.find((entry) => entry.id === 1)?.killTurn).toBe(expectedKillTurn);
|
|
}
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
integration('명장일람 rank_data command parity', () => {
|
|
it('화계 increments firenum from the same seeded value as legacy', async () => {
|
|
const request = buildRequest(
|
|
'che_화계',
|
|
{ destCityID: 70 },
|
|
{ intelligence: 100 },
|
|
{
|
|
generals: { 2: { intelligence: 10 } },
|
|
rankData: [{ generalId: 1, type: 'firenum', value: 17 }],
|
|
}
|
|
);
|
|
request.setup!.world!.hiddenSeed = 'general-value-0';
|
|
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(hasSuccessfulSabotageLog(reference.after.logs)).toBe(true);
|
|
expect(hasSuccessfulSabotageLog(core.after.logs)).toBe(true);
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(reference.after.rankData).toContainEqual(
|
|
expect.objectContaining({ generalId: 1, type: 'firenum', value: 18 })
|
|
);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
}, 120_000);
|
|
|
|
it('은퇴 resets every legacy RankColumn row exactly like legacy', async () => {
|
|
const request = buildRequest(
|
|
'che_은퇴',
|
|
undefined,
|
|
{ age: 65, lastTurn: { command: '은퇴', term: 1 } },
|
|
{
|
|
rankData: LEGACY_RANK_DATA_TYPES.map((type, index) => ({
|
|
generalId: 1,
|
|
type,
|
|
value: index + 1,
|
|
})),
|
|
}
|
|
);
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
|
|
expect(reference.after.rankData.filter((row) => row.generalId === 1)).toHaveLength(
|
|
LEGACY_RANK_DATA_TYPES.length
|
|
);
|
|
expect(reference.after.rankData.filter((row) => row.generalId === 1).every((row) => row.value === 0)).toBe(
|
|
true
|
|
);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
}, 120_000);
|
|
});
|
|
|
|
type GeneralFailureCase = {
|
|
action: string;
|
|
args?: Record<string, unknown>;
|
|
hiddenSeed: string;
|
|
failureText: string;
|
|
};
|
|
|
|
const failureCases: GeneralFailureCase[] = [
|
|
{
|
|
action: 'che_주민선정',
|
|
hiddenSeed: 'general-failure-che_주민선정-0',
|
|
failureText: "주민 선정을 <span class='ev_failed'>실패</span>",
|
|
},
|
|
{
|
|
action: 'che_정착장려',
|
|
hiddenSeed: 'general-failure-che_정착장려-0',
|
|
failureText: "정착 장려를 <span class='ev_failed'>실패</span>",
|
|
},
|
|
{
|
|
action: 'che_상업투자',
|
|
hiddenSeed: 'general-failure-che_상업투자-2',
|
|
failureText: "상업 투자를 <span class='ev_failed'>실패</span>",
|
|
},
|
|
{
|
|
action: 'che_기술연구',
|
|
hiddenSeed: 'general-failure-che_기술연구-0',
|
|
failureText: "기술 연구를 <span class='ev_failed'>실패</span>",
|
|
},
|
|
{
|
|
action: 'che_물자조달',
|
|
hiddenSeed: 'general-failure-che_물자조달-1',
|
|
failureText: "조달을 <span class='ev_failed'>실패</span>",
|
|
},
|
|
{
|
|
action: 'che_화계',
|
|
args: { destCityID: 70 },
|
|
hiddenSeed: 'general-failure-che_화계-0',
|
|
failureText: '화계가 실패했습니다.',
|
|
},
|
|
{
|
|
action: 'che_선동',
|
|
args: { destCityID: 70 },
|
|
hiddenSeed: 'general-failure-che_선동-0',
|
|
failureText: '선동이 실패했습니다.',
|
|
},
|
|
{
|
|
action: 'che_파괴',
|
|
args: { destCityID: 70 },
|
|
hiddenSeed: 'general-failure-che_파괴-0',
|
|
failureText: '파괴가 실패했습니다.',
|
|
},
|
|
{
|
|
action: 'che_탈취',
|
|
args: { destCityID: 70 },
|
|
hiddenSeed: 'general-failure-che_탈취-0',
|
|
failureText: '탈취가 실패했습니다.',
|
|
},
|
|
];
|
|
|
|
const failureLogTexts = (logs: Array<Record<string, unknown>>, failureText: string): string[] =>
|
|
logs
|
|
.map((entry) => entry.text)
|
|
.filter((text): text is string => typeof text === 'string' && text.includes(failureText));
|
|
|
|
const legacyActionLogBody = (text: string): string => {
|
|
const match = /^<C>●<\/>\d+월:(.*) <1>\d{2}:\d{2}<\/>$/.exec(text);
|
|
return match?.[1] ?? text;
|
|
};
|
|
|
|
integration('general command in-action failure matrix', () => {
|
|
it.each(failureCases)(
|
|
'$action matches legacy failure RNG, side effects, and failure log',
|
|
async ({ action, args, hiddenSeed, failureText }) => {
|
|
const request = buildRequest(action, args);
|
|
request.setup!.world!.hiddenSeed = hiddenSeed;
|
|
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: action,
|
|
actionKey: action,
|
|
usedFallback: false,
|
|
});
|
|
expect(core.execution.outcome).not.toHaveProperty('blockedReason');
|
|
expect(failureLogTexts(reference.after.logs, failureText)).toHaveLength(1);
|
|
expect(failureLogTexts(core.after.logs, failureText)).toEqual(
|
|
failureLogTexts(reference.after.logs, failureText).map(legacyActionLogBody)
|
|
);
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
type GeneralStatusTransitionBoundaryCase = {
|
|
name: string;
|
|
action: 'che_하야' | 'che_은퇴' | 'che_선양';
|
|
args?: Record<string, unknown>;
|
|
actorPatch?: Record<string, unknown>;
|
|
fixturePatches?: FixturePatches;
|
|
completed: boolean;
|
|
expectedActionKey?: string;
|
|
compareLogs?: boolean;
|
|
};
|
|
|
|
const generalStatusTransitionBoundaryCases: GeneralStatusTransitionBoundaryCase[] = [
|
|
{
|
|
name: 'resignation applies the betrayal penalty and returns excess resources',
|
|
action: 'che_하야',
|
|
actorPatch: {
|
|
officerLevel: 1,
|
|
experience: 1001,
|
|
dedication: 1003,
|
|
gold: 1500,
|
|
rice: 1600,
|
|
betray: 3,
|
|
},
|
|
completed: true,
|
|
compareLogs: true,
|
|
},
|
|
{
|
|
name: 'resigning troop leader releases every member',
|
|
action: 'che_하야',
|
|
actorPatch: { officerLevel: 1, troopId: 1 },
|
|
fixturePatches: {
|
|
generals: { 3: { troopId: 1 } },
|
|
troops: [{ id: 1, nationId: 1, name: '아국군' }],
|
|
},
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'retirement rejects age fifty nine',
|
|
action: 'che_은퇴',
|
|
actorPatch: { age: 59, lastTurn: { command: '은퇴', term: 1 } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'retirement rounds odd attributes and resets speciality ages and ranks',
|
|
action: 'che_은퇴',
|
|
actorPatch: {
|
|
age: 60,
|
|
leadership: 91,
|
|
strength: 81,
|
|
intelligence: 71,
|
|
experience: 1001,
|
|
dedication: 1003,
|
|
dex1: 101,
|
|
dex2: 103,
|
|
dex3: 105,
|
|
dex4: 107,
|
|
dex5: 109,
|
|
specAge: 65,
|
|
specAge2: 66,
|
|
lastTurn: { command: '은퇴', term: 1 },
|
|
},
|
|
fixturePatches: {
|
|
rankData: LEGACY_RANK_DATA_TYPES.map((type, index) => ({
|
|
generalId: 1,
|
|
type,
|
|
value: index + 1,
|
|
})),
|
|
},
|
|
completed: true,
|
|
compareLogs: true,
|
|
},
|
|
{
|
|
name: 'abdication rejects a fractional target ID',
|
|
action: 'che_선양',
|
|
args: { destGeneralID: 3.5 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'abdication rejects the acting monarch as target',
|
|
action: 'che_선양',
|
|
args: { destGeneralID: 1 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'abdication rejects a target with no-chief penalty in action',
|
|
action: 'che_선양',
|
|
args: { destGeneralID: 3 },
|
|
fixturePatches: { generals: { 3: { penalty: { noChief: true } } } },
|
|
completed: false,
|
|
expectedActionKey: 'che_선양',
|
|
compareLogs: true,
|
|
},
|
|
{
|
|
name: 'abdication transfers the monarch office and experience penalty',
|
|
action: 'che_선양',
|
|
args: { destGeneralID: 3 },
|
|
actorPatch: { experience: 1001 },
|
|
completed: true,
|
|
compareLogs: true,
|
|
},
|
|
];
|
|
|
|
integration('general resignation, retirement, and abdication boundary, state, and log parity', () => {
|
|
it.each(generalStatusTransitionBoundaryCases)(
|
|
'$name',
|
|
async ({ name, action, args, actorPatch, fixturePatches, completed, expectedActionKey, compareLogs }) => {
|
|
const request = buildRequest(action, args, actorPatch, fixturePatches);
|
|
request.setup!.world!.hiddenSeed = `general-status-transition-${name}`;
|
|
request.observe!.includeGlobalHistoryLogs = true;
|
|
request.observe!.includeNationHistoryLogs = true;
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
const actionKey = expectedActionKey ?? (completed ? action : '휴식');
|
|
const coreCompleted = actionKey === '휴식' ? true : completed;
|
|
|
|
expect(reference.execution.outcome).toMatchObject({ completed });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: action,
|
|
actionKey,
|
|
usedFallback: !completed && actionKey === '휴식',
|
|
completed: coreCompleted,
|
|
});
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
|
|
if (compareLogs) {
|
|
expect(semanticLogSignatures(core.after.logs)).toEqual(
|
|
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
|
|
);
|
|
}
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
type GeneralFoundingRebellionBoundaryCase = {
|
|
name: string;
|
|
action: 'che_거병' | 'che_모반시도' | 'che_건국' | 'cr_건국' | 'che_무작위건국';
|
|
args?: Record<string, unknown>;
|
|
actorPatch?: Record<string, unknown>;
|
|
fixturePatches?: FixturePatches;
|
|
completed: boolean;
|
|
expectedActionKey?: string;
|
|
compareLogs?: boolean;
|
|
};
|
|
|
|
const foundingArgs = { nationName: '신국', nationType: 'che_도적', colorType: 1 };
|
|
const wanderingFoundingFixture = (overrides: FixturePatches = {}): FixturePatches => ({
|
|
...overrides,
|
|
world: { startYear: 180, initYear: 180, initMonth: 1, year: 181, ...overrides.world },
|
|
nations: {
|
|
...overrides.nations,
|
|
1: { level: 0, capitalCityId: 0, typeCode: 'None', generalCount: 2, ...overrides.nations?.[1] },
|
|
},
|
|
cities: {
|
|
...overrides.cities,
|
|
3: { nationId: 0, level: 5, ...overrides.cities?.[3] },
|
|
},
|
|
});
|
|
|
|
const generalFoundingRebellionBoundaryCases: GeneralFoundingRebellionBoundaryCase[] = [
|
|
{
|
|
name: 'uprising rejects a no-founding penalty from the general column',
|
|
action: 'che_거병',
|
|
actorPatch: { nationId: 0, officerLevel: 0, penalty: { noFoundNation: true } },
|
|
fixturePatches: { world: { startYear: 180, year: 181 } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'uprising rejects a remaining join-action cooldown',
|
|
action: 'che_거병',
|
|
actorPatch: { nationId: 0, officerLevel: 0, makeLimit: 1 },
|
|
fixturePatches: { world: { startYear: 180, year: 181 } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'uprising trims a duplicate full-width nation name and preserves logs',
|
|
action: 'che_거병',
|
|
actorPatch: { name: '가나다라마바사아자차', nationId: 0, officerLevel: 0 },
|
|
fixturePatches: {
|
|
world: { startYear: 180, year: 181 },
|
|
nations: { 1: { name: '가나다라마바사아자차' } },
|
|
},
|
|
completed: true,
|
|
compareLogs: true,
|
|
},
|
|
{
|
|
name: 'rebellion rejects an active monarch',
|
|
action: 'che_모반시도',
|
|
actorPatch: { officerLevel: 11 },
|
|
fixturePatches: { generals: { 3: { officerLevel: 12, officerCityId: 3, killTurn: 999_999 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'rebellion rejects an npc monarch',
|
|
action: 'che_모반시도',
|
|
actorPatch: { officerLevel: 11 },
|
|
fixturePatches: { generals: { 3: { officerLevel: 12, officerCityId: 3, killTurn: 0, npcState: 2 } } },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'rebellion rounds the former monarch experience and transfers the chief cache',
|
|
action: 'che_모반시도',
|
|
actorPatch: { officerLevel: 11 },
|
|
fixturePatches: {
|
|
generals: { 3: { officerLevel: 12, officerCityId: 3, killTurn: 0, experience: 1001 } },
|
|
},
|
|
completed: true,
|
|
compareLogs: true,
|
|
},
|
|
...(['che_건국', 'cr_건국', 'che_무작위건국'] as const).map((action) => ({
|
|
name: `${action} rejects a fractional color index`,
|
|
action,
|
|
args: { ...foundingArgs, colorType: 1.5 },
|
|
fixturePatches: wanderingFoundingFixture(),
|
|
completed: false,
|
|
})),
|
|
{
|
|
name: 'founding rejects a nation name wider than eighteen legacy columns',
|
|
action: 'che_건국',
|
|
args: { ...foundingArgs, nationName: '가나다라마바사아자차' },
|
|
fixturePatches: wanderingFoundingFixture(),
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'founding rejects an unavailable nation type',
|
|
action: 'che_건국',
|
|
args: { ...foundingArgs, nationType: 'che_없는국가형' },
|
|
fixturePatches: wanderingFoundingFixture(),
|
|
completed: false,
|
|
},
|
|
...(['che_건국', 'cr_건국', 'che_무작위건국'] as const).map((action) => ({
|
|
name: `${action} uses the cached nation general count`,
|
|
action,
|
|
args: foundingArgs,
|
|
fixturePatches: wanderingFoundingFixture({ nations: { 1: { generalCount: 1 } } }),
|
|
completed: false,
|
|
})),
|
|
{
|
|
name: 'founding continues into talent scout during the initial month',
|
|
action: 'che_건국',
|
|
args: foundingArgs,
|
|
fixturePatches: wanderingFoundingFixture({
|
|
world: { startYear: 180, initYear: 181, initMonth: 1, year: 181 },
|
|
}),
|
|
completed: false,
|
|
expectedActionKey: 'che_인재탐색',
|
|
},
|
|
{
|
|
name: 'restricted founding continues into talent scout during the initial month',
|
|
action: 'cr_건국',
|
|
args: foundingArgs,
|
|
fixturePatches: wanderingFoundingFixture({
|
|
world: { startYear: 180, initYear: 181, initMonth: 1, year: 181 },
|
|
}),
|
|
completed: false,
|
|
expectedActionKey: 'che_인재탐색',
|
|
},
|
|
...(['che_건국', 'cr_건국', 'che_무작위건국'] as const).map((action) => ({
|
|
name: `${action} clears the founding city conflict and preserves success logs`,
|
|
action,
|
|
args: foundingArgs,
|
|
fixturePatches: wanderingFoundingFixture({
|
|
cities: { 3: { conflict: { 2: 7 } } },
|
|
randomFoundingCandidateCityIds: action === 'che_무작위건국' ? [3] : undefined,
|
|
}),
|
|
completed: true,
|
|
compareLogs: true,
|
|
})),
|
|
];
|
|
|
|
integration('general uprising, rebellion, and founding boundary, state, RNG, and log parity', () => {
|
|
it.each(generalFoundingRebellionBoundaryCases)(
|
|
'$name',
|
|
async ({ name, action, args, actorPatch, fixturePatches, completed, expectedActionKey, compareLogs }) => {
|
|
const request = buildRequest(action, args, actorPatch, fixturePatches);
|
|
request.setup!.world!.hiddenSeed = `general-founding-rebellion-${name}`;
|
|
if (action === 'che_거병') {
|
|
request.observe!.nationIds!.push(3);
|
|
}
|
|
request.observe!.includeGlobalHistoryLogs = true;
|
|
request.observe!.includeNationHistoryLogs = true;
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
const actionKey = expectedActionKey ?? (completed ? action : '휴식');
|
|
|
|
expect(reference.execution.outcome).toMatchObject({ completed });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: action,
|
|
actionKey,
|
|
usedFallback: !completed && actionKey === '휴식',
|
|
});
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
|
|
if (compareLogs) {
|
|
expect(semanticLogSignatures(core.after.logs)).toEqual(
|
|
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
|
|
);
|
|
}
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
type SabotageProbabilityClampCase = {
|
|
name: string;
|
|
action: 'che_화계' | 'che_선동' | 'che_파괴' | 'che_탈취';
|
|
stat: 'leadership' | 'strength' | 'intelligence';
|
|
boundary: 'zero' | 'max';
|
|
};
|
|
|
|
const sabotageStatProgressionCases = [
|
|
{ action: 'che_화계', stat: 'intelligence', statExp: 'intelExp' },
|
|
{ action: 'che_선동', stat: 'leadership', statExp: 'leadershipExp' },
|
|
{ action: 'che_파괴', stat: 'strength', statExp: 'strengthExp' },
|
|
{ action: 'che_탈취', stat: 'strength', statExp: 'strengthExp' },
|
|
] as const;
|
|
|
|
const sabotageSuccessfulEffectCases = sabotageStatProgressionCases.map(({ action, stat }) => ({ action, stat }));
|
|
|
|
const sabotageSuccessfulEffectExpected = {
|
|
che_화계: { city: { agriculture: 494, commerce: 859, state: 32 } },
|
|
che_선동: { city: { security: 0, trust: 70.1066, state: 32 } },
|
|
che_파괴: { city: { defence: 536, wall: 222, state: 32 } },
|
|
che_탈취: {
|
|
city: { state: 32 },
|
|
nation: { gold: 999_341, rice: 999_200 },
|
|
actor: { gold: 100_108, rice: 100_140 },
|
|
},
|
|
} as const;
|
|
|
|
integration('general sabotage successful effect matrix', () => {
|
|
it.each(sabotageSuccessfulEffectCases)(
|
|
'$action executes a real general turn at the 0.5 probability clamp',
|
|
async ({ action, stat }) => {
|
|
const request = buildRequest(
|
|
action,
|
|
{ destCityID: 70 },
|
|
{ [stat]: 100 },
|
|
{
|
|
generals: { 2: { [stat]: 10 } },
|
|
cities: { 70: { security: 100, securityMax: 2_000, supplyState: 1 } },
|
|
}
|
|
);
|
|
request.setup!.world!.hiddenSeed = 'general-value-0';
|
|
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: action,
|
|
actionKey: action,
|
|
usedFallback: false,
|
|
});
|
|
expect(reference.rng[0]).toMatchObject({
|
|
operation: 'nextBits',
|
|
arguments: { bits: 1 },
|
|
result: '01',
|
|
});
|
|
expect(hasSuccessfulSabotageLog(reference.after.logs)).toBe(true);
|
|
expect(hasSuccessfulSabotageLog(core.after.logs)).toBe(true);
|
|
expect(core.rng).toEqual(reference.rng);
|
|
const findById = (rows: Array<Record<string, unknown>>, id: number) =>
|
|
rows.find((entry) => entry.id === id);
|
|
const expected = sabotageSuccessfulEffectExpected[action];
|
|
expect(findById(reference.after.cities, 70)).toMatchObject(expected.city);
|
|
if ('nation' in expected) {
|
|
expect(findById(reference.after.nations, 2)).toMatchObject(expected.nation);
|
|
}
|
|
if ('actor' in expected) {
|
|
expect(findById(reference.after.generals, 1)).toMatchObject(expected.actor);
|
|
}
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
|
|
if (process.env.TURN_DIFFERENTIAL_SABOTAGE_EVIDENCE === '1') {
|
|
process.stderr.write(
|
|
`${JSON.stringify({
|
|
action,
|
|
probability: 0.5,
|
|
rng: reference.rng,
|
|
reference: {
|
|
actorBefore: findById(reference.before.generals, 1),
|
|
actorAfter: findById(reference.after.generals, 1),
|
|
targetCityBefore: findById(reference.before.cities, 70),
|
|
targetCityAfter: findById(reference.after.cities, 70),
|
|
targetNationBefore: findById(reference.before.nations, 2),
|
|
targetNationAfter: findById(reference.after.nations, 2),
|
|
},
|
|
core: {
|
|
actorAfter: findById(core.after.generals, 1),
|
|
targetCityAfter: findById(core.after.cities, 70),
|
|
targetNationAfter: findById(core.after.nations, 2),
|
|
},
|
|
})}\n`
|
|
);
|
|
}
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
integration('general sabotage stat progression matrix', () => {
|
|
it.each(sabotageStatProgressionCases)(
|
|
'$action inherits the base strategy stat progression tail',
|
|
async ({ action, stat, statExp }) => {
|
|
const request = buildRequest(
|
|
action,
|
|
{ destCityID: 70 },
|
|
{ [stat]: 100, [statExp]: 29 },
|
|
{
|
|
generals: { 2: { [stat]: 10 } },
|
|
cities: { 70: { security: 0, securityMax: 2_000 } },
|
|
}
|
|
);
|
|
request.setup!.world!.hiddenSeed = 'general-value-0';
|
|
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: action,
|
|
actionKey: action,
|
|
usedFallback: false,
|
|
});
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(reference.after.generals.find((entry) => entry.id === 1)?.[stat]).toBe(101);
|
|
expect(core.after.generals.find((entry) => entry.id === 1)?.[stat]).toBe(101);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
const sabotageProbabilityClampCases: SabotageProbabilityClampCase[] = (
|
|
[
|
|
['che_화계', 'intelligence'],
|
|
['che_선동', 'leadership'],
|
|
['che_파괴', 'strength'],
|
|
['che_탈취', 'strength'],
|
|
] as const
|
|
).flatMap(([action, stat]) => [
|
|
{ name: `${action} probability zero clamp`, action, stat, boundary: 'zero' },
|
|
{ name: `${action} probability 0.5 clamp`, action, stat, boundary: 'max' },
|
|
]);
|
|
|
|
integration('general sabotage probability clamp matrix', () => {
|
|
it.each(sabotageProbabilityClampCases)(
|
|
'$name preserves the legacy clamp and RNG primitive',
|
|
async ({ action, stat, boundary }) => {
|
|
const isZero = boundary === 'zero';
|
|
const request = buildRequest(
|
|
action,
|
|
{ destCityID: 70 },
|
|
{ [stat]: isZero ? 10 : 100 },
|
|
{
|
|
generals: { 2: { [stat]: isZero ? 100 : 10 } },
|
|
cities: {
|
|
70: {
|
|
security: isZero ? 2_000 : 0,
|
|
securityMax: 2_000,
|
|
supplyState: 1,
|
|
},
|
|
},
|
|
}
|
|
);
|
|
request.setup!.world!.hiddenSeed = `general-probability-clamp-${action}-${boundary}`;
|
|
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: action,
|
|
actionKey: action,
|
|
usedFallback: false,
|
|
});
|
|
expect(core.execution.outcome).not.toHaveProperty('blockedReason');
|
|
expect(reference.rng[0]).toMatchObject(
|
|
isZero
|
|
? { operation: 'nextInt', arguments: { maxInclusive: 99 } }
|
|
: { operation: 'nextBits', arguments: { bits: 1 } }
|
|
);
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
type SabotageValueBoundaryCase = {
|
|
name: string;
|
|
action: 'che_화계' | 'che_선동' | 'che_파괴' | 'che_탈취';
|
|
stat: 'leadership' | 'strength' | 'intelligence';
|
|
fixturePatches: FixturePatches;
|
|
};
|
|
|
|
const sabotageValueBoundaryCases: SabotageValueBoundaryCase[] = [
|
|
{
|
|
name: 'fire attack does not reduce agriculture or commerce below zero',
|
|
action: 'che_화계',
|
|
stat: 'intelligence',
|
|
fixturePatches: { cities: { 70: { agriculture: 1, commerce: 1 } } },
|
|
},
|
|
{
|
|
name: 'agitation does not reduce security or trust below zero',
|
|
action: 'che_선동',
|
|
stat: 'leadership',
|
|
fixturePatches: { cities: { 70: { security: 1, trust: 1 } } },
|
|
},
|
|
{
|
|
name: 'destruction does not reduce defence or wall below zero',
|
|
action: 'che_파괴',
|
|
stat: 'strength',
|
|
fixturePatches: { cities: { 70: { defence: 1, wall: 1 } } },
|
|
},
|
|
{
|
|
name: 'seizure does not take more than supplied nation resources',
|
|
action: 'che_탈취',
|
|
stat: 'strength',
|
|
fixturePatches: { nations: { 2: { gold: 1, rice: 1 } } },
|
|
},
|
|
{
|
|
name: 'seizure does not reduce unsupplied city resources below zero',
|
|
action: 'che_탈취',
|
|
stat: 'strength',
|
|
fixturePatches: {
|
|
cities: { 70: { agriculture: 1, commerce: 1, supplyState: 0 } },
|
|
},
|
|
},
|
|
];
|
|
|
|
const hasSuccessfulSabotageLog = (logs: Array<Record<string, unknown>>): boolean =>
|
|
logs.some((entry) => typeof entry.text === 'string' && entry.text.includes('성공했습니다.'));
|
|
|
|
integration('general sabotage value boundary matrix', () => {
|
|
it.each(sabotageValueBoundaryCases)(
|
|
'$name matches the legacy clamped state delta',
|
|
async ({ action, stat, fixturePatches }) => {
|
|
const request = buildRequest(
|
|
action,
|
|
{ destCityID: 70 },
|
|
{ [stat]: 100 },
|
|
{
|
|
...fixturePatches,
|
|
generals: {
|
|
...fixturePatches.generals,
|
|
2: { ...fixturePatches.generals?.[2], [stat]: 10 },
|
|
},
|
|
cities: {
|
|
...fixturePatches.cities,
|
|
70: {
|
|
...fixturePatches.cities?.[70],
|
|
security: 0,
|
|
securityMax: 2_000,
|
|
},
|
|
},
|
|
}
|
|
);
|
|
request.setup!.world!.hiddenSeed = 'general-value-0';
|
|
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: action,
|
|
actionKey: action,
|
|
usedFallback: false,
|
|
});
|
|
expect(hasSuccessfulSabotageLog(reference.after.logs)).toBe(true);
|
|
expect(hasSuccessfulSabotageLog(core.after.logs)).toBe(true);
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
type SabotageInjuryBoundaryCase = {
|
|
action: 'che_화계' | 'che_선동' | 'che_파괴';
|
|
stat: 'leadership' | 'strength' | 'intelligence';
|
|
hiddenSeed: string;
|
|
};
|
|
|
|
const sabotageInjuryBoundaryCases: SabotageInjuryBoundaryCase[] = [
|
|
{ action: 'che_화계', stat: 'intelligence', hiddenSeed: 'general-injury-4' },
|
|
{ action: 'che_선동', stat: 'leadership', hiddenSeed: 'general-injury-13' },
|
|
{ action: 'che_파괴', stat: 'strength', hiddenSeed: 'general-injury-4' },
|
|
];
|
|
|
|
const injuryLogTexts = (logs: Array<Record<string, unknown>>): string[] =>
|
|
logs
|
|
.map((entry) => entry.text)
|
|
.filter((text): text is string => typeof text === 'string' && text.includes('부상</>을 당했습니다.'));
|
|
|
|
const legacyInjuryLogBody = (text: string): string => text.replace(/^<C>●<\/>\d+월:/, '');
|
|
|
|
integration('general sabotage injury boundary matrix', () => {
|
|
it.each(sabotageInjuryBoundaryCases)(
|
|
'$action matches legacy injury cap, integer persistence, and log',
|
|
async ({ action, stat, hiddenSeed }) => {
|
|
const request = buildRequest(
|
|
action,
|
|
{ destCityID: 70 },
|
|
{ [stat]: 100 },
|
|
{
|
|
generals: {
|
|
2: {
|
|
[stat]: 10,
|
|
injury: 79,
|
|
crew: 101,
|
|
atmos: 51,
|
|
train: 51,
|
|
},
|
|
},
|
|
cities: { 70: { security: 0, securityMax: 2_000 } },
|
|
}
|
|
);
|
|
request.setup!.world!.hiddenSeed = hiddenSeed;
|
|
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: action,
|
|
actionKey: action,
|
|
usedFallback: false,
|
|
});
|
|
expect(injuryLogTexts(reference.after.logs)).toHaveLength(1);
|
|
expect(injuryLogTexts(core.after.logs)).toEqual(
|
|
injuryLogTexts(reference.after.logs).map(legacyInjuryLogBody)
|
|
);
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
integration('general command alternative matrix', () => {
|
|
const expectAlternativeParity = async (
|
|
request: TurnCommandFixtureRequest,
|
|
expectedActionKey: string,
|
|
expectedLogText: string
|
|
): Promise<void> => {
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
|
|
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: request.action,
|
|
actionKey: expectedActionKey,
|
|
usedFallback: false,
|
|
});
|
|
expect(
|
|
reference.after.logs.some((entry) => typeof entry.text === 'string' && entry.text.includes(expectedLogText))
|
|
).toBe(true);
|
|
expect(
|
|
core.after.logs.some((entry) => typeof entry.text === 'string' && entry.text.includes(expectedLogText))
|
|
).toBe(true);
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
};
|
|
|
|
it('che_해산 continues into che_인재탐색 with the legacy RNG and state delta', async () => {
|
|
const request = buildRequest(
|
|
'che_해산',
|
|
undefined,
|
|
{},
|
|
{
|
|
world: { initYear: 190, initMonth: 1 },
|
|
nations: { 1: { level: 0, capitalCityId: 0, typeCode: 'None' } },
|
|
}
|
|
);
|
|
request.setup!.world!.hiddenSeed = 'general-alternative-disband-search';
|
|
await expectAlternativeParity(request, 'che_인재탐색', '다음 턴부터 해산할 수 있습니다.');
|
|
}, 120_000);
|
|
|
|
it('che_랜덤임관 continues into che_인재탐색 when no eligible nation exists', async () => {
|
|
const request = buildRequest(
|
|
'che_랜덤임관',
|
|
undefined,
|
|
{ nationId: 0, officerLevel: 0 },
|
|
{
|
|
generals: {
|
|
2: { npcState: 5 },
|
|
3: { npcState: 5 },
|
|
},
|
|
}
|
|
);
|
|
request.setup!.world!.hiddenSeed = 'general-alternative-random-appointment-search';
|
|
await expectAlternativeParity(request, 'che_인재탐색', '임관 가능한 국가가 없습니다.');
|
|
}, 120_000);
|
|
|
|
it('che_무작위건국 continues into che_인재탐색 during the initial month', async () => {
|
|
const request = buildRequest(
|
|
'che_무작위건국',
|
|
{ nationName: '신국', nationType: 'che_도적', colorType: 1 },
|
|
{},
|
|
{
|
|
world: { startYear: 190, initYear: 190, initMonth: 1 },
|
|
nations: { 1: { level: 0, capitalCityId: 0, typeCode: 'None' } },
|
|
}
|
|
);
|
|
request.setup!.world!.hiddenSeed = 'general-alternative-random-founding-search';
|
|
await expectAlternativeParity(request, 'che_인재탐색', '다음 턴부터 건국할 수 있습니다.');
|
|
}, 120_000);
|
|
|
|
it('che_무작위건국 continues into che_해산 when no founding city exists', async () => {
|
|
const request = buildRequest(
|
|
'che_무작위건국',
|
|
{ nationName: '신국', nationType: 'che_도적', colorType: 1 },
|
|
{},
|
|
{
|
|
world: { initYear: 180, initMonth: 1, year: 181 },
|
|
nations: { 1: { level: 0, capitalCityId: 0, typeCode: 'None' } },
|
|
cities: {
|
|
3: { nationId: 1, level: 4 },
|
|
70: { nationId: 0, level: 4 },
|
|
},
|
|
randomFoundingCandidateCityIds: [3],
|
|
}
|
|
);
|
|
request.setup!.world!.hiddenSeed = 'general-alternative-random-founding-disband';
|
|
await expectAlternativeParity(request, 'che_해산', '건국할 수 있는 도시가 없습니다.');
|
|
}, 120_000);
|
|
|
|
it('che_출병 continues into che_이동 when the destination belongs to the actor nation', async () => {
|
|
const request = buildRequest(
|
|
'che_출병',
|
|
{ destCityID: 70 },
|
|
{},
|
|
{
|
|
world: { startYear: 180, year: 185 },
|
|
cities: { 70: { nationId: 1, supplyState: 1, frontState: 0 } },
|
|
generals: { 2: { nationId: 1 } },
|
|
}
|
|
);
|
|
request.setup!.world!.hiddenSeed = 'general-alternative-sortie-move';
|
|
await expectAlternativeParity(request, 'che_이동', '본국입니다.');
|
|
}, 120_000);
|
|
});
|
|
|
|
type GeneralPreReqBoundaryCase = {
|
|
name: string;
|
|
action: string;
|
|
actorPatch?: Record<string, unknown>;
|
|
expectedCommand: string;
|
|
expectedTerm: number;
|
|
expectedProgressText: string;
|
|
};
|
|
|
|
const generalPreReqBoundaryCases: GeneralPreReqBoundaryCase[] = [
|
|
{
|
|
name: 'battle preparation starts at term 1',
|
|
action: 'che_전투태세',
|
|
expectedCommand: '전투태세',
|
|
expectedTerm: 1,
|
|
expectedProgressText: '전투태세 수행중... (1/4)',
|
|
},
|
|
{
|
|
name: 'battle preparation advances term 1 to 2',
|
|
action: 'che_전투태세',
|
|
actorPatch: { lastTurn: { command: '전투태세', term: 1 } },
|
|
expectedCommand: '전투태세',
|
|
expectedTerm: 2,
|
|
expectedProgressText: '전투태세 수행중... (2/4)',
|
|
},
|
|
{
|
|
name: 'battle preparation advances term 2 to 3',
|
|
action: 'che_전투태세',
|
|
actorPatch: { lastTurn: { command: '전투태세', term: 2 } },
|
|
expectedCommand: '전투태세',
|
|
expectedTerm: 3,
|
|
expectedProgressText: '전투태세 수행중... (3/4)',
|
|
},
|
|
{
|
|
name: 'domestic trait reset starts at term 1 after another command',
|
|
action: 'che_내정특기초기화',
|
|
actorPatch: {
|
|
specialDomestic: 'che_인덕',
|
|
lastTurn: { command: '전투태세', term: 3 },
|
|
},
|
|
expectedCommand: '내정 특기 초기화',
|
|
expectedTerm: 1,
|
|
expectedProgressText: '새로운 적성을 찾는 중... (1/2)',
|
|
},
|
|
{
|
|
name: 'war trait reset starts at term 1',
|
|
action: 'che_전투특기초기화',
|
|
actorPatch: { specialWar: 'che_귀병' },
|
|
expectedCommand: '전투 특기 초기화',
|
|
expectedTerm: 1,
|
|
expectedProgressText: '새로운 적성을 찾는 중... (1/2)',
|
|
},
|
|
{
|
|
name: 'retirement starts at term 1 without applying retirement',
|
|
action: 'che_은퇴',
|
|
actorPatch: { age: 60 },
|
|
expectedCommand: '은퇴',
|
|
expectedTerm: 1,
|
|
expectedProgressText: '은퇴 수행중... (1/2)',
|
|
},
|
|
];
|
|
|
|
integration('general command pre-required turn boundary matrix', () => {
|
|
it.each(generalPreReqBoundaryCases)(
|
|
'$name matches legacy intermediate last-turn state without consuming command RNG',
|
|
async ({ action, actorPatch, expectedCommand, expectedTerm, expectedProgressText }) => {
|
|
const request = buildRequest(action, undefined, actorPatch);
|
|
request.setup!.world!.hiddenSeed = `general-prereq-${action}-${expectedTerm}`;
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
const referenceActor = reference.after.generals.find((entry) => entry.id === 1);
|
|
const coreActor = core.after.generals.find((entry) => entry.id === 1);
|
|
|
|
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: action,
|
|
actionKey: action,
|
|
usedFallback: false,
|
|
});
|
|
expect(referenceActor?.lastTurn).toMatchObject({
|
|
command: expectedCommand,
|
|
term: expectedTerm,
|
|
});
|
|
expect(coreActor?.lastTurn).toMatchObject({
|
|
command: expectedCommand,
|
|
term: expectedTerm,
|
|
});
|
|
expect(reference.after.logs.some((entry) => String(entry.text).includes(expectedProgressText))).toBe(true);
|
|
expect(core.after.logs.some((entry) => String(entry.text).includes(expectedProgressText))).toBe(true);
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
const readGeneralCooldown = (
|
|
snapshot: { world: Record<string, unknown> },
|
|
generalId: number,
|
|
actionName: string
|
|
): number | null => {
|
|
const cooldowns = Array.isArray(snapshot.world.generalCooldowns) ? snapshot.world.generalCooldowns : [];
|
|
const matched = cooldowns.find(
|
|
(entry) =>
|
|
typeof entry === 'object' &&
|
|
entry !== null &&
|
|
(entry as Record<string, unknown>).generalId === generalId &&
|
|
(entry as Record<string, unknown>).actionName === actionName
|
|
);
|
|
const value =
|
|
typeof matched === 'object' && matched !== null ? (matched as Record<string, unknown>).nextAvailableTurn : null;
|
|
return typeof value === 'number' ? value : null;
|
|
};
|
|
|
|
integration('general command post-required cooldown boundary matrix', () => {
|
|
it('stores the same 60-turn cooldown after domestic trait reset completion', async () => {
|
|
const request = buildRequest('che_내정특기초기화', undefined, {
|
|
specialDomestic: 'che_인덕',
|
|
lastTurn: { command: '내정 특기 초기화', term: 1 },
|
|
});
|
|
request.observe!.generalCooldowns = [{ generalId: 1, actionName: '내정 특기 초기화' }];
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
const expectedNextAvailableTurn = 190 * 12 + 1 - 1 + 60 - 1;
|
|
|
|
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: 'che_내정특기초기화',
|
|
actionKey: 'che_내정특기초기화',
|
|
usedFallback: false,
|
|
});
|
|
expect(readGeneralCooldown(reference.after, 1, '내정 특기 초기화')).toBe(expectedNextAvailableTurn);
|
|
expect(readGeneralCooldown(core.after, 1, '내정 특기 초기화')).toBe(expectedNextAvailableTurn);
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
}, 120_000);
|
|
|
|
it('blocks domestic trait reset one turn before the cooldown boundary', async () => {
|
|
const currentYearMonth = 190 * 12 + 1 - 1;
|
|
const request = buildRequest('che_내정특기초기화', undefined, {
|
|
specialDomestic: 'che_인덕',
|
|
lastTurn: { command: '내정 특기 초기화', term: 1 },
|
|
});
|
|
request.setup!.generalCooldowns = [
|
|
{
|
|
generalId: 1,
|
|
actionName: '내정 특기 초기화',
|
|
nextAvailableTurn: currentYearMonth + 1,
|
|
},
|
|
];
|
|
request.observe!.generalCooldowns = [{ generalId: 1, actionName: '내정 특기 초기화' }];
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
|
|
expect(readGeneralCooldown(reference.before, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1);
|
|
expect(readGeneralCooldown(core.before, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1);
|
|
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: 'che_내정특기초기화',
|
|
actionKey: '휴식',
|
|
usedFallback: true,
|
|
blockedReason: '1턴 더 기다려야 합니다',
|
|
});
|
|
expect(reference.after.logs.some((entry) => String(entry.text).includes('1턴 더 기다려야 합니다'))).toBe(true);
|
|
expect(core.after.logs.some((entry) => String(entry.text).includes('1턴 더 기다려야 합니다'))).toBe(true);
|
|
expect(readGeneralCooldown(reference.after, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1);
|
|
expect(readGeneralCooldown(core.after, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1);
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
}, 120_000);
|
|
|
|
it('allows war trait reset exactly at the cooldown boundary', async () => {
|
|
const currentYearMonth = 190 * 12 + 1 - 1;
|
|
const request = buildRequest('che_전투특기초기화', undefined, {
|
|
specialWar: 'che_귀병',
|
|
lastTurn: { command: '전투 특기 초기화', term: 1 },
|
|
});
|
|
request.setup!.generalCooldowns = [
|
|
{
|
|
generalId: 1,
|
|
actionName: '전투 특기 초기화',
|
|
nextAvailableTurn: currentYearMonth,
|
|
},
|
|
];
|
|
request.observe!.generalCooldowns = [{ generalId: 1, actionName: '전투 특기 초기화' }];
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
const expectedNextAvailableTurn = currentYearMonth + 60 - 1;
|
|
|
|
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: 'che_전투특기초기화',
|
|
actionKey: 'che_전투특기초기화',
|
|
usedFallback: false,
|
|
});
|
|
expect(readGeneralCooldown(reference.after, 1, '전투 특기 초기화')).toBe(expectedNextAvailableTurn);
|
|
expect(readGeneralCooldown(core.after, 1, '전투 특기 초기화')).toBe(expectedNextAvailableTurn);
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
}, 120_000);
|
|
});
|
|
|
|
type GeneralSpecialResetBoundaryCase = {
|
|
name: string;
|
|
action: 'che_내정특기초기화' | 'che_전투특기초기화';
|
|
actorPatch: Record<string, unknown>;
|
|
completed: boolean;
|
|
previousTypeKey: 'prev_types_special' | 'prev_types_special2';
|
|
expectedPreviousTypes?: string[];
|
|
expectedSpecAgeKey: 'specAge' | 'specAge2';
|
|
expectUniqueItem?: boolean;
|
|
};
|
|
|
|
const generalSpecialResetBoundaryCases: GeneralSpecialResetBoundaryCase[] = [
|
|
{
|
|
name: 'domestic reset rejects an actor without a domestic trait',
|
|
action: 'che_내정특기초기화',
|
|
actorPatch: { specialDomestic: 'None' },
|
|
completed: false,
|
|
previousTypeKey: 'prev_types_special',
|
|
expectedSpecAgeKey: 'specAge',
|
|
},
|
|
{
|
|
name: 'war reset rejects an actor without a war trait',
|
|
action: 'che_전투특기초기화',
|
|
actorPatch: { specialWar: 'None' },
|
|
completed: false,
|
|
previousTypeKey: 'prev_types_special2',
|
|
expectedSpecAgeKey: 'specAge2',
|
|
},
|
|
{
|
|
name: 'domestic reset restarts the previous-type cycle after every trait was seen',
|
|
action: 'che_내정특기초기화',
|
|
actorPatch: {
|
|
specialDomestic: 'che_인덕',
|
|
lastTurn: { command: '내정 특기 초기화', term: 1 },
|
|
meta: { prev_types_special: Array.from({ length: 7 }, (_, index) => `domestic-${index}`) },
|
|
},
|
|
completed: true,
|
|
previousTypeKey: 'prev_types_special',
|
|
expectedPreviousTypes: ['che_인덕'],
|
|
expectedSpecAgeKey: 'specAge',
|
|
},
|
|
{
|
|
name: 'war reset restarts the previous-type cycle after every trait was seen',
|
|
action: 'che_전투특기초기화',
|
|
actorPatch: {
|
|
specialWar: 'che_귀병',
|
|
lastTurn: { command: '전투 특기 초기화', term: 1 },
|
|
meta: { prev_types_special2: Array.from({ length: 19 }, (_, index) => `war-${index}`) },
|
|
},
|
|
completed: true,
|
|
previousTypeKey: 'prev_types_special2',
|
|
expectedPreviousTypes: ['che_귀병'],
|
|
expectedSpecAgeKey: 'specAge2',
|
|
},
|
|
{
|
|
name: 'domestic reset stores the old trait and runs the forced unique lottery',
|
|
action: 'che_내정특기초기화',
|
|
actorPatch: {
|
|
specialDomestic: 'che_인덕',
|
|
lastTurn: { command: '내정 특기 초기화', term: 1 },
|
|
meta: { inheritRandomUnique: true, prev_types_special: ['che_농업'] },
|
|
},
|
|
completed: true,
|
|
previousTypeKey: 'prev_types_special',
|
|
expectedPreviousTypes: ['che_농업', 'che_인덕'],
|
|
expectedSpecAgeKey: 'specAge',
|
|
expectUniqueItem: true,
|
|
},
|
|
{
|
|
name: 'war reset stores the old trait and runs the forced unique lottery',
|
|
action: 'che_전투특기초기화',
|
|
actorPatch: {
|
|
specialWar: 'che_귀병',
|
|
lastTurn: { command: '전투 특기 초기화', term: 1 },
|
|
meta: { inheritRandomUnique: true, prev_types_special2: ['che_신산'] },
|
|
},
|
|
completed: true,
|
|
previousTypeKey: 'prev_types_special2',
|
|
expectedPreviousTypes: ['che_신산', 'che_귀병'],
|
|
expectedSpecAgeKey: 'specAge2',
|
|
expectUniqueItem: true,
|
|
},
|
|
];
|
|
|
|
integration('general special reset constraint, state, unique lottery, and log parity', () => {
|
|
it.each(generalSpecialResetBoundaryCases)(
|
|
'$name',
|
|
async ({
|
|
name,
|
|
action,
|
|
actorPatch,
|
|
completed,
|
|
previousTypeKey,
|
|
expectedPreviousTypes,
|
|
expectedSpecAgeKey,
|
|
expectUniqueItem,
|
|
}) => {
|
|
const request = buildRequest(action, undefined, actorPatch, {
|
|
world: { initYear: 180, initMonth: 1 },
|
|
});
|
|
request.setup!.world!.hiddenSeed = `general-special-reset-${name}`;
|
|
request.observe!.includeGlobalHistoryLogs = true;
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
const referenceActor = reference.after.generals.find((entry) => entry.id === 1);
|
|
const coreActor = core.after.generals.find((entry) => entry.id === 1);
|
|
|
|
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([]);
|
|
expect(semanticLogSignatures(core.after.logs)).toEqual(
|
|
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
|
|
);
|
|
|
|
if (completed) {
|
|
expect(asRecord(referenceActor?.meta)[previousTypeKey]).toEqual(expectedPreviousTypes);
|
|
expect(asRecord(coreActor?.meta)[previousTypeKey]).toEqual(expectedPreviousTypes);
|
|
expect(referenceActor?.[expectedSpecAgeKey]).toBe(31);
|
|
expect(coreActor?.[expectedSpecAgeKey]).toBe(31);
|
|
}
|
|
if (expectUniqueItem) {
|
|
const referenceItems = [
|
|
referenceActor?.itemHorse,
|
|
referenceActor?.itemWeapon,
|
|
referenceActor?.itemBook,
|
|
referenceActor?.itemExtra,
|
|
];
|
|
const coreItems = [
|
|
coreActor?.itemHorse,
|
|
coreActor?.itemWeapon,
|
|
coreActor?.itemBook,
|
|
coreActor?.itemExtra,
|
|
];
|
|
expect(referenceItems.some((item) => item && item !== 'None')).toBe(true);
|
|
expect(coreItems).toEqual(referenceItems);
|
|
}
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
const missingTargetCases: Array<{ name: string; action: string; args: Record<string, unknown> }> = [
|
|
{
|
|
name: 'gift to a missing general',
|
|
action: 'che_증여',
|
|
args: { isGold: true, amount: 100, destGeneralID: 999 },
|
|
},
|
|
{
|
|
name: 'spy on a missing city',
|
|
action: 'che_첩보',
|
|
args: { destCityID: 999 },
|
|
},
|
|
{
|
|
name: 'move to a missing city',
|
|
action: 'che_이동',
|
|
args: { destCityID: 999 },
|
|
},
|
|
{
|
|
name: 'employ a missing general',
|
|
action: 'che_등용',
|
|
args: { destGeneralID: 999 },
|
|
},
|
|
];
|
|
|
|
integration('general command missing-target fallback matrix', () => {
|
|
it.each(missingTargetCases)(
|
|
'$name rejects the missing target and falls back without 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);
|
|
|
|
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: action,
|
|
actionKey: '휴식',
|
|
usedFallback: true,
|
|
});
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
integration('general gift missing argument object parity', () => {
|
|
it('falls back with the legacy invalid-argument log and no RNG', async () => {
|
|
const request = buildRequest('che_증여');
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
|
|
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: 'che_증여',
|
|
actionKey: '휴식',
|
|
usedFallback: true,
|
|
});
|
|
expect(reference.rng).toEqual([]);
|
|
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);
|
|
});
|
|
|
|
const resourceAmountCases: Array<{
|
|
name: string;
|
|
action: string;
|
|
args: Record<string, unknown>;
|
|
expectedAmount: number;
|
|
}> = [
|
|
{
|
|
name: 'gift rounds a half unit up',
|
|
action: 'che_증여',
|
|
args: { isGold: true, amount: 150, destGeneralID: 3 },
|
|
expectedAmount: 200,
|
|
},
|
|
{
|
|
name: 'gift clamps below the minimum',
|
|
action: 'che_증여',
|
|
args: { isGold: true, amount: 1, destGeneralID: 3 },
|
|
expectedAmount: 100,
|
|
},
|
|
{
|
|
name: 'gift clamps above the maximum',
|
|
action: 'che_증여',
|
|
args: { isGold: true, amount: 10_050, destGeneralID: 3 },
|
|
expectedAmount: 10_000,
|
|
},
|
|
{
|
|
name: 'donation rounds a half unit up',
|
|
action: 'che_헌납',
|
|
args: { isGold: true, amount: 150 },
|
|
expectedAmount: 200,
|
|
},
|
|
{
|
|
name: 'donation clamps below the minimum',
|
|
action: 'che_헌납',
|
|
args: { isGold: true, amount: 1 },
|
|
expectedAmount: 100,
|
|
},
|
|
{
|
|
name: 'donation clamps above the maximum',
|
|
action: 'che_헌납',
|
|
args: { isGold: true, amount: 10_050 },
|
|
expectedAmount: 10_000,
|
|
},
|
|
{
|
|
name: 'trade rounds a half unit up',
|
|
action: 'che_군량매매',
|
|
args: { buyRice: true, amount: 150 },
|
|
expectedAmount: 200,
|
|
},
|
|
{
|
|
name: 'trade clamps below the minimum',
|
|
action: 'che_군량매매',
|
|
args: { buyRice: true, amount: 1 },
|
|
expectedAmount: 100,
|
|
},
|
|
{
|
|
name: 'trade clamps above the maximum',
|
|
action: 'che_군량매매',
|
|
args: { buyRice: true, amount: 10_050 },
|
|
expectedAmount: 10_000,
|
|
},
|
|
];
|
|
|
|
integration('general command resource amount normalization matrix', () => {
|
|
it.each(resourceAmountCases)(
|
|
'$name matches legacy rounding and clamp semantics',
|
|
async ({ action, args, expectedAmount }) => {
|
|
const request = buildRequest(action, args);
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
const referenceActor = reference.after.generals.find((entry) => entry.id === 1);
|
|
const coreActor = core.after.generals.find((entry) => entry.id === 1);
|
|
const referenceLastTurn = referenceActor?.lastTurn as { arg?: Record<string, unknown> } | null | undefined;
|
|
const coreLastTurn = coreActor?.lastTurn as { arg?: Record<string, unknown> } | null | undefined;
|
|
|
|
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: action,
|
|
actionKey: action,
|
|
usedFallback: false,
|
|
});
|
|
expect(referenceLastTurn?.arg).toMatchObject({ amount: expectedAmount });
|
|
expect(coreLastTurn?.arg).toMatchObject({ amount: expectedAmount });
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
integration('general command donation resource boundaries', () => {
|
|
it('donates the available resource when the normalized request exceeds the current amount', async () => {
|
|
const request = buildRequest('che_헌납', { isGold: true, amount: 10_000 }, { gold: 5_000 });
|
|
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([]);
|
|
}, 120_000);
|
|
|
|
it('falls back when current rice is below the legacy minimum even for a small request', async () => {
|
|
const request = buildRequest('che_헌납', { isGold: false, amount: 100 }, { rice: 499 });
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
|
|
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: 'che_헌납',
|
|
actionKey: '휴식',
|
|
usedFallback: true,
|
|
});
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
}, 120_000);
|
|
});
|
|
|
|
integration('general command gift resource and target boundaries', () => {
|
|
it('keeps the legacy minimum rice reserve while gifting the available amount', async () => {
|
|
const request = buildRequest('che_증여', { isGold: false, amount: 10_000, destGeneralID: 3 }, { rice: 600 });
|
|
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([]);
|
|
}, 120_000);
|
|
|
|
it('rejects gifting to the actor and falls back without command RNG', async () => {
|
|
const request = buildRequest('che_증여', { isGold: true, amount: 100, destGeneralID: 1 });
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
|
|
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: 'che_증여',
|
|
actionKey: '휴식',
|
|
usedFallback: true,
|
|
});
|
|
expect(core.rng).toEqual(reference.rng);
|
|
expect(
|
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
|
})
|
|
).toEqual([]);
|
|
}, 120_000);
|
|
});
|
|
|
|
interface ResourceTransferBoundaryCase {
|
|
name: string;
|
|
action: 'che_증여' | 'che_헌납' | 'che_군량매매';
|
|
args: Record<string, unknown>;
|
|
actorPatch?: Record<string, unknown>;
|
|
fixturePatches?: FixturePatches;
|
|
completed: boolean;
|
|
}
|
|
|
|
const resourceTransferBoundaryCases: ResourceTransferBoundaryCase[] = [
|
|
{
|
|
name: 'gift completes with a zero transferable gold amount at the exact minimum',
|
|
action: 'che_증여',
|
|
args: { isGold: true, amount: 100, destGeneralID: 3 },
|
|
actorPatch: { gold: 0 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'gift rejects a general from another nation',
|
|
action: 'che_증여',
|
|
args: { isGold: true, amount: 100, destGeneralID: 2 },
|
|
completed: false,
|
|
},
|
|
{
|
|
name: 'donation at the exact rice minimum may spend below that minimum',
|
|
action: 'che_헌납',
|
|
args: { isGold: false, amount: 100 },
|
|
actorPatch: { rice: 500, crew: 0 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'donation completes with zero gold at the zero minimum',
|
|
action: 'che_헌납',
|
|
args: { isGold: true, amount: 100 },
|
|
actorPatch: { gold: 0 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'buying rice with one gold preserves the fee and integer persistence boundary',
|
|
action: 'che_군량매매',
|
|
args: { buyRice: true, amount: 100 },
|
|
actorPatch: { gold: 1, rice: 0 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'selling one rice preserves the fee and integer persistence boundary',
|
|
action: 'che_군량매매',
|
|
args: { buyRice: false, amount: 100 },
|
|
actorPatch: { gold: 0, rice: 1, crew: 0 },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'trade rate 137 preserves resource, tax, stat experience, and logs',
|
|
action: 'che_군량매매',
|
|
args: { buyRice: false, amount: 1_000 },
|
|
fixturePatches: { cities: { 3: { trade: 137 } } },
|
|
completed: true,
|
|
},
|
|
{
|
|
name: 'a neutral general can trade in a neutral trader city without nation tax',
|
|
action: 'che_군량매매',
|
|
args: { buyRice: false, amount: 1_000 },
|
|
actorPatch: { nationId: 0, officerLevel: 0 },
|
|
fixturePatches: { cities: { 3: { nationId: 0 } } },
|
|
completed: true,
|
|
},
|
|
];
|
|
|
|
integration('general resource transfer target, fee, state, and log parity', () => {
|
|
it.each(resourceTransferBoundaryCases)(
|
|
'$name',
|
|
async ({ action, args, actorPatch, fixturePatches, completed }) => {
|
|
const request = buildRequest(action, args, actorPatch, fixturePatches);
|
|
request.setup!.world!.hiddenSeed = `general-resource-transfer-${action}-${String(args.isGold ?? args.buyRice)}`;
|
|
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 });
|
|
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([]);
|
|
|
|
expect(semanticLogSignatures(core.after.logs)).toEqual(
|
|
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
|
|
);
|
|
},
|
|
120_000
|
|
);
|
|
});
|
|
|
|
type GeneralConstraintCase = {
|
|
name: string;
|
|
action: string;
|
|
args?: Record<string, unknown>;
|
|
actorPatch?: Record<string, unknown>;
|
|
fixturePatches?: FixturePatches;
|
|
};
|
|
|
|
const constraintCases: GeneralConstraintCase[] = [
|
|
{
|
|
name: 'neutral general',
|
|
action: 'che_훈련',
|
|
actorPatch: { nationId: 0, officerLevel: 0 },
|
|
},
|
|
{
|
|
name: 'wandering nation',
|
|
action: 'che_농지개간',
|
|
fixturePatches: {
|
|
nations: { 1: { level: 0, capitalCityId: 0, typeCode: 'None' } },
|
|
},
|
|
},
|
|
{
|
|
name: 'city not occupied by actor nation',
|
|
action: 'che_농지개간',
|
|
fixturePatches: { cities: { 3: { nationId: 2 } } },
|
|
},
|
|
{
|
|
name: 'unsupplied city',
|
|
action: 'che_농지개간',
|
|
fixturePatches: { cities: { 3: { supplyState: 0 } } },
|
|
},
|
|
{
|
|
name: 'insufficient gold',
|
|
action: 'che_상업투자',
|
|
actorPatch: { gold: 0 },
|
|
},
|
|
{
|
|
name: 'insufficient rice',
|
|
action: 'che_주민선정',
|
|
actorPatch: { rice: 0 },
|
|
},
|
|
{
|
|
name: 'maximum city trust',
|
|
action: 'che_주민선정',
|
|
fixturePatches: { cities: { 3: { trust: 100 } } },
|
|
},
|
|
{
|
|
name: 'sabotage targets the occupied city',
|
|
action: 'che_화계',
|
|
args: { destCityID: 3 },
|
|
},
|
|
{
|
|
name: 'sabotage targets a neutral city',
|
|
action: 'che_화계',
|
|
args: { destCityID: 70 },
|
|
fixturePatches: { cities: { 70: { nationId: 0 } } },
|
|
},
|
|
{
|
|
name: 'sabotage targets a non-aggression nation',
|
|
action: 'che_화계',
|
|
args: { destCityID: 70 },
|
|
fixturePatches: {
|
|
diplomacy: {
|
|
'1:2': { state: 7 },
|
|
'2:1': { state: 7 },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: 'insufficient sabotage gold',
|
|
action: 'che_화계',
|
|
args: { destCityID: 70 },
|
|
actorPatch: { gold: 0 },
|
|
},
|
|
{
|
|
name: 'insufficient sabotage rice',
|
|
action: 'che_화계',
|
|
args: { destCityID: 70 },
|
|
actorPatch: { rice: 0 },
|
|
},
|
|
];
|
|
|
|
const targetFailureLogCases: Array<{
|
|
action: 'che_이동' | 'che_강행' | 'che_출병' | 'che_첩보' | 'che_화계';
|
|
args: Record<string, unknown>;
|
|
fixturePatches?: FixturePatches;
|
|
}> = [
|
|
{ action: 'che_이동', args: { destCityID: 3 } },
|
|
{ action: 'che_강행', args: { destCityID: 3 } },
|
|
{
|
|
action: 'che_출병',
|
|
args: { destCityID: 3 },
|
|
fixturePatches: { world: { startYear: 180, year: 185 } },
|
|
},
|
|
{ action: 'che_첩보', args: { destCityID: 3 } },
|
|
{ action: 'che_화계', args: { destCityID: 3 } },
|
|
];
|
|
|
|
integration('general command target-specific constraint failure log parity', () => {
|
|
it.each(targetFailureLogCases)(
|
|
'$action preserves the legacy destination name and particle',
|
|
async ({ action, args, fixturePatches }) => {
|
|
const request = buildRequest(action, args, {}, fixturePatches);
|
|
request.setup!.world!.hiddenSeed = `general-target-failure-log-${action}`;
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
|
|
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: action,
|
|
actionKey: '휴식',
|
|
usedFallback: true,
|
|
});
|
|
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('general command full-constraint fallback matrix', () => {
|
|
it.each(constraintCases)(
|
|
'$name: $action falls back exactly like legacy',
|
|
async ({ action, args, actorPatch, fixturePatches }) => {
|
|
const request = buildRequest(action, args, actorPatch, fixturePatches);
|
|
request.setup!.world!.hiddenSeed = `general-constraint-${action}`;
|
|
const reference = runReferenceTurnCommandTraceRequest(
|
|
workspaceRoot!,
|
|
request as unknown as Record<string, unknown>
|
|
);
|
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
|
|
|
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
|
expect(core.execution.outcome).toMatchObject({
|
|
requestedAction: action,
|
|
actionKey: '휴식',
|
|
usedFallback: true,
|
|
});
|
|
expect(core.execution.outcome).toHaveProperty('blockedReason');
|
|
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
|
|
);
|
|
});
|