diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 00dd074..3c32e75 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -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({ diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index 4aff3ad..d639f25 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -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. diff --git a/packages/logic/src/actions/turn/general/che_등용수락.ts b/packages/logic/src/actions/turn/general/che_등용수락.ts index 8c739ef..24b2b3b 100644 --- a/packages/logic/src/actions/turn/general/che_등용수락.ts +++ b/packages/logic/src/actions/turn/general/che_등용수락.ts @@ -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 ) diff --git a/packages/logic/src/actions/turn/general/che_방랑.ts b/packages/logic/src/actions/turn/general/che_방랑.ts index 521acf7..85f9b2e 100644 --- a/packages/logic/src/actions/turn/general/che_방랑.ts +++ b/packages/logic/src/actions/turn/general/che_방랑.ts @@ -104,7 +104,7 @@ export class ActionResolver< level: 0, typeCode: 'None', meta: { ...nation.meta, tech: 0 }, - capitalCityId: null, + capitalCityId: 0, }, nation.id ) diff --git a/packages/logic/src/actions/turn/general/che_해산.ts b/packages/logic/src/actions/turn/general/che_해산.ts index 0509a34..fe80b0e 100644 --- a/packages/logic/src/actions/turn/general/che_해산.ts +++ b/packages/logic/src/actions/turn/general/che_해산.ts @@ -69,8 +69,8 @@ export class ActionDefinition< const effects: Array> = []; - 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, diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index fd0bbf5..fd0445d 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -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>; nations?: Array>; cities?: Array>; + troops?: Array>; diplomacy?: Array>; + randomFoundingCandidateCityIds?: number[]; }; observe?: { generalIds?: number[]; @@ -245,7 +249,7 @@ const buildNation = (row: Record, 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, diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 718072b..a19caaf 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -64,10 +64,21 @@ const general = (id: number, nationId: number, cityId: number, officerLevel: num meta: {}, }); +interface FixturePatches { + world?: NonNullable['world']>; + generals?: Record>; + nations?: Record>; + cities?: Record>; + troops?: Array>; + diplomacy?: Record>; + randomFoundingCandidateCityIds?: number[]; +} + const buildRequest = ( action: string, args?: Record, - actorPatch: Record = {} + actorPatch: Record = {}, + 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 | undefined, Record | undefined]> = [ +const cases: Array< + [string, Record | undefined, Record | undefined, FixturePatches?] +> = [ ['휴식', undefined, undefined], ['che_훈련', undefined, undefined], ['cr_맹훈련', undefined, undefined], @@ -200,19 +240,94 @@ const cases: Array<[string, Record | undefined, Record { 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