diff --git a/app/game-engine/src/turn/monthlyInvaderAction.ts b/app/game-engine/src/turn/monthlyInvaderAction.ts index 5a454bf0..011c352f 100644 --- a/app/game-engine/src/turn/monthlyInvaderAction.ts +++ b/app/game-engine/src/turn/monthlyInvaderAction.ts @@ -500,14 +500,16 @@ export const createInvaderEndingHandler = (options: { return; } const nations = world.listNations(); - if (nations.length >= 2) { - return; - } - const neutralCityCount = world.listCities().filter((city) => city.nationId === 0).length; - let userWin = false; - if (neutralCityCount === 0) { - userWin = nations.length === 1 && !nations[0]!.name.startsWith(INVADER_PREFIX); - } else if (neutralCityCount !== world.listCities().length) { + // Ref는 국가명 `ⓞ` prefix로 이벤트 승자를 구분한다. 전체 국가 수로 + // 막으면 서로 불가침 중인 이민족이 여럿 남았을 때 일반국 전멸 뒤에도 + // 종료할 수 없으므로, 일반국의 부재를 직접 판정한다. + const ordinaryNations = nations.filter((nation) => !nation.name.startsWith(INVADER_PREFIX)); + const invaderNations = nations.filter((nation) => nation.name.startsWith(INVADER_PREFIX)); + const cities = world.listCities(); + const neutralCityCount = cities.filter((city) => city.nationId === 0).length; + const userWin = ordinaryNations.length === 1 && invaderNations.length === 0 && neutralCityCount === 0; + const invaderWin = ordinaryNations.length === 0; + if (!userWin && !invaderWin) { return; } const texts = userWin diff --git a/app/game-engine/test/monthlyInvaderAction.test.ts b/app/game-engine/test/monthlyInvaderAction.test.ts index ae4e8adf..96b396ea 100644 --- a/app/game-engine/test/monthlyInvaderAction.test.ts +++ b/app/game-engine/test/monthlyInvaderAction.test.ts @@ -372,6 +372,85 @@ describe('invader monthly actions', () => { ]); }); + it('finishes as an invader victory when no ordinary nation remains without queuing unification archives', async () => { + const endingEvent: TurnEvent = { + id: 9, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['InvaderEnding']], + meta: {}, + }; + const invaderNations = [ + buildNation(2, 1, 'ⓞ남만족'), + buildNation(3, 2, 'ⓞ산월족'), + ]; + const harness = buildHarness({ + cities: [buildCity(1, 2, 4), buildCity(2, 3, 4)], + nations: invaderNations, + generals: [], + events: [endingEvent], + meta: { isunited: 1, refreshLimit: 3 }, + }); + const state = { ...harness.state, currentYear: 199, currentMonth: 12 }; + let world: InMemoryTurnWorld | null = null; + const handler = createInvaderEndingHandler({ getWorld: () => world }); + world = new InMemoryTurnWorld(state, harness.snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + calendarHandler: createMonthlyEventHandler({ + getWorld: () => world, + startYear: 190, + actions: new Map([['InvaderEnding', handler]]), + }), + }); + + await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z')); + + expect(world.getState().meta).toMatchObject({ isunited: 3, isUnited: 3, refreshLimit: 300 }); + expect(world.listEvents()).toHaveLength(0); + expect(world.peekDirtyState().logs.map((log) => log.text)).toEqual([ + '【이벤트】중원은 이민족에 의해 혼란에 빠졌습니다.', + '【이벤트】백성은 언젠가 영웅이 나타나길 기다립니다.', + ]); + expect(world.peekDirtyState().pendingUnificationFinalizations).toEqual([]); + expect(world.peekDirtyState().pendingYearbookSnapshots).toEqual([]); + }); + + it('keeps the invader event running while an ordinary nation remains', async () => { + const endingEvent: TurnEvent = { + id: 9, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['InvaderEnding']], + meta: {}, + }; + const harness = buildHarness({ + cities: [buildCity(1, 1, 3), buildCity(2, 2, 4), buildCity(3, 3, 4)], + nations: [buildNation(1), buildNation(2, 2, 'ⓞ남만족'), buildNation(3, 3, 'ⓞ산월족')], + events: [endingEvent], + meta: { isunited: 1, refreshLimit: 3 }, + }); + const state = { ...harness.state, currentYear: 199, currentMonth: 12 }; + let world: InMemoryTurnWorld | null = null; + const handler = createInvaderEndingHandler({ getWorld: () => world }); + world = new InMemoryTurnWorld(state, harness.snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + calendarHandler: createMonthlyEventHandler({ + getWorld: () => world, + startYear: 190, + actions: new Map([['InvaderEnding', handler]]), + }), + }); + + await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z')); + + expect(world.getState().meta).toMatchObject({ isunited: 1, refreshLimit: 3 }); + expect(world.listEvents().map((entry) => entry.id)).toEqual([9]); + expect(world.peekDirtyState().logs).toEqual([]); + expect(world.peekDirtyState().pendingUnificationFinalizations).toEqual([]); + }); + it('does not execute dynamically-added events in the same monthly dispatch', async () => { const harness = buildHarness(); const actions = new Map([ diff --git a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts index cb7c8716..8957b202 100644 --- a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts @@ -28,6 +28,7 @@ const ordinaryCityId = 991_100; const invaderCityId = 991_101; const sourceEventId = 991_100; const createdEventIds = [991_101, 991_102]; +const serverId = 'raise-invader-persistence'; type ReferenceInvaderLifecycleTrace = { phases: { @@ -172,6 +173,11 @@ integration('RaiseInvader database persistence', () => { const clean = async () => { const createdGeneralIds = Array.from({ length: 10 }, (_, index) => firstCreatedGeneralId + index); + await db.unificationFinalization.deleteMany({ where: { serverId } }); + await db.yearbookHistory.deleteMany({ where: { profileName: serverId } }); + await db.hallOfFame.deleteMany({ where: { serverId } }); + await db.emperor.deleteMany({ where: { serverId } }); + await db.gameHistory.deleteMany({ where: { serverId } }); await db.logEntry.deleteMany({ where: { year: 200, text: { contains: '【이벤트】' } }, }); @@ -304,10 +310,10 @@ integration('RaiseInvader database persistence', () => { tickSeconds: 600, config: {}, meta: { - hiddenSeed: 'raise-invader-persistence', + hiddenSeed: serverId, lastGeneralId: firstCreatedGeneralId - 1, lastNationId: createdNationId - 1, - serverId: 'raise-invader-persistence', + serverId, refreshLimit: 3, }, }, @@ -319,10 +325,10 @@ integration('RaiseInvader database persistence', () => { tickSeconds: 600, lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), meta: { - hiddenSeed: 'raise-invader-persistence', + hiddenSeed: serverId, lastGeneralId: firstCreatedGeneralId - 1, lastNationId: createdNationId - 1, - serverId: 'raise-invader-persistence', + serverId, refreshLimit: 3, }, }; @@ -572,6 +578,11 @@ integration('RaiseInvader database persistence', () => { { text: '●200년 4월:【이벤트】이민족을 모두 소탕했습니다!' }, { text: '●200년 4월:【이벤트】중원은 당분간 태평성대를 누릴 것입니다.' }, ]); + await expect(db.unificationFinalization.count({ where: { serverId } })).resolves.toBe(0); + await expect(db.yearbookHistory.count({ where: { profileName: serverId } })).resolves.toBe(0); + await expect(db.hallOfFame.count({ where: { serverId } })).resolves.toBe(0); + await expect(db.emperor.count({ where: { serverId } })).resolves.toBe(0); + await expect(db.gameHistory.count({ where: { serverId } })).resolves.toBe(0); if (referenceTrace) { expect(referenceTrace.phases.afterUserWin).toEqual({ result: 'Deleted',