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 dedicationLevel = Math.max(0, Math.min(maxDedicationLevel, Math.ceil(Math.sqrt(general.dedication) / 10)));
const meta = { ...general.meta }; 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; meta.explevel = expLevel;
} }
if (general.dedication !== previousGeneral.dedication) { if (!preserveLevel && (forceRefreshLevel || general.dedication !== previousGeneral.dedication)) {
meta.dedlevel = dedicationLevel; meta.dedlevel = dedicationLevel;
} }
@@ -1224,7 +1229,10 @@ export const createReservedTurnHandler = async (options: {
const hasNationChange = (resolution.patches?.cities ?? []).some((patch) => const hasNationChange = (resolution.patches?.cities ?? []).some((patch) =>
Object.prototype.hasOwnProperty.call(patch.patch ?? {}, 'nationId') 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; const worldView = worldOverlay?.view ?? worldRef;
if (worldView && options.map) { if (worldView && options.map) {
const frontPatches = buildFrontStatePatches({ const frontPatches = buildFrontStatePatches({
@@ -56,10 +56,12 @@ normalized to the core `*Id` spelling before command identity comparison.
canonical projection. canonical projection.
- `trace.ts`: before/execute/after capture boundary. - `trace.ts`: before/execute/after capture boundary.
- `compare.ts`: exact snapshot and delta comparison. - `compare.ts`: exact snapshot and delta comparison.
- `turnCommandGeneralMatrix.integration.test.ts`: 37 successful general - `turnCommandGeneralMatrix.integration.test.ts`: 54 successful general
command paths, including the four-call `전투태세` completion path and command paths. Together with the live sortie fixture, this exercises a
movement/return/gift/spy/sabotage, recruitment, proficiency transfer, completed success path for every one of the 55 general command keys.
trait reset and equipment-trade commands. 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 - `turnCommandNationMatrix.integration.test.ts`: 8 successful nation
command paths. command paths.
- `turnCommandCoreReference.integration.test.ts`: declaration and live - `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. defeated-general neutralization and last-city nation collapse.
Each case may include a structured `setup` object for `world`, `nations`, Each case may include a structured `setup` object for `world`, `nations`,
`cities`, `generals` and `diplomacy`. The runner accepts only explicitly mapped `cities`, `generals`, `troops` and `diplomacy`. The runner accepts only
fields; it does not accept SQL. Setup and command mutation happen only inside explicitly mapped fields; it does not accept SQL. Founding fixtures may pin
the cloned `sammo_td_*` databases. `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 ## Case request
@@ -164,13 +168,17 @@ Compatibility is established per case only when:
4. live sortie also passes the battle trace comparison; 4. live sortie also passes the battle trace comparison;
5. any ignored path is documented in the case evidence. 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 As of 2026-07-25, 54 general matrix cases, 8 nation matrix cases, declaration
pass this boundary. Live sortie covers battle entry, conquest, defeated-general and live sortie pass this boundary. Live sortie is the remaining general
neutralization and last-city nation collapse. This is 47 executable comparison command key and covers battle entry, conquest, defeated-general neutralization
cases, not a claim that all 55 general and 38 nation command classes have been and last-city nation collapse. Thus all 55 general command keys have a
dynamically compared. 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 The fixture runner also reports whether the requested legacy command reached
its completed execution path. For multi-turn commands this is derived from the its completed execution path. For multi-turn commands this is derived from the
pre-execution `LastTurn`, because commands such as `전투태세` reset their result 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 // 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( effects.push(
createGeneralPatchEffect( createGeneralPatchEffect(
{ {
experience: destGeneral.experience + 100, experience: recruiterExperience,
dedication: destGeneral.dedication + 100, dedication: recruiterDedication,
meta: {
...destGeneral.meta,
explevel: recruiterExpLevel,
dedlevel: recruiterDedicationLevel,
},
}, },
destGeneral.id destGeneral.id
) )
@@ -104,7 +104,7 @@ export class ActionResolver<
level: 0, level: 0,
typeCode: 'None', typeCode: 'None',
meta: { ...nation.meta, tech: 0 }, meta: { ...nation.meta, tech: 0 },
capitalCityId: null, capitalCityId: 0,
}, },
nation.id nation.id
) )
@@ -69,8 +69,8 @@ export class ActionDefinition<
const effects: Array<GeneralActionEffect<TriggerState>> = []; const effects: Array<GeneralActionEffect<TriggerState>> = [];
const baseGold = this.env.baseGold > 0 ? this.env.baseGold : 1000; const defaultGold = this.env.defaultNpcGold > 0 ? this.env.defaultNpcGold : 1000;
const baseRice = this.env.baseRice > 0 ? this.env.baseRice : 1000; const defaultRice = this.env.defaultNpcRice > 0 ? this.env.defaultNpcRice : 1000;
const nationGenerals = context.nationGenerals ?? []; const nationGenerals = context.nationGenerals ?? [];
for (const targetGeneral of nationGenerals) { for (const targetGeneral of nationGenerals) {
@@ -81,8 +81,11 @@ export class ActionDefinition<
nationId: 0, nationId: 0,
officerLevel: 0, officerLevel: 0,
troopId: 0, troopId: 0,
gold: Math.min(targetGeneral.gold, baseGold), gold: Math.min(targetGeneral.gold, defaultGold),
rice: Math.min(targetGeneral.rice, baseRice), // 레거시는 전체 장수의 gold를 먼저 제한한 뒤 rice UPDATE의
// WHERE에도 gold를 사용한다. 따라서 다른 장수의 rice는
// 그대로 남고, 실행 장수만 아래 명시적 제한을 받는다.
rice: isActor ? Math.min(targetGeneral.rice, defaultRice) : targetGeneral.rice,
meta: { meta: {
...targetGeneral.meta, ...targetGeneral.meta,
belong: 0, belong: 0,
@@ -34,6 +34,8 @@ export interface TurnCommandFixtureRequest {
setup?: { setup?: {
world?: { world?: {
startYear?: number; startYear?: number;
initYear?: number;
initMonth?: number;
year?: number; year?: number;
month?: number; month?: number;
hiddenSeed?: string; hiddenSeed?: string;
@@ -42,7 +44,9 @@ export interface TurnCommandFixtureRequest {
generals?: Array<Record<string, unknown>>; generals?: Array<Record<string, unknown>>;
nations?: Array<Record<string, unknown>>; nations?: Array<Record<string, unknown>>;
cities?: Array<Record<string, unknown>>; cities?: Array<Record<string, unknown>>;
troops?: Array<Record<string, unknown>>;
diplomacy?: Array<Record<string, unknown>>; diplomacy?: Array<Record<string, unknown>>;
randomFoundingCandidateCityIds?: number[];
}; };
observe?: { observe?: {
generalIds?: number[]; generalIds?: number[];
@@ -245,7 +249,7 @@ const buildNation = (row: Record<string, unknown>, generals: TurnGeneral[]): Nat
id, id,
name: readString(row, 'name', `국가${id}`), name: readString(row, 'name', `국가${id}`),
color: readString(row, 'color', '#777777'), 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, chiefGeneralId: generals.find((general) => general.nationId === id && general.officerLevel === 12)?.id ?? null,
gold: readNumber(row, 'gold'), gold: readNumber(row, 'gold'),
rice: readNumber(row, 'rice'), rice: readNumber(row, 'rice'),
@@ -274,6 +278,9 @@ const buildWorldInput = (
const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime)); const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime));
const nations = referenceBefore.nations.map((row) => buildNation(row, generals)); const nations = referenceBefore.nations.map((row) => buildNation(row, generals));
const observedCityRows = new Map(referenceBefore.cities.map((row) => [readNumber(row, 'id'), row] as const)); 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) => ({ const diplomacy: TurnDiplomacy[] = referenceBefore.diplomacy.map((row) => ({
fromNationId: readNumber(row, 'fromNationId'), fromNationId: readNumber(row, 'fromNationId'),
toNationId: readNumber(row, 'toNationId'), toNationId: readNumber(row, 'toNationId'),
@@ -323,7 +330,12 @@ const buildWorldInput = (
id: definition.id, id: definition.id,
name: readString(row, 'name', definition.name), name: readString(row, 'name', definition.name),
nationId: readNumber(row, 'nationId'), 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'), state: readNumber(row, 'state'),
population: readNumber(row, 'population', definition.initial.population), population: readNumber(row, 'population', definition.initial.population),
populationMax: readNumber(row, 'populationMax', definition.max.population), populationMax: readNumber(row, 'populationMax', definition.max.population),
@@ -347,7 +359,15 @@ const buildWorldInput = (
}; };
}), }),
generals, 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, diplomacy,
events: [], events: [],
initialEvents: [], initialEvents: [],
@@ -364,8 +384,12 @@ const buildWorldInput = (
killturn: 24, killturn: 24,
isUnited: readNumber(referenceBefore.world, 'isUnited'), isUnited: readNumber(referenceBefore.world, 'isUnited'),
scenarioId: readNumber(referenceBefore.world, 'scenarioId'), scenarioId: readNumber(referenceBefore.world, 'scenarioId'),
initYear: readNumber(referenceBefore.world, 'initYear', request.setup?.world?.startYear ?? year), initYear: readNumber(
initMonth: readNumber(referenceBefore.world, 'initMonth', 1), referenceBefore.world,
'initYear',
request.setup?.world?.initYear ?? request.setup?.world?.startYear ?? year
),
initMonth: readNumber(referenceBefore.world, 'initMonth', request.setup?.world?.initMonth ?? 1),
}, },
}, },
snapshot, snapshot,
@@ -64,10 +64,21 @@ const general = (id: number, nationId: number, cityId: number, officerLevel: num
meta: {}, 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 = ( const buildRequest = (
action: string, action: string,
args?: Record<string, unknown>, args?: Record<string, unknown>,
actorPatch: Record<string, unknown> = {} actorPatch: Record<string, unknown> = {},
fixturePatches: FixturePatches = {}
): TurnCommandFixtureRequest => ({ ): TurnCommandFixtureRequest => ({
kind: 'general', kind: 'general',
actorGeneralId: 1, actorGeneralId: 1,
@@ -80,6 +91,7 @@ const buildRequest = (
year: 190, year: 190,
month: 1, month: 1,
hiddenSeed: 'turn-command-general-matrix-v1', hiddenSeed: 'turn-command-general-matrix-v1',
...fixturePatches.world,
}, },
nations: [ nations: [
{ {
@@ -94,6 +106,7 @@ const buildRequest = (
war: 0, war: 0,
generalCount: 2, generalCount: 2,
meta: {}, meta: {},
...fixturePatches.nations?.[1],
}, },
{ {
id: 2, id: 2,
@@ -107,6 +120,7 @@ const buildRequest = (
war: 0, war: 0,
generalCount: 1, generalCount: 1,
meta: {}, meta: {},
...fixturePatches.nations?.[2],
}, },
], ],
cities: [ cities: [
@@ -126,6 +140,7 @@ const buildRequest = (
term: 0, term: 0,
trust: 80, trust: 80,
trade: 100, trade: 100,
...fixturePatches.cities?.[3],
}, },
{ {
id: 70, id: 70,
@@ -143,12 +158,35 @@ const buildRequest = (
term: 0, term: 0,
trust: 80, trust: 80,
trade: 100, 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: [ 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: { 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], ['휴식', undefined, undefined],
['che_훈련', undefined, undefined], ['che_훈련', undefined, undefined],
['cr_맹훈련', undefined, undefined], ['cr_맹훈련', undefined, undefined],
@@ -200,19 +240,94 @@ const cases: Array<[string, Record<string, unknown> | undefined, Record<string,
undefined, undefined,
{ specialDomestic: 'che_인덕', lastTurn: { command: '내정 특기 초기화', term: 1 } }, { 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_장비매매', { 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', () => { integration('general command success matrix', () => {
it.each(cases)( it.each(cases)(
'%s matches the legacy state delta and command RNG', '%s matches the legacy state delta and command RNG',
async (action, args, actorPatch) => { async (action, args, actorPatch, fixturePatches) => {
const request = buildRequest(action, args, actorPatch); const request = buildRequest(action, args, actorPatch, fixturePatches);
const reference = runReferenceTurnCommandTraceRequest( const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!, workspaceRoot!,
request as unknown as Record<string, unknown> request as unknown as Record<string, unknown>