From a55c190e4ee119df50e94bf5f50d77f9cf569bf1 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 01:28:11 +0000 Subject: [PATCH] test: expand nation command differential matrix --- .../src/turn/reservedTurnHandler.ts | 13 +- .../actions/turn/nation/che_무작위수도이전.ts | 12 +- .../src/turn-differential/canonical.ts | 1 + .../src/turn-differential/coreCommandTrace.ts | 20 ++- ...urnCommandNationMatrix.integration.test.ts | 136 +++++++++++++++++- 5 files changed, 169 insertions(+), 13 deletions(-) diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 3c32e75..4fe2c0a 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -1229,10 +1229,15 @@ export const createReservedTurnHandler = async (options: { const hasNationChange = (resolution.patches?.cities ?? []).some((patch) => Object.prototype.hasOwnProperty.call(patch.patch ?? {}, 'nationId') ); - // 레거시 건국 계열은 도시의 nation만 바꾸고 supply/front를 - // 즉시 재계산하지 않는다. 다음 월 처리 전까지 그 상태를 보존한다. - const preservesFoundingFrontState = ['che_건국', 'cr_건국', 'che_무작위건국'].includes(actionKey); - if (hasNationChange && !preservesFoundingFrontState) { + // 레거시 건국 계열과 무작위 수도 이전은 명령이 직접 지정한 + // front 값만 바꾸고 전체 전선을 즉시 재계산하지 않는다. + const preservesImmediateFrontState = [ + 'che_건국', + 'cr_건국', + 'che_무작위건국', + 'che_무작위수도이전', + ].includes(actionKey); + if (hasNationChange && !preservesImmediateFrontState) { const worldView = worldOverlay?.view ?? worldRef; if (worldView && options.map) { const frontPatches = buildFrontStatePatches({ diff --git a/packages/logic/src/actions/turn/nation/che_무작위수도이전.ts b/packages/logic/src/actions/turn/nation/che_무작위수도이전.ts index f0743c5..1387d49 100644 --- a/packages/logic/src/actions/turn/nation/che_무작위수도이전.ts +++ b/packages/logic/src/actions/turn/nation/che_무작위수도이전.ts @@ -36,6 +36,15 @@ export interface RandomMoveCapitalResolveContext< const ACTION_NAME = '무작위 수도 이전'; +type InclusiveRandomGenerator = GeneralActionResolveContext['rng'] & { + nextIntInclusive?: (maxInclusive: number) => number; +}; + +const legacyChoiceIndex = (rng: GeneralActionResolveContext['rng'], length: number): number => { + const inclusive = rng as InclusiveRandomGenerator; + return inclusive.nextIntInclusive ? inclusive.nextIntInclusive(length - 1) : rng.nextInt(0, length); +}; + export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionDefinition< @@ -89,7 +98,8 @@ export class ActionDefinition< }; } - const destCity = neutralCandidateCities[rng.nextInt(0, neutralCandidateCities.length)]!; + // 레거시 RandUtil::choice()는 후보가 하나뿐이어도 nextInt(0)을 소비한다. + const destCity = neutralCandidateCities[legacyChoiceIndex(rng, neutralCandidateCities.length)]!; const oldCityId = nation.capitalCityId; const generalName = general.name; const nationName = nation.name; diff --git a/tools/integration-tests/src/turn-differential/canonical.ts b/tools/integration-tests/src/turn-differential/canonical.ts index 7f67094..1919c6f 100644 --- a/tools/integration-tests/src/turn-differential/canonical.ts +++ b/tools/integration-tests/src/turn-differential/canonical.ts @@ -188,6 +188,7 @@ export const projectCoreDatabaseSnapshot = (rows: { power: readNumber(meta, 'power'), war: readNumber(meta, 'war'), diplomacyLimit: readNumber(meta, 'surlimit'), + capitalRevision: readNumber(meta, 'capset'), meta, }; }); diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index fd0445d..ac2872c 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -245,6 +245,7 @@ const buildGeneral = (row: Record, turnTime: Date): TurnGeneral const buildNation = (row: Record, generals: TurnGeneral[]): Nation => { const id = readNumber(row, 'id'); const meta = asRecord(row.meta); + const turnLastByOfficerLevel = asRecord(row.turnLastByOfficerLevel); return { id, name: readString(row, 'name', `국가${id}`), @@ -258,10 +259,17 @@ const buildNation = (row: Record, generals: TurnGeneral[]): Nat typeCode: readString(row, 'typeCode', 'che_중립'), meta: { ...meta, + ...Object.fromEntries( + Object.entries(turnLastByOfficerLevel).map(([officerLevel, lastTurn]) => [ + `turn_last_${officerLevel}`, + lastTurn, + ]) + ), tech: readNumber(row, 'tech', readNumber(meta, 'tech')), gennum: readNumber(row, 'generalCount', readNumber(meta, 'gennum')), war: readNumber(row, 'war', readNumber(meta, 'war')), surlimit: readNumber(row, 'diplomacyLimit', readNumber(meta, 'surlimit')), + capset: readNumber(row, 'capitalRevision', readNumber(meta, 'capset')), }, }; }; @@ -276,7 +284,16 @@ const buildWorldInput = ( const month = readNumber(referenceBefore.world, 'month', request.setup?.world?.month ?? 1); const turnTime = new Date(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00.000Z`); const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime)); - const nations = referenceBefore.nations.map((row) => buildNation(row, generals)); + const fixtureNations = new Map((request.setup?.nations ?? []).map((row) => [readNumber(row, 'id'), row] as const)); + const nations = referenceBefore.nations.map((row) => + buildNation( + { + ...row, + turnLastByOfficerLevel: fixtureNations.get(readNumber(row, 'id'))?.turnLastByOfficerLevel, + }, + 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) @@ -511,6 +528,7 @@ const projectWorld = ( power: nation.power, war: readNumber(nation.meta, 'war'), diplomacyLimit: readNumber(nation.meta, 'surlimit'), + capitalRevision: readNumber(nation.meta, 'capset'), meta: nation.meta, })), diplomacy: world diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index 9a8a988..964710d 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -59,7 +59,21 @@ const general = (id: number, nationId: number, cityId: number, officerLevel: num meta: {}, }); -const buildRequest = (action: string, args?: Record): TurnCommandFixtureRequest => ({ +interface FixturePatches { + world?: Partial['world']>>; + generals?: Record>; + nations?: Record>; + cities?: Record>; + troops?: Array>; + diplomacy?: Record>; + randomFoundingCandidateCityIds?: number[]; +} + +const buildRequest = ( + action: string, + args?: Record, + fixturePatches: FixturePatches = {} +): TurnCommandFixtureRequest => ({ kind: 'nation', actorGeneralId: 1, action, @@ -71,6 +85,7 @@ const buildRequest = (action: string, args?: Record): TurnComma year: 190, month: 1, hiddenSeed: 'turn-command-nation-matrix-v1', + ...fixturePatches.world, }, nations: [ { @@ -86,6 +101,7 @@ const buildRequest = (action: string, args?: Record): TurnComma diplomacyLimit: 0, generalCount: 2, meta: { can_국호변경: 1, can_국기변경: 1, surlimit: 0 }, + ...fixturePatches.nations?.[1], }, { id: 2, @@ -100,6 +116,7 @@ const buildRequest = (action: string, args?: Record): TurnComma diplomacyLimit: 0, generalCount: 1, meta: { surlimit: 0 }, + ...fixturePatches.nations?.[2], }, ], cities: [ @@ -118,6 +135,7 @@ const buildRequest = (action: string, args?: Record): TurnComma term: 0, trust: 80, trade: 100, + ...fixturePatches.cities?.[3], }, { id: 70, @@ -134,12 +152,35 @@ const buildRequest = (action: string, args?: Record): TurnComma term: 0, trust: 80, trade: 100, + ...fixturePatches.cities?.[70], }, ], - generals: [general(1, 1, 3, 12), general(2, 2, 70, 12), general(3, 1, 3, 1)], + generals: [ + { ...general(1, 1, 3, 12), ...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: 3, term: 0, dead: 0 }, - { fromNationId: 2, toNationId: 1, state: 3, term: 0, dead: 0 }, + { + fromNationId: 1, + toNationId: 2, + state: 3, + term: 0, + dead: 0, + ...fixturePatches.diplomacy?.['1:2'], + }, + { + fromNationId: 2, + toNationId: 1, + state: 3, + term: 0, + dead: 0, + ...fixturePatches.diplomacy?.['2:1'], + }, ], }, observe: { @@ -151,7 +192,7 @@ const buildRequest = (action: string, args?: Record): TurnComma }, }); -const cases: Array<[string, Record | undefined]> = [ +const cases: Array<[string, Record | undefined, FixturePatches?]> = [ ['휴식', undefined], ['che_포상', { isGold: true, amount: 100, destGeneralID: 3 }], ['che_선전포고', { destNationID: 2 }], @@ -160,13 +201,94 @@ const cases: Array<[string, Record | undefined]> = [ ['che_몰수', { isGold: true, amount: 100, destGeneralID: 3 }], ['che_물자원조', { destNationID: 2, amountList: [100, 200] }], ['che_불가침제의', { destNationID: 2, year: 191, month: 1 }], + [ + 'che_부대탈퇴지시', + { destGeneralID: 3 }, + { + generals: { 1: { troopId: 1 }, 3: { troopId: 1 } }, + troops: [{ id: 1, nationId: 1, name: '조조군' }], + }, + ], + ['che_발령', { destGeneralID: 3, destCityID: 70 }, { cities: { 70: { nationId: 1 } } }], + ['che_종전제의', { destNationID: 2 }, { diplomacy: { '1:2': { state: 0 }, '2:1': { state: 0 } } }], + ['che_불가침파기제의', { destNationID: 2 }, { diplomacy: { '1:2': { state: 7 }, '2:1': { state: 7 } } }], + ['cr_인구이동', { destCityID: 70, amount: 1000 }, { cities: { 70: { nationId: 1, supplyState: 1 } } }], + [ + 'che_천도', + { destCityID: 70 }, + { + nations: { + 1: { + capitalRevision: 0, + turnLastByOfficerLevel: { + 12: { + command: '천도', + arg: { destCityID: 70 }, + term: 2, + seq: 0, + }, + }, + }, + }, + cities: { 70: { nationId: 1, supplyState: 1 } }, + }, + ], + [ + 'che_증축', + undefined, + { + nations: { + 1: { + capitalRevision: 0, + turnLastByOfficerLevel: { + 12: { command: '증축', arg: {}, term: 5, seq: 0 }, + }, + }, + }, + cities: { 3: { level: 7 } }, + }, + ], + [ + 'che_감축', + undefined, + { + nations: { + 1: { + capitalRevision: 0, + turnLastByOfficerLevel: { + 12: { command: '감축', arg: {}, term: 5, seq: 0 }, + }, + }, + }, + cities: { 3: { level: 9 } }, + }, + ], + [ + 'che_무작위수도이전', + undefined, + { + world: { year: 181 }, + nations: { + 1: { + meta: { + can_무작위수도이전: 1, + }, + turnLastByOfficerLevel: { + 12: { command: '무작위 수도 이전', arg: {}, term: 1 }, + }, + }, + }, + cities: { 70: { nationId: 0, supplyState: 0 } }, + randomFoundingCandidateCityIds: [70], + }, + ], ]; integration('nation command success matrix', () => { it.each(cases)( '%s matches the legacy state delta and command RNG', - async (action, args) => { - const request = buildRequest(action, args); + async (action, args, fixturePatches) => { + const request = buildRequest(action, args, fixturePatches); const reference = runReferenceTurnCommandTraceRequest( workspaceRoot!, request as unknown as Record