From 80a2c9502f633d66c3da05eca9a27fe6ca3a9912 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 17:12:54 +0000 Subject: [PATCH] fix general status transition parity --- .../src/turn/reservedTurnHandler.ts | 10 +- .../generalTurnLegacyCompatibility.test.ts | 38 ++++- packages/logic/src/actions/engine.ts | 3 + .../src/actions/turn/general/che_선양.ts | 24 ++- .../src/actions/turn/general/che_은퇴.ts | 8 +- .../src/actions/turn/general/che_하야.ts | 11 +- .../general/migratedGeneralCommands.test.ts | 1 + .../src/turn-differential/canonical.ts | 3 + .../src/turn-differential/coreCommandTrace.ts | 6 + ...rnCommandGeneralMatrix.integration.test.ts | 151 ++++++++++++++++++ 10 files changed, 238 insertions(+), 17 deletions(-) diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index b3ab6f3..fdd7a86 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -847,6 +847,7 @@ export const createReservedTurnHandler = async (options: { }> = []; const createdGenerals: TurnGeneral[] = []; const createdNations: Nation[] = []; + const commandDeletedTroopIds = new Set(); let currentGeneral = context.general; let currentCity = context.city; @@ -1092,6 +1093,9 @@ export const createReservedTurnHandler = async (options: { }, actionArgs ); + for (const troopId of resolution.deletedTroopIds ?? []) { + commandDeletedTroopIds.add(troopId); + } currentGeneral = resolution.general as TurnGeneral; currentCity = resolution.city ?? currentCity; @@ -1599,7 +1603,7 @@ export const createReservedTurnHandler = async (options: { let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = 'active'; let deleteGeneral = false; - const deletedTroopIds: number[] = []; + const deletedTroopIds = Array.from(commandDeletedTroopIds); const lifecycleSnapshot = cloneTurnGeneral(currentGeneral); if (currentGeneral.meta.killturn <= 0 && typeof currentGeneral.deadYear === 'number') { if ( @@ -1752,10 +1756,10 @@ export const createReservedTurnHandler = async (options: { ...(createdNations.length > 0 ? { nations: createdNations } : {}), } : undefined, - ...(deleteGeneral + ...(deleteGeneral || deletedTroopIds.length > 0 ? { deleted: { - general: true, + general: deleteGeneral, ...(deletedTroopIds.length > 0 ? { troopIds: deletedTroopIds } : {}), }, } diff --git a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts index 7b83750..aabbca3 100644 --- a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts +++ b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts @@ -68,7 +68,10 @@ const makeGeneral = (patch: Partial = {}): TurnGeneral => ({ ...patch, }); -const makeSnapshot = (general: TurnGeneral): TurnWorldSnapshot => ({ +const makeSnapshot = ( + general: TurnGeneral, + extras: { generals?: TurnGeneral[]; troops?: TurnWorldSnapshot['troops'] } = {} +): TurnWorldSnapshot => ({ scenarioConfig: { stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, iconPath: '', @@ -132,8 +135,8 @@ const makeSnapshot = (general: TurnGeneral): TurnWorldSnapshot => ({ meta: { trust: 50, trade: 100, region: 1 }, }, ], - generals: [general], - troops: [], + generals: [general, ...(extras.generals ?? [])], + troops: extras.troops ?? [], diplomacy: [], events: [], initialEvents: [], @@ -248,4 +251,33 @@ describe('legacy general-turn execution contract', () => { expect(harness.reservedTurnStore.getGeneralTurn(1, 0).action).toBe('휴식'); expect(harness.getCollectedLogs().some((log) => log.text.includes('악성유저'))).toBe(true); }); + + it('deletes the troop row and releases members when a troop leader resigns', async () => { + const leader = makeGeneral({ troopId: 1 }); + const member = makeGeneral({ + id: 2, + name: '부대원', + troopId: 1, + turnTime: new Date(start.getTime() + 10 * 60_000), + }); + const snapshot = makeSnapshot(leader, { + generals: [member], + troops: [{ id: 1, nationId: 1, name: '테스트군' }], + }); + snapshot.nations[0]!.meta.gennum = 2; + const harness = await createTurnTestHarness({ + snapshot, + state: makeState(), + schedule, + map, + }); + harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_하야', args: {} }; + + await harness.runOneTick({ maxGenerals: 1 }); + + expect(harness.world.getGeneralById(1)?.troopId).toBe(0); + expect(harness.world.getGeneralById(2)?.troopId).toBe(0); + expect(harness.world.getTroopById(1)).toBeNull(); + expect(harness.world.consumeDirtyState().deletedTroops).toEqual([1]); + }); }); diff --git a/packages/logic/src/actions/engine.ts b/packages/logic/src/actions/engine.ts index 6d839c5..67070ae 100644 --- a/packages/logic/src/actions/engine.ts +++ b/packages/logic/src/actions/engine.ts @@ -106,6 +106,7 @@ export type GeneralActionEffect { effects: GeneralActionEffect[]; completed?: boolean; + deletedTroopIds?: number[]; alternative?: { commandKey: string; args: unknown; @@ -146,6 +147,7 @@ export interface GeneralActionResolution { commandKey: string; args: unknown; }; + deletedTroopIds?: number[]; } export const createGeneralPatchEffect = ( @@ -388,6 +390,7 @@ export const resolveGeneralAction = [], + test: () => ({ kind: 'deny', reason: '본인입니다' }), + }, + ]; + } return [beLord(), existsDestGeneral(), friendlyDestGeneral()]; } @@ -66,7 +79,8 @@ export class ActionDefinition< throw new Error('선양 대상 장수가 없습니다.'); } - const penaltyRaw = destGeneral.meta.penalty; + const penaltyRaw = + (destGeneral as General & { penalty?: unknown }).penalty ?? destGeneral.meta.penalty; if (penaltyRaw && typeof penaltyRaw === 'object' && !Array.isArray(penaltyRaw)) { const penaltyMap = penaltyRaw as Record; for (const penaltyKey of blockedPenaltyKeys) { @@ -79,6 +93,7 @@ export class ActionDefinition< format: LogFormat.MONTH, }), ], + completed: false, }; } } @@ -135,7 +150,7 @@ export class ActionDefinition< createGeneralPatchEffect( { officerLevel: 1, - experience: Math.floor(general.experience * 0.7), + experience: Math.round(general.experience * 0.7), meta: { ...general.meta, officer_city: 0, @@ -143,7 +158,8 @@ export class ActionDefinition< }, }, general.id - ) + ), + createNationPatchEffect({ chiefGeneralId: destGeneral.id }, nation.id) ); return { effects }; diff --git a/packages/logic/src/actions/turn/general/che_은퇴.ts b/packages/logic/src/actions/turn/general/che_은퇴.ts index 557e217..1b3f788 100644 --- a/packages/logic/src/actions/turn/general/che_은퇴.ts +++ b/packages/logic/src/actions/turn/general/che_은퇴.ts @@ -48,8 +48,10 @@ export class ActionResolver< const value = typeof nextMeta[key] === 'number' ? nextMeta[key] : 0; nextMeta[key] = Math.round(value * 0.5); } - nextMeta.specAge = 0; - nextMeta.specAge2 = 0; + delete nextMeta.specAge; + delete nextMeta.specAge2; + nextMeta.specage = 0; + nextMeta.specage2 = 0; for (const type of LEGACY_RANK_DATA_TYPES) { nextMeta[rankDataMetaKey(type)] = 0; } @@ -57,7 +59,7 @@ export class ActionResolver< const josaYi = JosaUtil.pick(general.name, '이'); context.addLog(`${general.name}${josaYi} 은퇴하고 그 자손이 유지를 이어받았습니다.`, { scope: LogScope.SYSTEM, - category: LogCategory.ACTION, + category: LogCategory.SUMMARY, format: LogFormat.RAWTEXT, }); context.addLog('나이가 들어 은퇴하고 자손에게 자리를 물려줍니다.', { diff --git a/packages/logic/src/actions/turn/general/che_하야.ts b/packages/logic/src/actions/turn/general/che_하야.ts index d603f69..a5e245f 100644 --- a/packages/logic/src/actions/turn/general/che_하야.ts +++ b/packages/logic/src/actions/turn/general/che_하야.ts @@ -66,8 +66,8 @@ export class ActionResolver< // Penalty const betrayal = typeof general.meta.betray === 'number' ? general.meta.betray : 0; const penaltyRatio = betrayal * 0.1; - const nextExp = Math.floor(general.experience * (1 - penaltyRatio)); - const nextDed = Math.floor(general.dedication * (1 - penaltyRatio)); + const nextExp = Math.round(general.experience * (1 - penaltyRatio)); + const nextDed = Math.round(general.dedication * (1 - penaltyRatio)); context.addLog(`${nation.name}에서 하야했습니다.`, { category: LogCategory.ACTION, @@ -80,7 +80,7 @@ export class ActionResolver< const josaYi = JosaUtil.pick(general.name, '이'); context.addLog(`${general.name}${josaYi} ${nation.name}에서 하야했습니다.`, { scope: LogScope.SYSTEM, - category: LogCategory.ACTION, + category: LogCategory.SUMMARY, format: LogFormat.RAWTEXT, }); @@ -125,7 +125,10 @@ export class ActionResolver< ) ); - return { effects }; + return { + effects, + ...(general.troopId === general.id ? { deletedTroopIds: [general.id] } : {}), + }; } } diff --git a/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts b/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts index cf5368d..0afb5f7 100644 --- a/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts +++ b/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts @@ -223,6 +223,7 @@ describe('migrated general commands', () => { expect(updatedHeir.officerLevel).toBe(12); expect(updatedLord.officerLevel).toBe(1); expect(updatedLord.experience).toBe(700); + expect(world.getNation(1)!.chiefGeneralId).toBe(heir.id); }); it('che_증여: 금은 레거시 최소 보유량 0을 적용해 요청한 금액을 이전한다', async () => { diff --git a/tools/integration-tests/src/turn-differential/canonical.ts b/tools/integration-tests/src/turn-differential/canonical.ts index fb2d38b..bfc02fc 100644 --- a/tools/integration-tests/src/turn-differential/canonical.ts +++ b/tools/integration-tests/src/turn-differential/canonical.ts @@ -153,6 +153,9 @@ export const projectCoreDatabaseSnapshot = (rows: { dex3: readNumber(meta, 'dex3'), dex4: readNumber(meta, 'dex4'), dex5: readNumber(meta, 'dex5'), + specAge: readNumber(meta, 'specage'), + specAge2: readNumber(meta, 'specage2'), + penalty: asRecord(row.penalty), killTurn: readNumber(meta, 'killturn'), mySet: readNumber(meta, 'myset'), }; diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index a387b59..0e5d75b 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -248,9 +248,12 @@ const buildGeneral = (row: Record, fallbackTurnTime: Date): Tur atmos: readNumber(row, 'atmos'), age: readNumber(row, 'age', 30), npcState: readNumber(row, 'npcState'), + penalty: row.penalty, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: { ...meta, + specage: readNumber(row, 'specAge', readNumber(meta, 'specage')), + specage2: readNumber(row, 'specAge2', readNumber(meta, 'specage2')), killturn: readNumber(row, 'killTurn', readNumber(meta, 'killturn', 24)), leadership_exp: readNumber(row, 'leadershipExp', readNumber(meta, 'leadership_exp')), strength_exp: readNumber(row, 'strengthExp', readNumber(meta, 'strength_exp')), @@ -568,6 +571,9 @@ const projectWorld = ( dex3: toDatabaseInt(readNumber(general.meta, 'dex3')), dex4: toDatabaseInt(readNumber(general.meta, 'dex4')), dex5: toDatabaseInt(readNumber(general.meta, 'dex5')), + specAge: toDatabaseInt(readNumber(general.meta, 'specage')), + specAge2: toDatabaseInt(readNumber(general.meta, 'specage2')), + penalty: asRecord(general.penalty), killTurn: readNumber(general.meta, 'killturn'), mySet: readNumber(general.meta, 'myset'), })); diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index dca26ba..903649e 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -2089,6 +2089,157 @@ integration('general command in-action failure matrix', () => { ); }); +type GeneralStatusTransitionBoundaryCase = { + name: string; + action: 'che_하야' | 'che_은퇴' | 'che_선양'; + args?: Record; + actorPatch?: Record; + fixturePatches?: FixturePatches; + completed: boolean; + expectedActionKey?: string; + compareLogs?: boolean; +}; + +const generalStatusTransitionBoundaryCases: GeneralStatusTransitionBoundaryCase[] = [ + { + name: 'resignation applies the betrayal penalty and returns excess resources', + action: 'che_하야', + actorPatch: { + officerLevel: 1, + experience: 1001, + dedication: 1003, + gold: 1500, + rice: 1600, + betray: 3, + }, + completed: true, + compareLogs: true, + }, + { + name: 'resigning troop leader releases every member', + action: 'che_하야', + actorPatch: { officerLevel: 1, troopId: 1 }, + fixturePatches: { + generals: { 3: { troopId: 1 } }, + troops: [{ id: 1, nationId: 1, name: '아국군' }], + }, + completed: true, + }, + { + name: 'retirement rejects age fifty nine', + action: 'che_은퇴', + actorPatch: { age: 59, lastTurn: { command: '은퇴', term: 1 } }, + completed: false, + }, + { + name: 'retirement rounds odd attributes and resets speciality ages and ranks', + action: 'che_은퇴', + actorPatch: { + age: 60, + leadership: 91, + strength: 81, + intelligence: 71, + experience: 1001, + dedication: 1003, + dex1: 101, + dex2: 103, + dex3: 105, + dex4: 107, + dex5: 109, + specAge: 65, + specAge2: 66, + lastTurn: { command: '은퇴', term: 1 }, + }, + fixturePatches: { + rankData: LEGACY_RANK_DATA_TYPES.map((type, index) => ({ + generalId: 1, + type, + value: index + 1, + })), + }, + completed: true, + compareLogs: true, + }, + { + name: 'abdication rejects a fractional target ID', + action: 'che_선양', + args: { destGeneralID: 3.5 }, + completed: false, + }, + { + name: 'abdication rejects the acting monarch as target', + action: 'che_선양', + args: { destGeneralID: 1 }, + completed: false, + }, + { + name: 'abdication rejects a target with no-chief penalty in action', + action: 'che_선양', + args: { destGeneralID: 3 }, + fixturePatches: { generals: { 3: { penalty: { noChief: true } } } }, + completed: false, + expectedActionKey: 'che_선양', + compareLogs: true, + }, + { + name: 'abdication transfers the monarch office and experience penalty', + action: 'che_선양', + args: { destGeneralID: 3 }, + actorPatch: { experience: 1001 }, + completed: true, + compareLogs: true, + }, +]; + +integration('general resignation, retirement, and abdication boundary, state, and log parity', () => { + it.each(generalStatusTransitionBoundaryCases)( + '$name', + async ({ + name, + action, + args, + actorPatch, + fixturePatches, + completed, + expectedActionKey, + compareLogs, + }) => { + const request = buildRequest(action, args, actorPatch, fixturePatches); + request.setup!.world!.hiddenSeed = `general-status-transition-${name}`; + request.observe!.includeGlobalHistoryLogs = true; + request.observe!.includeNationHistoryLogs = true; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + const actionKey = expectedActionKey ?? (completed ? action : '휴식'); + const coreCompleted = actionKey === '휴식' ? true : completed; + + expect(reference.execution.outcome).toMatchObject({ completed }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey, + usedFallback: !completed && actionKey === '휴식', + completed: coreCompleted, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + + if (compareLogs) { + expect(semanticLogSignatures(core.after.logs)).toEqual( + semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) + ); + } + }, + 120_000 + ); +}); + type SabotageProbabilityClampCase = { name: string; action: 'che_화계' | 'che_선동' | 'che_파괴' | 'che_탈취';