diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index cc742204..a6ad2f33 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -2498,7 +2498,13 @@ export const createReservedTurnHandler = async (options: { candidate.npcState !== 5 ); let successor: TurnGeneral | undefined; - const fiction = readMetaNumber(worldMeta, 'fiction', 0); + // 설치된 상성 모드는 world config가 소유한다. meta의 오래된 값으로 + // 가상 모드 군주까지 NPC 상성 승계를 실행하지 않는다. + const fiction = readMetaNumber( + options.getWorld()?.getWorldConfig() ?? {}, + 'fiction', + options.scenarioMeta?.fiction ?? readMetaNumber(worldMeta, 'fiction', 0) + ); if ( fiction === 0 && currentGeneral.npcState > 0 && @@ -2514,11 +2520,17 @@ export const createReservedTurnHandler = async (options: { const distance = Math.abs((candidate.affinity ?? 0) - (currentGeneral.affinity ?? 0)); return distance > 75 ? 150 - distance : distance; }; - const minDistance = Math.min(...npcCandidates.map(affinityDistance)); - const nearest = npcCandidates.filter( - (candidate) => affinityDistance(candidate) === minDistance + npcCandidates.sort( + (left, right) => affinityDistance(left) - affinityDistance(right) || left.id - right.id ); - if (nearest.length > 0) { + // Ref nextRuler의 !$candidate['npcmatch2'] == $minNPCMatch는 + // !가 먼저 평가된다. 거리 0이면 동률만, 양수이면 정렬된 전체 + // 후보를 추첨하는 실제 계승 계약을 보존한다. + const hasExactMatch = npcCandidates[0] && affinityDistance(npcCandidates[0]) === 0; + const eligible = hasExactMatch + ? npcCandidates.filter((candidate) => affinityDistance(candidate) === 0) + : npcCandidates; + if (eligible.length > 0) { const rng = new RandUtil( new LiteHashDRBG( serializeSeed( @@ -2530,7 +2542,7 @@ export const createReservedTurnHandler = async (options: { ) ) ); - successor = rng.choice(nearest); + successor = rng.choice(eligible); } } successor ??= candidates diff --git a/app/game-engine/test/generalTurnLifecycle.test.ts b/app/game-engine/test/generalTurnLifecycle.test.ts index 72ac8ec4..dda3100a 100644 --- a/app/game-engine/test/generalTurnLifecycle.test.ts +++ b/app/game-engine/test/generalTurnLifecycle.test.ts @@ -408,6 +408,28 @@ describe('legacy general turn lifecycle', () => { expect(harness.world.peekDirtyState().deletedGenerals).toContain(1); }); + it.each([ + { name: 'installed fiction overrides stale meta', config: 1, scenario: 0, legacy: 0 }, + { name: 'scenario fiction without installed override', config: undefined, scenario: 1, legacy: 0 }, + ])('uses chief order for fictional NPC ruler: $name', async ({ config, scenario, legacy }) => { + const leader = makeGeneral({ officerLevel: 12, npcState: 2, meta: { killturn: 1 } }); + const closeNpc = makeGeneral({ id: 2, npcState: 2, affinity: 50, officerLevel: 1 }); + const chief = makeGeneral({ id: 3, npcState: 0, officerLevel: 11 }); + const troopLeader = makeGeneral({ id: 4, npcState: 5, officerLevel: 11, dedication: 9999 }); + const snapshot = makeSnapshot([leader, closeNpc, chief, troopLeader]); + snapshot.worldConfig = config === undefined ? {} : { fiction: config }; + snapshot.scenarioMeta!.fiction = scenario; + const state = makeState(); + state.meta = { ...state.meta, fiction: legacy }; + const harness = await createTurnTestHarness({ snapshot, state, schedule, map }); + await harness.runOneTick(); + expect(harness.world.getGeneralById(1)).toBeNull(); + expect(harness.world.getGeneralById(3)!.officerLevel).toBe(12); + expect(harness.world.getGeneralById(2)!.officerLevel).toBe(1); + expect(harness.world.getGeneralById(4)!.officerLevel).toBe(11); + expect(harness.world.getNationById(1)!.chiefGeneralId).toBe(3); + }); + it('dissolves a dying ruler nation when only troop-leader NPCs remain', async () => { const leader = makeGeneral({ officerLevel: 12, meta: { killturn: 1 } }); const troopLeader = makeGeneral({ diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index e41698af..8deb395a 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -73,6 +73,7 @@ export interface TurnCommandFixtureRequest { develCost?: number; isUnited?: 0 | 1 | 2 | 3; hiddenSeed?: string; + fiction?: 0 | 1; scenarioEffect?: string | null; staticEventHandlers?: Record; freezeClock?: boolean; @@ -548,7 +549,7 @@ export const buildCoreTurnCommandWorldInput = ( title: '턴 명령 차등', startYear: request.setup?.world?.startYear ?? Math.max(1, year - 5), life: null, - fiction: 0, + fiction: request.setup?.world?.fiction ?? 0, history: [], ignoreDefaultEvents: false, }, diff --git a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts index a0d50181..03fba7c3 100644 --- a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts +++ b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts @@ -168,6 +168,83 @@ const readFixture = (relativePath: string): TurnCommandFixtureRequest => { }; integration('core ↔ legacy command-boundary differential', () => { + it.each([ + { + name: 'fictional NPC chief priority', + expected: 4, + fiction: 1, + npc: 2, + affinities: [50, 90, 120], + levels: [1, 11, 10], + }, + { name: 'positive affinity distances', expected: 5, npc: 2, affinities: [60, 90, 120], levels: [1, 1, 1] }, + { + name: 'affinity order differs from general id', + expected: 3, + npc: 2, + affinities: [120, 60, 90], + levels: [1, 1, 1], + }, + { name: 'zero affinity distance', expected: 3, npc: 2, affinities: [50, 90, 120], levels: [1, 1, 1] }, + { name: 'human ruler chief priority', expected: 4, npc: 0, affinities: [60, 90, 120], levels: [9, 11, 10] }, + { name: 'human ruler dedication fallback', expected: 5, npc: 0, affinities: [60, 90, 120], levels: [1, 1, 1] }, + ])('matches ruler succession: $name', async ({ npc, affinities, levels, fiction, expected }) => { + const request = readFixture('fixtures/turn-differential/live-sortie-conquest.json'); + request.setup!.world!.hiddenSeed = 'succession-0'; + request.setup!.world!.fiction = fiction === 1 ? 1 : 0; + request.actorGeneralId = 2; + request.action = '휴식'; + request.args = {}; + request.includeLifecycle = true; + request.setup!.generals![1] = { + ...request.setup!.generals![1], + npcState: npc, + affinity: 50, + killTurn: 1, + deadYear: 185, + }; + request.setup!.generals!.push( + ...affinities.map((affinity, index) => ({ + id: index + 3, + name: `후계${index}`, + nationId: 2, + cityId: 70, + npcState: 2, + affinity, + officerLevel: levels[index], + dedication: (index + 1) * 100, + killTurn: 24, + crew: 0, + })) + ); + request.observe = { + ...request.observe, + generalIds: [1, 2, 3, 4, 5], + nationIds: [1, 2], + cityIds: [3, 70], + includeGlobalHistoryLogs: true, + }; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + const rulers = (snapshot: CanonicalTurnSnapshot) => + snapshot.generals + .filter((general) => general.nationId === 2 && general.officerLevel === 12) + .map((general) => general.id); + expect(reference.after.generals.find((general) => general.id === 2)).toBeUndefined(); + expect(core.after.generals.find((general) => general.id === 2)).toBeUndefined(); + expect(reference.before.generals.find((general) => general.id === 2)?.affinity).toBe(50); + expect( + reference.before.generals.filter((general) => Number(general.id) >= 3).map((general) => general.affinity) + ).toEqual(affinities); + expect(rulers(reference.after)).toEqual([expected]); + expect(rulers(core.after)).toEqual(rulers(reference.after)); + expect(rulers(core.after)).toHaveLength(1); + expect(core.rng).toEqual(reference.rng); + }); + it('dissolves a successorless ruler nation at the death boundary', async () => { const request = readFixture('fixtures/turn-differential/live-sortie-conquest.json'); request.actorGeneralId = 2;