Merge general command differential coverage

This commit is contained in:
2026-07-26 01:05:44 +00:00
7 changed files with 218 additions and 40 deletions
@@ -118,10 +118,15 @@ const applyLegacyGeneralProgression = (
);
const dedicationLevel = Math.max(0, Math.min(maxDedicationLevel, Math.ceil(Math.sqrt(general.dedication) / 10)));
const meta = { ...general.meta };
if (general.experience !== previousGeneral.experience) {
// 하야는 ref에서 addExperience(0)/addDedication(0)을 호출해 현재 값으로
// 등급을 강제 재계산한다. 반대로 은퇴의 rebirth()와 선양의
// multiplyVar('experience')는 수치를 줄이면서도 기존 등급을 그대로 둔다.
const forceRefreshLevel = actionKey === 'che_하야';
const preserveLevel = actionKey === 'che_은퇴' || actionKey === 'che_선양';
if (!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience)) {
meta.explevel = expLevel;
}
if (general.dedication !== previousGeneral.dedication) {
if (!preserveLevel && (forceRefreshLevel || general.dedication !== previousGeneral.dedication)) {
meta.dedlevel = dedicationLevel;
}
@@ -1224,7 +1229,10 @@ export const createReservedTurnHandler = async (options: {
const hasNationChange = (resolution.patches?.cities ?? []).some((patch) =>
Object.prototype.hasOwnProperty.call(patch.patch ?? {}, 'nationId')
);
if (hasNationChange) {
// 레거시 건국 계열은 도시의 nation만 바꾸고 supply/front를
// 즉시 재계산하지 않는다. 다음 월 처리 전까지 그 상태를 보존한다.
const preservesFoundingFrontState = ['che_건국', 'cr_건국', 'che_무작위건국'].includes(actionKey);
if (hasNationChange && !preservesFoundingFrontState) {
const worldView = worldOverlay?.view ?? worldRef;
if (worldView && options.map) {
const frontPatches = buildFrontStatePatches({
@@ -56,10 +56,12 @@ normalized to the core `*Id` spelling before command identity comparison.
canonical projection.
- `trace.ts`: before/execute/after capture boundary.
- `compare.ts`: exact snapshot and delta comparison.
- `turnCommandGeneralMatrix.integration.test.ts`: 37 successful general
command paths, including the four-call `전투태세` completion path and
movement/return/gift/spy/sabotage, recruitment, proficiency transfer,
trait reset and equipment-trade commands.
- `turnCommandGeneralMatrix.integration.test.ts`: 54 successful general
command paths. Together with the live sortie fixture, this exercises a
completed success path for every one of the 55 general command keys.
The matrix includes the four-call `전투태세` path, lifecycle and
appointment commands, troop assembly, rebellion and all three founding
variants.
- `turnCommandNationMatrix.integration.test.ts`: 8 successful nation
command paths.
- `turnCommandCoreReference.integration.test.ts`: declaration and live
@@ -77,9 +79,11 @@ Two committed cases exercise the guarded runner end to end:
defeated-general neutralization and last-city nation collapse.
Each case may include a structured `setup` object for `world`, `nations`,
`cities`, `generals` and `diplomacy`. The runner accepts only explicitly mapped
fields; it does not accept SQL. Setup and command mutation happen only inside
the cloned `sammo_td_*` databases.
`cities`, `generals`, `troops` and `diplomacy`. The runner accepts only
explicitly mapped fields; it does not accept SQL. Founding fixtures may pin
`initYear`/`initMonth` and an ordered random-founding city candidate set so
both maps expose the same RNG domain. Setup and command mutation happen only
inside the cloned `sammo_td_*` databases.
## Case request
@@ -164,13 +168,17 @@ Compatibility is established per case only when:
4. live sortie also passes the battle trace comparison;
5. any ignored path is documented in the case evidence.
As of 2026-07-25, 37 general cases, 8 nation cases, declaration and live sortie
pass this boundary. Live sortie covers battle entry, conquest, defeated-general
neutralization and last-city nation collapse. This is 47 executable comparison
cases, not a claim that all 55 general and 38 nation command classes have been
dynamically compared.
As of 2026-07-25, 54 general matrix cases, 8 nation matrix cases, declaration
and live sortie pass this boundary. Live sortie is the remaining general
command key and covers battle entry, conquest, defeated-general neutralization
and last-city nation collapse. Thus all 55 general command keys have a
completed success-path comparison. This is 64 executable comparison cases;
it is not yet a claim that failure/boundary paths or all 38 nation commands
have been dynamically compared.
The fixture runner also reports whether the requested legacy command reached
its completed execution path. For multi-turn commands this is derived from the
pre-execution `LastTurn`, because commands such as `전투태세` reset their result
term to `1` on the completion call.
term to `1` on the completion call. `등용수락` and the founding commands have
explicit state-based completion checks because their successful legacy result
turn is not a reliable completion marker.
@@ -82,11 +82,31 @@ export class ActionResolver<
});
// 2. Recruiter Rewards
const recruiterExperience = destGeneral.experience + 100;
const recruiterDedication = destGeneral.dedication + 100;
const recruiterExpLevel = Math.max(
0,
Math.min(
this.env.maxStatLevel ?? 255,
recruiterExperience < 1_000
? Math.trunc(recruiterExperience / 100)
: Math.trunc(Math.sqrt(recruiterExperience / 10))
)
);
const recruiterDedicationLevel = Math.max(
0,
Math.min(this.env.maxDedicationLevel ?? 30, Math.ceil(Math.sqrt(recruiterDedication) / 10))
);
effects.push(
createGeneralPatchEffect(
{
experience: destGeneral.experience + 100,
dedication: destGeneral.dedication + 100,
experience: recruiterExperience,
dedication: recruiterDedication,
meta: {
...destGeneral.meta,
explevel: recruiterExpLevel,
dedlevel: recruiterDedicationLevel,
},
},
destGeneral.id
)
@@ -104,7 +104,7 @@ export class ActionResolver<
level: 0,
typeCode: 'None',
meta: { ...nation.meta, tech: 0 },
capitalCityId: null,
capitalCityId: 0,
},
nation.id
)
@@ -69,8 +69,8 @@ export class ActionDefinition<
const effects: Array<GeneralActionEffect<TriggerState>> = [];
const baseGold = this.env.baseGold > 0 ? this.env.baseGold : 1000;
const baseRice = this.env.baseRice > 0 ? this.env.baseRice : 1000;
const defaultGold = this.env.defaultNpcGold > 0 ? this.env.defaultNpcGold : 1000;
const defaultRice = this.env.defaultNpcRice > 0 ? this.env.defaultNpcRice : 1000;
const nationGenerals = context.nationGenerals ?? [];
for (const targetGeneral of nationGenerals) {
@@ -81,8 +81,11 @@ export class ActionDefinition<
nationId: 0,
officerLevel: 0,
troopId: 0,
gold: Math.min(targetGeneral.gold, baseGold),
rice: Math.min(targetGeneral.rice, baseRice),
gold: Math.min(targetGeneral.gold, defaultGold),
// 레거시는 전체 장수의 gold를 먼저 제한한 뒤 rice UPDATE의
// WHERE에도 gold를 사용한다. 따라서 다른 장수의 rice는
// 그대로 남고, 실행 장수만 아래 명시적 제한을 받는다.
rice: isActor ? Math.min(targetGeneral.rice, defaultRice) : targetGeneral.rice,
meta: {
...targetGeneral.meta,
belong: 0,
@@ -34,6 +34,8 @@ export interface TurnCommandFixtureRequest {
setup?: {
world?: {
startYear?: number;
initYear?: number;
initMonth?: number;
year?: number;
month?: number;
hiddenSeed?: string;
@@ -42,7 +44,9 @@ export interface TurnCommandFixtureRequest {
generals?: Array<Record<string, unknown>>;
nations?: Array<Record<string, unknown>>;
cities?: Array<Record<string, unknown>>;
troops?: Array<Record<string, unknown>>;
diplomacy?: Array<Record<string, unknown>>;
randomFoundingCandidateCityIds?: number[];
};
observe?: {
generalIds?: number[];
@@ -245,7 +249,7 @@ const buildNation = (row: Record<string, unknown>, generals: TurnGeneral[]): Nat
id,
name: readString(row, 'name', `국가${id}`),
color: readString(row, 'color', '#777777'),
capitalCityId: readNumber(row, 'capitalCityId') || null,
capitalCityId: readNumber(row, 'capitalCityId'),
chiefGeneralId: generals.find((general) => general.nationId === id && general.officerLevel === 12)?.id ?? null,
gold: readNumber(row, 'gold'),
rice: readNumber(row, 'rice'),
@@ -274,6 +278,9 @@ const buildWorldInput = (
const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime));
const nations = referenceBefore.nations.map((row) => buildNation(row, generals));
const observedCityRows = new Map(referenceBefore.cities.map((row) => [readNumber(row, 'id'), row] as const));
const randomFoundingCandidateCityIds = request.setup?.randomFoundingCandidateCityIds
? new Set(request.setup.randomFoundingCandidateCityIds)
: null;
const diplomacy: TurnDiplomacy[] = referenceBefore.diplomacy.map((row) => ({
fromNationId: readNumber(row, 'fromNationId'),
toNationId: readNumber(row, 'toNationId'),
@@ -323,7 +330,12 @@ const buildWorldInput = (
id: definition.id,
name: readString(row, 'name', definition.name),
nationId: readNumber(row, 'nationId'),
level: readNumber(row, 'level', definition.level),
level:
randomFoundingCandidateCityIds === null
? readNumber(row, 'level', definition.level)
: randomFoundingCandidateCityIds.has(definition.id)
? 5
: 4,
state: readNumber(row, 'state'),
population: readNumber(row, 'population', definition.initial.population),
populationMax: readNumber(row, 'populationMax', definition.max.population),
@@ -347,7 +359,15 @@ const buildWorldInput = (
};
}),
generals,
troops: [],
troops:
request.setup?.troops?.map((row) => {
const id = readNumber(row, 'id');
return {
id,
nationId: readNumber(row, 'nationId'),
name: readString(row, 'name', `부대${id}`),
};
}) ?? [],
diplomacy,
events: [],
initialEvents: [],
@@ -364,8 +384,12 @@ const buildWorldInput = (
killturn: 24,
isUnited: readNumber(referenceBefore.world, 'isUnited'),
scenarioId: readNumber(referenceBefore.world, 'scenarioId'),
initYear: readNumber(referenceBefore.world, 'initYear', request.setup?.world?.startYear ?? year),
initMonth: readNumber(referenceBefore.world, 'initMonth', 1),
initYear: readNumber(
referenceBefore.world,
'initYear',
request.setup?.world?.initYear ?? request.setup?.world?.startYear ?? year
),
initMonth: readNumber(referenceBefore.world, 'initMonth', request.setup?.world?.initMonth ?? 1),
},
},
snapshot,
@@ -64,10 +64,21 @@ const general = (id: number, nationId: number, cityId: number, officerLevel: num
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[];
}
const buildRequest = (
action: string,
args?: Record<string, unknown>,
actorPatch: Record<string, unknown> = {}
actorPatch: Record<string, unknown> = {},
fixturePatches: FixturePatches = {}
): TurnCommandFixtureRequest => ({
kind: 'general',
actorGeneralId: 1,
@@ -80,6 +91,7 @@ const buildRequest = (
year: 190,
month: 1,
hiddenSeed: 'turn-command-general-matrix-v1',
...fixturePatches.world,
},
nations: [
{
@@ -94,6 +106,7 @@ const buildRequest = (
war: 0,
generalCount: 2,
meta: {},
...fixturePatches.nations?.[1],
},
{
id: 2,
@@ -107,6 +120,7 @@ const buildRequest = (
war: 0,
generalCount: 1,
meta: {},
...fixturePatches.nations?.[2],
},
],
cities: [
@@ -126,6 +140,7 @@ const buildRequest = (
term: 0,
trust: 80,
trade: 100,
...fixturePatches.cities?.[3],
},
{
id: 70,
@@ -143,12 +158,35 @@ const buildRequest = (
term: 0,
trust: 80,
trade: 100,
...fixturePatches.cities?.[70],
},
],
generals: [{ ...general(1, 1, 3, 12), ...actorPatch }, general(2, 2, 70, 12), general(3, 1, 3, 1)],
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.troops ? { troops: fixturePatches.troops } : {}),
...(fixturePatches.randomFoundingCandidateCityIds
? { randomFoundingCandidateCityIds: fixturePatches.randomFoundingCandidateCityIds }
: {}),
diplomacy: [
{ fromNationId: 1, toNationId: 2, state: 0, term: 12, dead: 0 },
{ fromNationId: 2, toNationId: 1, state: 0, term: 12, dead: 0 },
{
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: {
@@ -160,7 +198,9 @@ const buildRequest = (
},
});
const cases: Array<[string, Record<string, unknown> | undefined, Record<string, unknown> | undefined]> = [
const cases: Array<
[string, Record<string, unknown> | undefined, Record<string, unknown> | undefined, FixturePatches?]
> = [
['휴식', undefined, undefined],
['che_훈련', undefined, undefined],
['cr_맹훈련', undefined, undefined],
@@ -200,19 +240,94 @@ const cases: Array<[string, Record<string, unknown> | undefined, Record<string,
undefined,
{ specialDomestic: 'che_인덕', lastTurn: { command: '내정 특기 초기화', term: 1 } },
],
[
'che_전투특기초기화',
undefined,
{ specialWar: '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) => {
const request = buildRequest(action, args, actorPatch);
async (action, args, actorPatch, fixturePatches) => {
const request = buildRequest(action, args, actorPatch, fixturePatches);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>