diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 1c32fe24..d8f0b4b3 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -9,12 +9,13 @@ import type { TurnSchedule, UnitSetDefinition, } from '@sammo-ts/logic'; -import { getNextTurnAt, readScenarioGeneralPoolClaim } from '@sammo-ts/logic'; +import { getNextTurnAt, readScenarioGeneralPoolClaim, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import { GAME_TICKS_PER_TURN, GameClock, assertGameplayCommitAllowed, inferClockPhase, + JosaUtil, type GameClockMode, type GameClockPhase, type TurnRecoveryWindow, @@ -78,6 +79,7 @@ export interface GeneralTurnResult { troopIds?: number[]; }; destroyedNationIds?: number[]; + successorlessNationId?: number; lifecycleEvent?: GeneralLifecycleEvent; } @@ -2028,6 +2030,11 @@ export class InMemoryTurnWorld { this.removeTroop(troopId); } } + if (result.successorlessNationId !== undefined) { + // 사망 군주도 삭제 전 archive의 장수 목록과 멸망 로그에 포함한다. + this.generals.set(currentGeneral.id, result.general ?? currentGeneral); + this.dissolveNationWithoutSuccessor(result.successorlessNationId, currentGeneral.id); + } if (result.deleted?.general) { this.removeGeneral(currentGeneral.id); } @@ -2227,6 +2234,64 @@ export class InMemoryTurnWorld { return changes; } + dissolveNationWithoutSuccessor(nationId: number, dyingLordId?: number): boolean { + const nation = this.nations.get(nationId); + if (!nation) { + return false; + } + const members = this.listGenerals().filter((general) => general.nationId === nationId); + const dyingLord = members.find((general) => general.id === dyingLordId); + if ( + (dyingLordId !== undefined && dyingLord?.officerLevel !== 12) || + members.some( + (general) => general.id !== dyingLordId && (general.npcState !== 5 || general.officerLevel === 12) + ) + ) { + throw new Error(`Nation ${nationId} still has a ruler or successor.`); + } + // Ref nextRuler() -> deleteNation(true): 부대장(npc=5)은 후계자가 + // 될 수 없다. 자원 약탈·포상·난수 소비 없이 도시를 공백지로 돌린다. + for (const city of this.listCities()) { + if (city.nationId === nationId) { + this.updateCity(city.id, { nationId: 0, frontState: 0 }); + } + } + const orderedMembers = members.sort((left, right) => { + if (left.id === dyingLordId) return 1; + if (right.id === dyingLordId) return -1; + return left.id - right.id; + }); + const pushHistory = (): void => { + this.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + text: `【멸망】${nation.name}${JosaUtil.pick(nation.name, '은')} 멸망했습니다.`, + }); + }; + for (const general of orderedMembers) { + if (general.id === dyingLordId) pushHistory(); + // Ref applyDB()는 개인 역사 bucket을 행동 bucket보다 먼저 저장한다. + this.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + generalId: general.id, + format: LogFormat.YEAR_MONTH, + text: `${nation.name}${JosaUtil.pick(nation.name, '이')} 멸망`, + }); + this.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: general.id, + format: LogFormat.PLAIN, + text: `${nation.name}${JosaUtil.pick(nation.name, '이')} 멸망했습니다.`, + }); + } + // 과거 누락으로 군주가 이미 삭제된 국가의 운영 복구도 같은 정산을 쓴다. + if (dyingLordId === undefined) pushHistory(); + return this.collapseNation(nationId); + } + collapseNation(nationId: number): boolean { const nation = this.nations.get(nationId); if (!nation) { diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 0752bb38..b33e4651 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -2252,6 +2252,7 @@ export const createReservedTurnHandler = async (options: { ? 'retired' : 'active'; let deleteGeneral = false; + let successorlessNationId: number | undefined; const deletedTroopIds = Array.from(commandDeletedTroopIds); const lifecycleSnapshot = cloneTurnGeneral(currentGeneral); if (currentGeneral.meta.killturn <= 0) { @@ -2351,6 +2352,10 @@ export const createReservedTurnHandler = async (options: { `${successor.name}이 ${currentNation.name}의 유지를 이어 받았습니다` ) ); + } else { + // Ref nextRuler()는 후계자가 없으면 군주 삭제 전에 + // deleteNation($general, true)로 국가 전체를 정산한다. + successorlessNationId = currentNation.id; } } if (currentGeneral.troopId === currentGeneral.id) { @@ -2424,6 +2429,7 @@ export const createReservedTurnHandler = async (options: { } : undefined), ...(destroyedNationIds.size > 0 ? { destroyedNationIds: [...destroyedNationIds] } : undefined), + ...(successorlessNationId !== undefined ? { successorlessNationId } : {}), lifecycleEvent: { generalId: currentGeneral.id, outcome: lifecycleOutcome, diff --git a/app/game-engine/test/generalTurnLifecycle.test.ts b/app/game-engine/test/generalTurnLifecycle.test.ts index 71397b7d..5eaa5ac1 100644 --- a/app/game-engine/test/generalTurnLifecycle.test.ts +++ b/app/game-engine/test/generalTurnLifecycle.test.ts @@ -337,6 +337,62 @@ describe('legacy general turn lifecycle', () => { expect(harness.world.peekDirtyState().deletedGenerals).toContain(1); }); + it('dissolves a dying ruler nation when only troop-leader NPCs remain', async () => { + const leader = makeGeneral({ officerLevel: 12, meta: { killturn: 1 } }); + const troopLeader = makeGeneral({ + id: 2, + userId: null, + npcState: 5, + officerLevel: 11, + troopId: 2, + turnTime: new Date(start.getTime() + 3_600_000), + meta: { killturn: 24, officer_city: 1, belong: 5, permission: 'ambassador' }, + }); + const snapshot = makeSnapshot([leader, troopLeader]); + snapshot.cities[0]!.conflict = { '1': 1 }; + const harness = await createTurnTestHarness({ snapshot, state: makeState(), schedule, map }); + + await harness.runOneTick(); + + expect(harness.world.getGeneralById(1)).toBeNull(); + expect(harness.world.getNationById(1)).toBeNull(); + expect(harness.world.getCityById(1)).toMatchObject({ nationId: 0, frontState: 0, conflict: [] }); + expect(harness.world.getGeneralById(2)).toMatchObject({ + nationId: 0, + officerLevel: 0, + troopId: 0, + gold: troopLeader.gold, + rice: troopLeader.rice, + meta: { officer_city: 0, officerCity: 0, belong: 0, permission: 'normal' }, + }); + const dirty = harness.world.peekDirtyState(); + expect(dirty.deletedNations).toContain(1); + expect(dirty.deletedNationSnapshots[0]?.generalIds).toEqual([1, 2]); + expect(dirty.logs.filter((log) => log.text.includes('【멸망】'))).toHaveLength(1); + }); + + it('repairs an already orphaned nation once but refuses a nation with a successor', async () => { + const troopLeader = makeGeneral({ id: 2, userId: null, npcState: 5, officerLevel: 11 }); + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([troopLeader]), + state: makeState(), + schedule, + map, + }); + expect(harness.world.dissolveNationWithoutSuccessor(1)).toBe(true); + expect(harness.world.dissolveNationWithoutSuccessor(1)).toBe(false); + expect(harness.world.getGeneralById(2)?.nationId).toBe(0); + const intact = await createTurnTestHarness({ + snapshot: makeSnapshot([makeGeneral({ officerLevel: 9 })]), + state: makeState(), + schedule, + map, + }); + expect(() => intact.world.dissolveNationWithoutSuccessor(1)).toThrow('still has a ruler or successor'); + expect(intact.world.getCityById(1)?.nationId).toBe(1); + expect(intact.world.getNationById(1)).not.toBeNull(); + }); + it('deletes an expired NPC even when its in-memory lifespan metadata is missing', async () => { const harness = await createTurnTestHarness({ snapshot: makeSnapshot([ diff --git a/app/game-engine/test/monthlyWanderPersistence.integration.test.ts b/app/game-engine/test/monthlyWanderPersistence.integration.test.ts index 928410d3..b1b09683 100644 --- a/app/game-engine/test/monthlyWanderPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyWanderPersistence.integration.test.ts @@ -457,4 +457,157 @@ integration('monthly wandering nation persistence', () => { await hooks.close(); } }); + it('atomically repairs a rulerless nation with only troop NPCs and preserves its archive', async () => { + await cleanup(); + const nation = buildNation(nationIds[1]!, '방랑국', 3, 0); + const city = buildCity(cityIds[0]!, '방랑성', nation.id); + const general = buildGeneral({ + id: generalIds[0]!, + name: '부대장', + nationId: nation.id, + cityId: city.id, + officerLevel: 11, + npcState: 5, + gold: 2345, + rice: 6789, + belong: 5, + turnTime: new Date('0195-01-01T00:05:00.000Z'), + }); + await db.nation.create({ + data: { + id: nation.id, + name: nation.name, + color: nation.color, + level: nation.level, + typeCode: nation.typeCode, + chiefGeneralId: 12345, + }, + }); + await db.city.create({ + data: { + id: city.id, + name: city.name, + level: city.level, + nationId: nation.id, + region: 1, + population: 1000, + populationMax: 2000, + agriculture: 100, + agricultureMax: 200, + commerce: 100, + commerceMax: 200, + security: 100, + securityMax: 200, + defence: 100, + defenceMax: 200, + wall: 100, + wallMax: 200, + frontState: 1, + }, + }); + await db.general.create({ + data: { + id: general.id, + name: general.name, + nationId: nation.id, + cityId: city.id, + officerLevel: 11, + npcState: 5, + gold: general.gold, + rice: general.rice, + turnTime: general.turnTime, + }, + }); + await db.nationTurn.create({ + data: { nationId: nation.id, officerLevel: 12, turnIdx: 0, actionCode: '휴식', arg: {} }, + }); + const row = await db.worldState.create({ + data: { + scenarioCode, + currentYear: 195, + currentMonth: 1, + tickSeconds: 600, + meta: { serverId }, + }, + }); + const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }; + const world = new InMemoryTurnWorld( + { + id: row.id, + currentYear: 195, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0195-01-01T00:00:00.000Z'), + meta: { serverId }, + }, + { + scenarioConfig, + scenarioMeta: { + title: 'test', + startYear: 193, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + map: { id: 'test', name: 'test', cities: [] }, + nations: [nation], + cities: [city], + generals: [general], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + }, + { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } } + ); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + try { + world.dissolveNationWithoutSuccessor(nation.id); + const result = { + lastTurnTime: world.getState().lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 0, + durationMs: 0, + partial: false, + }; + // A stale clock fence must leave the entire repair uncommitted and retryable. + await db.worldState.update({ where: { id: row.id }, data: { clockRevision: 2 } }); + await expect(hooks.hooks.flushChanges!(result)).rejects.toThrow('Game clock fence changed'); + expect(await db.nation.count({ where: { id: nation.id } })).toBe(1); + expect((await db.general.findUniqueOrThrow({ where: { id: general.id } })).nationId).toBe(nation.id); + expect(await db.oldNation.count({ where: { serverId } })).toBe(0); + await db.worldState.update({ where: { id: row.id }, data: { clockRevision: 1 } }); + await hooks.hooks.flushChanges!(result); + expect(await db.nation.findUnique({ where: { id: nation.id } })).toBeNull(); + expect(await db.city.findUniqueOrThrow({ where: { id: city.id } })).toMatchObject({ + nationId: 0, + frontState: 0, + }); + expect(await db.general.findUniqueOrThrow({ where: { id: general.id } })).toMatchObject({ + nationId: 0, + officerLevel: 0, + gold: 2345, + rice: 6789, + }); + expect(await db.nationTurn.count({ where: { nationId: nation.id } })).toBe(0); + const archive = await db.oldNation.findUniqueOrThrow({ + where: { serverId_nation_sourceId: { serverId, nation: nation.id, sourceId: 0 } }, + }); + expect(archive.data).toMatchObject({ nation: nation.id, generals: [general.id] }); + const logCount = await db.logEntry.count({ where: { text: { contains: '방랑국' } } }); + expect(logCount).toBe(3); + expect(world.dissolveNationWithoutSuccessor(nation.id)).toBe(false); + await hooks.hooks.flushChanges!(result); + expect(await db.logEntry.count({ where: { text: { contains: '방랑국' } } })).toBe(logCount); + } finally { + await hooks.close(); + } + }); }); diff --git a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts index 0ff4a489..a0d50181 100644 --- a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts +++ b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts @@ -168,6 +168,60 @@ const readFixture = (relativePath: string): TurnCommandFixtureRequest => { }; integration('core ↔ legacy command-boundary differential', () => { + it('dissolves a successorless ruler nation at the death boundary', async () => { + const request = readFixture('fixtures/turn-differential/live-sortie-conquest.json'); + request.actorGeneralId = 2; + request.action = '휴식'; + request.args = {}; + request.includeLifecycle = true; + request.setup!.generals![1] = { + ...request.setup!.generals![1], + npcState: 4, + killTurn: 1, + deadYear: 185, + }; + request.setup!.generals!.push({ + id: 3, + name: '부대장', + nationId: 2, + cityId: 70, + officerLevel: 11, + npcState: 5, + killTurn: 24, + gold: 2345, + rice: 6789, + crew: 0, + }); + request.observe = { + ...request.observe, + generalIds: [1, 2, 3], + nationIds: [1, 2], + cityIds: [3, 70], + includeGlobalHistoryLogs: true, + includeNationHistoryLogs: true, + }; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + for (const result of [reference, core]) { + expect(result.after.generals.find((general) => general.id === 2)).toBeUndefined(); + expect(result.after.nations.find((nation) => nation.id === 2)).toBeUndefined(); + expect(result.after.cities.find((city) => city.id === 70)).toMatchObject({ nationId: 0, frontState: 0 }); + expect(result.after.generals.find((general) => general.id === 3)).toMatchObject({ + nationId: 0, + officerLevel: 0, + gold: 2345, + rice: 6789, + }); + } + expect(core.rng).toEqual(reference.rng); + expect(semanticLogSignatures(core.after.logs.filter((log) => String(log.text).includes('멸망')))).toEqual( + semanticLogSignatures(reference.after.logs.filter((log) => String(log.text).includes('멸망'))) + ); + }); + it.each([ ['nation declaration', 'fixtures/turn-differential/nation-declaration.json'], ['live sortie conquest', 'fixtures/turn-differential/live-sortie-conquest.json'],