diff --git a/app/game-engine/src/turn/actionableMessageResponse.ts b/app/game-engine/src/turn/actionableMessageResponse.ts index 6fe4aa67..d906cd69 100644 --- a/app/game-engine/src/turn/actionableMessageResponse.ts +++ b/app/game-engine/src/turn/actionableMessageResponse.ts @@ -192,6 +192,13 @@ const respondToScout = async (options: { return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' }; } + // 등용장은 발신 장수의 현재 소속이 아니라 발송 당시 국가에 귀속된다. + // 멸망 처리 도입 전에 남은 편지도 수락/거절 전에 영구 만료한다. + if (!world.getNationById(payload.src.nationId)) { + await invalidateMessageIds(db, world, [row.id], now); + return { ok: false, action: 'scout', reason: '등용장을 보낸 국가가 멸망했습니다.' }; + } + const sourceNationName = payload.src.nationName; const sourceNationJosaRo = JosaUtil.pick(sourceNationName, '로'); if (response) { diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 43177c68..a696e7c9 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -464,6 +464,11 @@ export const createReadModelChangeJournal = ( if (commandResult?.type === 'voteReward' && commandResult.ok) { journal.mark('front.general', commandResult.generalId); } + // 과거 멸망국 등용장의 거부 응답도 action을 만료시킬 수 있다. + // 개인 메시지 응답 뒤에는 성공 여부와 관계없이 해당 수신함을 다시 읽는다. + if (commandResult?.type === 'messageRespond' && commandResult.action === 'scout') { + journal.mark('messages.mailbox', commandResult.generalId); + } markIds(journal, 'general.content', changes.generalIds); markIds(journal, 'city.content', changes.cityIds); markIds(journal, 'nation.content', changes.nationIds); @@ -1910,6 +1915,37 @@ export const createDatabaseTurnHooks = async ( { sendDestOnly: message.sendDestOnly } ); } + if (deletedNations.length > 0) { + // 발신자 하야/이적은 등용장을 바꾸지 않는다. 발송 당시 국가가 + // 멸망할 때만 같은 transaction에서 action과 구버전 만료 투영을 닫는다. + // 이번 flush에 생성된 편지도 포함하도록 message 저장 뒤에 처리한다. + const letters = await prisma.message.findMany({ + where: { + type: 'private', + action: { actionType: 'scout', status: 'PENDING' }, + OR: deletedNations.map((nationId) => ({ + message: { path: ['src', 'nationId'], equals: nationId }, + })), + }, + select: { id: true, mailbox: true }, + }); + if (letters.length > 0) { + const ids = letters.map(({ id }) => id); + const resolvedGameTick = BigInt(state.clockTick ?? state.lastTurnTick ?? 0); + await prisma.messageAction.updateMany({ + where: { messageId: { in: ids }, status: 'PENDING' }, + data: { status: 'RESOLVED', resolvedGameTick }, + }); + await prisma.message.updateMany({ + where: { id: { in: ids } }, + data: { + validUntil: world.gameTickToDate(Number(resolvedGameTick)), + validUntilTick: resolvedGameTick, + }, + }); + persistedMessageMailboxes.push(...letters.map(({ mailbox }) => mailbox)); + } + } if (options?.reservedTurns && persistedReservedTurnChanges) { await options.reservedTurns.persistChanges(prisma, persistedReservedTurnChanges); } diff --git a/app/game-engine/test/actionableMessageResponse.test.ts b/app/game-engine/test/actionableMessageResponse.test.ts index ead7bdf3..1fd16976 100644 --- a/app/game-engine/test/actionableMessageResponse.test.ts +++ b/app/game-engine/test/actionableMessageResponse.test.ts @@ -52,7 +52,21 @@ const buildWorld = (): InMemoryTurnWorld => { const snapshot: TurnWorldSnapshot = { generals: [actor], cities: [], - nations: [], + nations: [ + { + id: 2, + name: '촉', + color: '#000000', + level: 1, + capitalCityId: 2, + chiefGeneralId: 8, + gold: 0, + rice: 0, + power: 0, + typeCode: 'che_중립', + meta: {}, + }, + ], troops: [], diplomacy: [], events: [], @@ -211,6 +225,37 @@ describe('actionable message response', () => { expect(world.peekDirtyState().messages).toHaveLength(0); }); + it.each([true, false])( + 'invalidates a surviving letter from a collapsed nation on response=%s', + async (response) => { + const world = buildWorld(); + world.removeNation(source.nationId); + const { db, actionUpdateMany, updateMany } = buildDb([[buildRow('scout')]]); + const executor = buildExecutor(); + const result = await respondToActionableMessage({ + db, + world, + executor, + requestId, + userId: actor.userId!, + generalId: actor.id, + messageId: 29, + response, + }); + expect(result).toEqual({ ok: false, action: 'scout', reason: '등용장을 보낸 국가가 멸망했습니다.' }); + expect(executor.execute).not.toHaveBeenCalled(); + expect(actionUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { messageId: { in: [29] }, status: 'PENDING' }, + data: { status: 'RESOLVED', resolvedGameTick: expect.any(BigInt) }, + }) + ); + expect(updateMany).toHaveBeenCalledOnce(); + expect(world.getGeneralById(actor.id)?.nationId).toBe(actor.nationId); + expect(world.peekDirtyState().messages).toHaveLength(0); + } + ); + it('treats a legacy truthy used value as an invalid scout letter', async () => { for (const row of [buildRow('scout', { option: { action: 'scout', used: 1 } })]) { const world = buildWorld(); diff --git a/app/game-engine/test/immediateGeneralActionsPersistence.integration.test.ts b/app/game-engine/test/immediateGeneralActionsPersistence.integration.test.ts index 3b145426..d2a0b611 100644 --- a/app/game-engine/test/immediateGeneralActionsPersistence.integration.test.ts +++ b/app/game-engine/test/immediateGeneralActionsPersistence.integration.test.ts @@ -4,6 +4,8 @@ import { SystemClock } from '@sammo-ts/common'; import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic'; +import { buildScoutMessageDraft } from '@sammo-ts/logic/messages/scoutMessage.js'; + import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js'; import { TurnDaemonLifecycle } from '../src/lifecycle/turnDaemonLifecycle.js'; import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; @@ -17,9 +19,9 @@ import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js'; const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); const worldId = 991_731; -const generalId = 991_731; -const cityId = 991_731; -const existingNationId = 991_730; +const generalId = 731; +const cityId = 731; +const existingNationId = 730; const requestId = 'integration:engine:immediate-action-uprising'; const occupiedUniqueItem = 'che_무기_12_칠성검'; @@ -179,7 +181,8 @@ integration('immediate general action persistence', () => { OR: [{ srcNationId: { gte: existingNationId } }, { destNationId: { gte: existingNationId } }], }, }); - await db.general.deleteMany({ where: { id: generalId } }); + await db.message.deleteMany({ where: { mailbox: { in: [generalId, generalId + 1] } } }); + await db.general.deleteMany({ where: { id: { in: [generalId, generalId + 1] } } }); await db.city.deleteMany({ where: { id: cityId } }); await db.nation.deleteMany({ where: { id: { gte: existingNationId } } }); await db.worldState.deleteMany({ where: { id: worldId } }); @@ -288,13 +291,193 @@ integration('immediate general action persistence', () => { OR: [{ srcNationId: { gte: existingNationId } }, { destNationId: { gte: existingNationId } }], }, }); - await db.general.deleteMany({ where: { id: generalId } }); + await db.message.deleteMany({ where: { mailbox: { in: [generalId, generalId + 1] } } }); + await db.general.deleteMany({ where: { id: { in: [generalId, generalId + 1] } } }); await db.city.deleteMany({ where: { id: cityId } }); await db.nation.deleteMany({ where: { id: { gte: existingNationId } } }); await db.worldState.deleteMany({ where: { id: worldId } }); await disconnect?.(); }); + it.each([ + 'normal', + 'resigned', + 'transferred', + 'deleted', + 'ruler', + 'collapsed', + 'legacyCollapsed', + 'random', + ] as const)('persists the recruitment-letter lifecycle: %s', async (mode) => { + const recruiterId = generalId + 1; + await db.general.create({ + data: { + id: recruiterId, + name: '권유자', + meta: { killturn: 24 }, + nationId: existingNationId, + officerLevel: 1, + cityId, + turnTime: general.turnTime, + }, + }); + await db.nation.update({ + where: { id: existingNationId }, + data: { capitalCityId: cityId, meta: { gennum: 1 } }, + }); + await db.city.update({ where: { id: cityId }, data: { nationId: existingNationId } }); + const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + const handler = createTurnDaemonCommandHandler({ world, scenarioMeta, map }); + const flush = () => + hooks.hooks.flushChanges!({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 0, + durationMs: 0, + partial: false, + }); + try { + const draft = buildScoutMessageDraft({ + srcGeneral: world.getGeneralById(recruiterId)!, + destGeneral: world.getGeneralById(generalId)!, + srcNation: world.getNationById(existingNationId), + destNation: null, + time: world.gameTickToDate(0), + }); + expect(draft).not.toBeNull(); + world.queueMessage(draft!); + await flush(); + const letter = await db.message.findFirstOrThrow({ + where: { mailbox: generalId }, + include: { action: true }, + }); + expect(letter.action?.status).toBe('PENDING'); + const envelopeWallTime = letter.createdAtWall; + if (mode === 'collapsed') { + world.queueMessage({ + ...draft!, + src: { ...draft!.src, nationId: existingNationId + 10 }, + text: '다른 국가의 등용장', + }); + world.queueMessage({ ...draft!, option: {}, text: '일반 서신' }); + await flush(); + } + while (hooks.takeCommittedReadModelChangeReceipt()) { + /* discard setup receipts */ + } + + if (mode === 'resigned' || mode === 'transferred') { + world.updateGeneral(recruiterId, { nationId: mode === 'resigned' ? 0 : existingNationId + 10 }); + } else if (mode === 'deleted') { + world.removeGeneral(recruiterId); + } else if (mode === 'ruler') { + world.updateGeneral(generalId, { nationId: existingNationId + 10, officerLevel: 12 }); + } else if (mode === 'random') { + world.updateWorldConfig({ joinMode: 'onlyRandom' }); + } else if (mode === 'collapsed' || mode === 'legacyCollapsed') { + world.removeNation(existingNationId); + } + if (mode === 'collapsed') { + // Force failure after nation deletion: the envelope/action and nation must roll back together. + await db.$executeRawUnsafe( + "ALTER TABLE message_action ADD CONSTRAINT scout_test_pending CHECK (status = 'PENDING')" + ); + await expect(flush()).rejects.toThrow(); + expect(await db.nation.findUnique({ where: { id: existingNationId } })).not.toBeNull(); + expect((await db.messageAction.findUniqueOrThrow({ where: { messageId: letter.id } })).status).toBe( + 'PENDING' + ); + await db.$executeRawUnsafe('ALTER TABLE message_action DROP CONSTRAINT scout_test_pending'); + } + await flush(); + if (mode === 'legacyCollapsed') { + // Simulate an old deployment which deleted the nation but left this action pending. + await db.messageAction.update({ + where: { messageId: letter.id }, + data: { status: 'PENDING', resolvedGameTick: null }, + }); + } + const currentLetter = await db.message.findUniqueOrThrow({ + where: { id: letter.id }, + include: { action: true }, + }); + expect(currentLetter.createdAtWall).toEqual(envelopeWallTime); + expect(currentLetter.action?.status).toBe(mode === 'collapsed' ? 'RESOLVED' : 'PENDING'); + if (mode === 'collapsed') { + expect(currentLetter.validUntilTick).not.toBeNull(); + const receipt = hooks.takeCommittedReadModelChangeReceipt(); + expect(receipt?.invalidation.revisions).toContainEqual( + expect.objectContaining({ + domain: 'messages.mailbox', + entityId: generalId, + }) + ); + const controls = await db.message.findMany({ + where: { mailbox: generalId, id: { not: letter.id } }, + include: { action: true }, + orderBy: { id: 'asc' }, + }); + expect(controls.map((entry) => entry.action?.status ?? null)).toEqual(['PENDING', null]); + expect(controls.every((entry) => entry.validUntil.getUTCFullYear() === 9999)).toBe(true); + } + const actionRequestId = `${requestId}:scout:${mode}`; + const payload = { + type: 'messageRespond' as const, + requestId: actionRequestId, + userId: general.userId!, + generalId, + messageId: letter.id, + response: true, + }; + await db.inputEvent.create({ + data: { + requestId: actionRequestId, + target: 'ENGINE', + eventType: 'messageRespond', + actorUserId: general.userId, + payload, + }, + }); + const queue = new DatabaseTurnDaemonCommandQueue(db); + await queue.initialize(); + const commands = await queue.drain(); + expect(commands).toHaveLength(1); + const result = await hooks.hooks.executeCommand!(actionRequestId, async (ctx) => { + const value = await handler.handle(commands[0]!, ctx); + if (!value) throw new Error('missing message response'); + return value; + }); + if (mode === 'legacyCollapsed') { + expect(hooks.takeCommittedReadModelChangeReceipt()?.invalidation.revisions).toContainEqual( + expect.objectContaining({ domain: 'messages.mailbox', entityId: generalId }) + ); + } + const accepted = ['normal', 'resigned', 'transferred', 'deleted'].includes(mode); + expect(result).toMatchObject( + accepted ? { ok: true, reason: 'success' } : { reason: expect.not.stringMatching(/^success$/) } + ); + const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + expect(reloaded.snapshot.generals.find(({ id }) => id === generalId)).toMatchObject({ + nationId: accepted ? existingNationId : mode === 'ruler' ? existingNationId + 10 : 0, + officerLevel: accepted ? 1 : mode === 'ruler' ? 12 : 0, + }); + expect((await db.messageAction.findUniqueOrThrow({ where: { messageId: letter.id } })).status).toBe( + mode === 'ruler' || mode === 'random' ? 'PENDING' : 'RESOLVED' + ); + expect((await db.inputEvent.findUniqueOrThrow({ where: { requestId: actionRequestId } })).status).toBe( + 'SUCCEEDED' + ); + expect(await queue.drain()).toEqual([]); + } finally { + await db.$executeRawUnsafe('ALTER TABLE message_action DROP CONSTRAINT IF EXISTS scout_test_pending'); + await hooks.close(); + } + }); + it('commits pre-opening uprising with rollback/retry while scheduled turns remain stopped', async () => { const snapshot: TurnWorldSnapshot = { generals: [general], diff --git a/app/game-engine/test/myInformationCommands.test.ts b/app/game-engine/test/myInformationCommands.test.ts index 1f81bb55..c7c25ffb 100644 --- a/app/game-engine/test/myInformationCommands.test.ts +++ b/app/game-engine/test/myInformationCommands.test.ts @@ -600,102 +600,126 @@ describe('my information world commands', () => { expect(nextIntInclusive).not.toHaveBeenCalled(); }); - it('loads the internal recruitment acceptance action outside the selectable command profile', async () => { - const originalLastTurn = { command: '전투태세', arg: { term: 3 } }; - const recipient = buildGeneral({ - id: 8, - userId: 'user-8', - name: '재야장수', - nationId: 0, - cityId: 1, - officerLevel: 0, - lastTurn: originalLastTurn, - }); - const recruiter = buildGeneral({ - id: 9, - userId: 'user-9', - name: '등용장수', - nationId: 2, - cityId: 2, - }); - const map = { - id: 'test', - name: 'test', - cities: [buildMapCity(1, [2]), buildMapCity(2, [1])], - }; - const fixture = buildImmediateActionWorld({ - general: recipient, - additionalGenerals: [recruiter], - cities: [ - { id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} }, - { id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} }, - ] as TurnWorldSnapshot['cities'], - nations: [ - { - id: 2, - name: '등용국', - color: '#222222', - typeCode: 'che_중립', - level: 1, - capitalCityId: 2, - chiefGeneralId: recruiter.id, - gold: 0, - rice: 0, - power: 0, - meta: { gennum: 1 }, + it.each([ + { recruiterNationId: 2, ruler: false }, + { recruiterNationId: 0, ruler: false }, + { recruiterNationId: 3, ruler: false }, + { recruiterNationId: 2, ruler: true }, + ])( + 'checks original-nation acceptance with $recruiterNationId / ruler=$ruler', + async ({ recruiterNationId, ruler }) => { + const originalLastTurn = { command: '전투태세', arg: { term: 3 } }; + const recipient = buildGeneral({ + id: 8, + userId: 'user-8', + name: '재야장수', + nationId: 0, + cityId: 1, + officerLevel: 0, + lastTurn: originalLastTurn, + }); + const recruiter = buildGeneral({ + id: 9, + userId: 'user-9', + name: '등용장수', + nationId: 2, + cityId: 2, + }); + const map = { + id: 'test', + name: 'test', + cities: [buildMapCity(1, [2]), buildMapCity(2, [1])], + }; + const fixture = buildImmediateActionWorld({ + general: recipient, + additionalGenerals: [recruiter], + cities: [ + { id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} }, + { id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} }, + ] as TurnWorldSnapshot['cities'], + nations: [ + { + id: 2, + name: '등용국', + color: '#222222', + typeCode: 'che_중립', + level: 1, + capitalCityId: 2, + chiefGeneralId: recruiter.id, + gold: 0, + rice: 0, + power: 0, + meta: { gennum: 1 }, + }, + ] as TurnWorldSnapshot['nations'], + map, + }); + const executor = await createImmediateGeneralActionExecutor({ + world: fixture.world, + reservedTurns: fixture.reservedTurns, + scenarioMeta: fixture.scenarioMeta, + map, + commandProfile: { + general: ['che_등용'], + nation: [], }, - ] as TurnWorldSnapshot['nations'], - map, - }); - const executor = await createImmediateGeneralActionExecutor({ - world: fixture.world, - reservedTurns: fixture.reservedTurns, - scenarioMeta: fixture.scenarioMeta, - map, - commandProfile: { - general: ['che_등용'], - nation: [], - }, - }); + }); - await expect( - executor.execute({ - actionKey: 'che_등용수락', - generalId: recipient.id, - rng: new RandUtil(new LiteHashDRBG('accept-recruitment-letter')), - args: { destNationId: 2, destGeneralId: recruiter.id }, - }) - ).resolves.toEqual({ ok: true }); - expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({ - nationId: 2, - cityId: 2, - officerLevel: 1, - lastTurn: originalLastTurn, - }); - expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({ - experience: recruiter.experience + 100, - dedication: recruiter.dedication + 100, - }); - const actionLogs = fixture.world - .consumeDirtyState() - .logs.filter((log) => log.scope === LogScope.GENERAL && log.category === LogCategory.ACTION); - expect(actionLogs.map((log) => log.text)).toEqual([ - expect.stringContaining('레벨업'), - expect.stringContaining('승급'), - expect.stringContaining('망명하여 수도로'), - expect.stringContaining('레벨업'), - expect.stringContaining('승급'), - expect.stringContaining('등용에 성공했습니다.'), - ]); - expect(actionLogs.map((log) => log.format)).toEqual([ - LogFormat.PLAIN, - LogFormat.PLAIN, - LogFormat.MONTH, - LogFormat.PLAIN, - LogFormat.PLAIN, - LogFormat.MONTH, - ]); - }); + fixture.world.updateGeneral(recruiter.id, { nationId: recruiterNationId }); + if (ruler) { + fixture.world.updateGeneral(recipient.id, { officerLevel: 12 }); + const before = fixture.world.captureState(); + await expect( + executor.execute({ + actionKey: 'che_등용수락', + generalId: recipient.id, + rng: new RandUtil(new LiteHashDRBG('reject-ruler-letter')), + args: { destNationId: 2, destGeneralId: recruiter.id }, + }) + ).resolves.toEqual({ ok: false, reason: '군주는 등용장을 수락할 수 없습니다 등용수락 실패.' }); + expect(fixture.world.captureState()).toEqual(before); + return; + } + + await expect( + executor.execute({ + actionKey: 'che_등용수락', + generalId: recipient.id, + rng: new RandUtil(new LiteHashDRBG('accept-recruitment-letter')), + args: { destNationId: 2, destGeneralId: recruiter.id }, + }) + ).resolves.toEqual({ ok: true }); + expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({ + nationId: 2, + cityId: 2, + officerLevel: 1, + lastTurn: originalLastTurn, + }); + expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({ + experience: recruiter.experience + 100, + dedication: recruiter.dedication + 100, + }); + const actionLogs = fixture.world + .consumeDirtyState() + .logs.filter((log) => log.scope === LogScope.GENERAL && log.category === LogCategory.ACTION); + expect(actionLogs.map((log) => log.text)).toEqual([ + expect.stringContaining('레벨업'), + expect.stringContaining('승급'), + expect.stringContaining('망명하여 수도로'), + expect.stringContaining('레벨업'), + expect.stringContaining('승급'), + expect.stringContaining('등용에 성공했습니다.'), + ]); + expect(actionLogs.map((log) => log.format)).toEqual([ + LogFormat.PLAIN, + LogFormat.PLAIN, + LogFormat.MONTH, + LogFormat.PLAIN, + LogFormat.PLAIN, + LogFormat.MONTH, + ]); + } + ); it('rejects recruitment-letter acceptance in a random-appointment-only world', async () => { const recipient = buildGeneral({