Merge general command differential coverage
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user