From 957a59960837719a696dd45d1953d7d86e6ccf76 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 10 Sep 2026 06:03:47 +0000 Subject: [PATCH] =?UTF-8?q?=EC=99=B8=EA=B5=90=20=EC=A0=9C=EC=9D=98=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C=EC=99=80=20=EC=B2=98=EB=A6=AC=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=EB=A5=BC=20=EC=82=AD=EC=A0=9C=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/messages/store.ts | 19 ++- .../test/messageLifecycleView.test.ts | 82 +++++++++++++ app/game-api/test/messagesRouter.test.ts | 113 +++++++++--------- app/game-frontend/e2e/mainNavigation.spec.ts | 94 +++++++++++++++ .../src/components/main/MessagePlate.vue | 25 +++- app/game-frontend/src/stores/mainDashboard.ts | 1 + 6 files changed, 272 insertions(+), 62 deletions(-) create mode 100644 app/game-api/test/messageLifecycleView.test.ts diff --git a/app/game-api/src/messages/store.ts b/app/game-api/src/messages/store.ts index 3b885522..4b072a97 100644 --- a/app/game-api/src/messages/store.ts +++ b/app/game-api/src/messages/store.ts @@ -57,10 +57,17 @@ const formatMessageTime = (value: Date): string => { const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => { const payload = parsePayload(row.message); const actionStatus = typeof row.action_status === 'string' ? row.action_status : null; - const actionUnavailable = - actionStatus !== null && - (actionStatus !== 'PENDING' || - (row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick)); + // 제의 종료는 본문 삭제가 아니다. 저장된 종료 상태를 기한 경과보다 우선한다. + const actionState = + actionStatus === null + ? null + : actionStatus === 'PENDING' + ? row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick + ? 'expired' + : 'pending' + : actionStatus === 'RESOLVED' + ? 'resolved' + : 'unavailable'; return { id: row.id, msgType: row.type, @@ -68,8 +75,8 @@ const toMessageView = (row: MessageRow, currentGameTick: bigint | null): Message dest: row.type === 'public' ? null : payload.dest, text: payload.text, option: - actionUnavailable && payload.option && typeof payload.option === 'object' - ? { ...payload.option, used: true, invalid: true } + actionState !== null && payload.option && typeof payload.option === 'object' + ? { ...payload.option, actionState, used: actionState !== 'pending' } : (payload.option ?? null), time: formatMessageTime(new Date(row.created_at_wall ?? row.time)), }; diff --git a/app/game-api/test/messageLifecycleView.test.ts b/app/game-api/test/messageLifecycleView.test.ts new file mode 100644 index 00000000..3fc874c1 --- /dev/null +++ b/app/game-api/test/messageLifecycleView.test.ts @@ -0,0 +1,82 @@ +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { createGamePostgresConnector } from '@sammo-ts/infra'; +import { fetchMessagesFromMailbox, fetchOldMessagesFromMailbox } from '../src/messages/store.js'; + +vi.mock('../src/services/gameClock.js', () => ({ + loadCurrentGameTime: vi.fn(async () => ({ tick: 100 })), +})); + +// SQL is mocked; this client is never connected. Keep the real delegate types. +const connector = createGamePostgresConnector({ url: 'postgresql://localhost:1/message_lifecycle_unit' }); +const db = connector.prisma; +const queryRaw = vi.spyOn(db, '$queryRaw'); +afterAll(() => connector.disconnect()); + +const target = { generalId: 1, generalName: '장수', nationId: 1, nationName: '국가', color: '#000', icon: '' }; + +for (const older of [false, true]) { + describe(older ? 'older message lifecycle' : 'recent message lifecycle', () => { + it.each([ + ['PENDING', 101n, 'pending', false], + ['PENDING', 100n, 'expired', true], + ['PENDING', 99n, 'expired', true], + ['PENDING', null, 'pending', false], + ['RESOLVED', 101n, 'resolved', true], + ['RESOLVED', 99n, 'resolved', true], + ['CANCELLED', 101n, 'unavailable', true], + ['UNKNOWN', 101n, 'unavailable', true], + [null, null, undefined, undefined], + ])('preserves body for status %s and deadline %s', async (status, deadline, state, used) => { + const row = { + id: 10, + mailbox: 1, + type: 'diplomacy', + src: 2, + dest: 1, + time: new Date('0200-01-01T00:00:00Z'), + created_at_wall: new Date('2026-09-10T00:00:00Z'), + action_status: status, + expires_game_tick: deadline, + message: { + src: target, + dest: target, + text: '210년 1월까지 불가침 제의', + option: { action: 'noAggression' }, + }, + }; + queryRaw.mockResolvedValue([row]); + const options = { db, mailbox: 1, msgType: 'diplomacy' as const, limit: 20 }; + const result = older + ? await fetchOldMessagesFromMailbox({ ...options, toSeq: 11 }) + : await fetchMessagesFromMailbox({ ...options, fromSeq: 0 }); + expect(result[0]?.text).toBe(row.message.text); + expect(result[0]?.option?.invalid).toBeUndefined(); + expect(result[0]?.option?.actionState).toBe(state); + expect(result[0]?.option?.used).toBe(used); + }); + + it('keeps actual deletion authoritative even when an action remains', async () => { + queryRaw.mockResolvedValue([ + { + id: 10, + type: 'diplomacy', + time: new Date(), + created_at_wall: new Date(), + action_status: 'RESOLVED', + expires_game_tick: 99n, + message: { + src: target, + dest: target, + text: '삭제된 메시지입니다.', + option: { action: 'noAggression', invalid: true }, + }, + }, + ]); + const options = { db, mailbox: 1, msgType: 'diplomacy' as const, limit: 20 }; + const result = older + ? await fetchOldMessagesFromMailbox({ ...options, toSeq: 11 }) + : await fetchMessagesFromMailbox({ ...options, fromSeq: 0 }); + expect(result[0]).toMatchObject({ text: '삭제된 메시지입니다.', option: { invalid: true, used: true } }); + }); + }); +} diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index 90a6960f..39fd10fb 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -128,64 +128,69 @@ describe('messages router missing-flow compatibility', () => { expect(result.canRespondDiplomacy).toBe(false); }); - it('redacts recent and old diplomacy content below secret permission 3', async () => { - const diplomacyRow = { - id: 19, - mailbox: 9001, - type: 'diplomacy', - src: 9002, - dest: 9001, - time: new Date(), - valid_until: new Date('9999-12-31T00:00:00Z'), - message: { - src: { - generalId: 8, - generalName: '외교관', - nationId: 2, - nationName: '촉', - color: '#000000', - icon: '', + it.each([null, 'RESOLVED'])( + 'redacts recent and old diplomacy content below secret permission 3 (%s)', + async (actionStatus) => { + const diplomacyRow = { + id: 19, + action_status: actionStatus, + expires_game_tick: null, + mailbox: 9001, + type: 'diplomacy', + src: 9002, + dest: 9001, + time: new Date(), + valid_until: new Date('9999-12-31T00:00:00Z'), + message: { + src: { + generalId: 8, + generalName: '외교관', + nationId: 2, + nationName: '촉', + color: '#000000', + icon: '', + }, + dest: { + generalId: 0, + generalName: '', + nationId: 1, + nationName: '위', + color: '#ffffff', + icon: '', + }, + text: '보이면 안 되는 외교 본문', + option: { action: 'noAggression' }, }, - dest: { - generalId: 0, - generalName: '', - nationId: 1, - nationName: '위', - color: '#ffffff', - icon: '', + }; + const queryRaw = vi.fn(async () => [diplomacyRow]); + const { caller } = buildContext({ + $queryRaw: queryRaw, + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async () => ({ meta: {} })), }, - text: '보이면 안 되는 외교 본문', - option: { action: 'noAggression' }, - }, - }; - const queryRaw = vi.fn(async () => [diplomacyRow]); - const { caller } = buildContext({ - $queryRaw: queryRaw, - nation: { - findMany: vi.fn(async () => []), - findUnique: vi.fn(async () => ({ meta: {} })), - }, - }); + }); - const recent = await caller.messages.getRecent({ generalId: general.id }); - const old = await caller.messages.getOld({ - generalId: general.id, - type: 'diplomacy', - to: 20, - }); + const recent = await caller.messages.getRecent({ generalId: general.id }); + const old = await caller.messages.getOld({ + generalId: general.id, + type: 'diplomacy', + to: 20, + }); - expect(recent.permission).toBe(2); - expect(recent.diplomacy[0]).toMatchObject({ - text: '조회 권한이 없는 외교 메시지입니다.', - option: { action: 'noAggression', permissionRedacted: true }, - }); - expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid'); - expect(old.diplomacy[0]).toMatchObject({ - text: '조회 권한이 없는 외교 메시지입니다.', - option: { action: 'noAggression', permissionRedacted: true }, - }); - expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid'); - }); + expect(recent.permission).toBe(2); + expect(recent.diplomacy[0]).toMatchObject({ + text: '조회 권한이 없는 외교 메시지입니다.', + option: { action: 'noAggression', permissionRedacted: true }, + }); + expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid'); + expect(old.diplomacy[0]).toMatchObject({ + text: '조회 권한이 없는 외교 메시지입니다.', + option: { action: 'noAggression', permissionRedacted: true }, + }); + expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid'); + } + ); it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => { const queryRaw = vi.fn(async () => [{ id: 51 }]); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index c1f4eb4b..64781bbc 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -6545,3 +6545,97 @@ for (const role of [ }); } } + +for (const width of [1200, 390]) { + test(`message lifecycle states preserve body and prevent stale actions ${width}`, async ({ page }, testInfo) => { + const makeMessage = ( + id: number, + option: Record, + text = '210년 1월까지 불가침을 제의합니다.' + ) => ({ + ...privateMessage(id, 9), + msgType: 'diplomacy', + src: { ...privateMessage(id, 9).src, nationId: 2 }, + text, + option: { action: 'noAggression', ...option }, + }); + const state: NavigationFixture = { + officerLevel: 1, + permission: 4, + nationLevel: 3, + stage: 0, + npcMode: 1, + latestVote: null, + generalMeCalls: 0, + operations: [], + messages: { + ...emptyMessages(4), + nationId: 1, + diplomacy: [ + makeMessage(801, { actionState: 'expired', used: true }), + makeMessage(802, { actionState: 'resolved', used: true }), + makeMessage(803, { invalid: true }, '삭제된 메시지입니다.'), + makeMessage( + 804, + { actionState: 'expired', used: true, permissionRedacted: true }, + '조회 권한이 없는 외교 메시지입니다.' + ), + makeMessage(805, { actionState: 'expired', used: true, action: 'stopWar' }, '종전을 제의합니다.'), + makeMessage(806, { actionState: 'unavailable', used: true }), + ], + }, + }; + await installFixture(page, state); + await page.setViewportSize({ width, height: 900 }); + await waitForMain(page); + const mobileButton = page.getByRole('button', { name: '메시지', exact: true }); + if (await mobileButton.isVisible()) await mobileButton.click(); + const expected = [ + [801, '만료된 불가침메시지입니다'], + [802, '처리 완료된 메시지입니다'], + [803, '삭제된 메시지입니다'], + [804, '조회 권한이 없는 외교 메시지입니다.'], + [805, '만료된 종전 제의 메시지입니다'], + [806, '더 이상 응답할 수 없는 메시지입니다'], + ] as const; + for (const [id, label] of expected) { + const plate = page.locator(`.msg-plate[data-id="${id}"]:visible`).first(); + await expect(plate).toContainText(label); + await expect(plate.locator('.message-response')).toHaveCount(0); + if ([801, 802, 806].includes(id)) await expect(plate).toContainText('210년 1월까지 불가침을 제의합니다.'); + if (id !== 803) await expect(plate).not.toContainText('삭제된 메시지입니다'); + if (id === 804) await expect(plate.locator('.message-action-status')).toHaveCount(0); + } + await expect(page.getByTestId('diplomacy-message-notice')).toHaveCount(0); + await page.reload(); + if (await mobileButton.isVisible()) await mobileButton.click(); + await expect(page.locator('.msg-plate[data-id="801"]:visible').first()).toContainText( + '만료된 불가침메시지입니다' + ); + await page.evaluate(() => document.fonts.ready); + const plates = page.locator('.DiplomacyTalk .msg-plate:visible'); + const geometry = await plates.evaluateAll((elements) => + elements.map((el) => { + const r = el.getBoundingClientRect(); + return { + id: el.getAttribute('data-id'), + width: r.width, + height: r.height, + clientHeight: el.clientHeight, + scrollHeight: el.scrollHeight, + font: getComputedStyle(el).font, + html: el.outerHTML, + }; + }) + ); + expect(geometry.length).toBeGreaterThanOrEqual(6); + expect(geometry.every((item) => item.height >= 64 && item.scrollHeight <= item.clientHeight)).toBe(true); + await writeFile(testInfo.outputPath('lifecycle-geometry.json'), JSON.stringify(geometry, null, 2)); + await page + .locator('.msg-plate[data-id="801"]:visible') + .first() + .screenshot({ path: testInfo.outputPath('expired-message.png') }); + await page.screenshot({ path: testInfo.outputPath('lifecycle-page.png'), fullPage: true }); + expect(state.operations.filter((op) => op === 'messages.respond')).toHaveLength(0); + }); +} diff --git a/app/game-frontend/src/components/main/MessagePlate.vue b/app/game-frontend/src/components/main/MessagePlate.vue index aec76082..b8290fb8 100644 --- a/app/game-frontend/src/components/main/MessagePlate.vue +++ b/app/game-frontend/src/components/main/MessagePlate.vue @@ -56,6 +56,26 @@ const destination = computed( const invalid = computed(() => props.message.option?.invalid === true); const permissionRedacted = computed(() => props.message.option?.permissionRedacted === true); +const actionState = computed(() => props.message.option?.actionState); +const actionUnavailable = computed( + () => props.message.option?.used === true || (actionState.value != null && actionState.value !== 'pending') +); +const actionStatusText = computed(() => { + if (invalid.value || permissionRedacted.value || !actionUnavailable.value) return null; + if (actionState.value === 'expired') { + switch (props.message.option?.action) { + case 'noAggression': + return '만료된 불가침메시지입니다'; + case 'stopWar': + return '만료된 종전 제의 메시지입니다'; + case 'scout': + return '만료된 등용 권유 메시지입니다'; + default: + return '만료된 메시지입니다'; + } + } + return actionState.value === 'resolved' ? '처리 완료된 메시지입니다' : '더 이상 응답할 수 없는 메시지입니다'; +}); const hasAction = computed(() => typeof props.message.option?.action === 'string'); const nationDirection = computed(() => { if (props.message.src.nationId === destination.value.nationId) { @@ -275,10 +295,11 @@ onBeforeUnmount(() => { ]" > 권한 제한 - {{ invalid ? '삭제된 메시지입니다' : message.text }} + {{ permissionRedacted ? message.text : invalid ? '삭제된 메시지입니다' : message.text }} +
{{ actionStatusText }}
-
+