외교 제의 만료와 처리 완료를 삭제 표시에서 분리
This commit is contained in:
@@ -57,10 +57,17 @@ const formatMessageTime = (value: Date): string => {
|
|||||||
const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => {
|
const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => {
|
||||||
const payload = parsePayload(row.message);
|
const payload = parsePayload(row.message);
|
||||||
const actionStatus = typeof row.action_status === 'string' ? row.action_status : null;
|
const actionStatus = typeof row.action_status === 'string' ? row.action_status : null;
|
||||||
const actionUnavailable =
|
// 제의 종료는 본문 삭제가 아니다. 저장된 종료 상태를 기한 경과보다 우선한다.
|
||||||
actionStatus !== null &&
|
const actionState =
|
||||||
(actionStatus !== 'PENDING' ||
|
actionStatus === null
|
||||||
(row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick));
|
? null
|
||||||
|
: actionStatus === 'PENDING'
|
||||||
|
? row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick
|
||||||
|
? 'expired'
|
||||||
|
: 'pending'
|
||||||
|
: actionStatus === 'RESOLVED'
|
||||||
|
? 'resolved'
|
||||||
|
: 'unavailable';
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
msgType: row.type,
|
msgType: row.type,
|
||||||
@@ -68,8 +75,8 @@ const toMessageView = (row: MessageRow, currentGameTick: bigint | null): Message
|
|||||||
dest: row.type === 'public' ? null : payload.dest,
|
dest: row.type === 'public' ? null : payload.dest,
|
||||||
text: payload.text,
|
text: payload.text,
|
||||||
option:
|
option:
|
||||||
actionUnavailable && payload.option && typeof payload.option === 'object'
|
actionState !== null && payload.option && typeof payload.option === 'object'
|
||||||
? { ...payload.option, used: true, invalid: true }
|
? { ...payload.option, actionState, used: actionState !== 'pending' }
|
||||||
: (payload.option ?? null),
|
: (payload.option ?? null),
|
||||||
time: formatMessageTime(new Date(row.created_at_wall ?? row.time)),
|
time: formatMessageTime(new Date(row.created_at_wall ?? row.time)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -128,64 +128,69 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
expect(result.canRespondDiplomacy).toBe(false);
|
expect(result.canRespondDiplomacy).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redacts recent and old diplomacy content below secret permission 3', async () => {
|
it.each([null, 'RESOLVED'])(
|
||||||
const diplomacyRow = {
|
'redacts recent and old diplomacy content below secret permission 3 (%s)',
|
||||||
id: 19,
|
async (actionStatus) => {
|
||||||
mailbox: 9001,
|
const diplomacyRow = {
|
||||||
type: 'diplomacy',
|
id: 19,
|
||||||
src: 9002,
|
action_status: actionStatus,
|
||||||
dest: 9001,
|
expires_game_tick: null,
|
||||||
time: new Date(),
|
mailbox: 9001,
|
||||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
type: 'diplomacy',
|
||||||
message: {
|
src: 9002,
|
||||||
src: {
|
dest: 9001,
|
||||||
generalId: 8,
|
time: new Date(),
|
||||||
generalName: '외교관',
|
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||||
nationId: 2,
|
message: {
|
||||||
nationName: '촉',
|
src: {
|
||||||
color: '#000000',
|
generalId: 8,
|
||||||
icon: '',
|
generalName: '외교관',
|
||||||
|
nationId: 2,
|
||||||
|
nationName: '촉',
|
||||||
|
color: '#000000',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
dest: {
|
||||||
|
generalId: 0,
|
||||||
|
generalName: '',
|
||||||
|
nationId: 1,
|
||||||
|
nationName: '위',
|
||||||
|
color: '#ffffff',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
text: '보이면 안 되는 외교 본문',
|
||||||
|
option: { action: 'noAggression' },
|
||||||
},
|
},
|
||||||
dest: {
|
};
|
||||||
generalId: 0,
|
const queryRaw = vi.fn(async () => [diplomacyRow]);
|
||||||
generalName: '',
|
const { caller } = buildContext({
|
||||||
nationId: 1,
|
$queryRaw: queryRaw,
|
||||||
nationName: '위',
|
nation: {
|
||||||
color: '#ffffff',
|
findMany: vi.fn(async () => []),
|
||||||
icon: '',
|
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 recent = await caller.messages.getRecent({ generalId: general.id });
|
||||||
const old = await caller.messages.getOld({
|
const old = await caller.messages.getOld({
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
type: 'diplomacy',
|
type: 'diplomacy',
|
||||||
to: 20,
|
to: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(recent.permission).toBe(2);
|
expect(recent.permission).toBe(2);
|
||||||
expect(recent.diplomacy[0]).toMatchObject({
|
expect(recent.diplomacy[0]).toMatchObject({
|
||||||
text: '조회 권한이 없는 외교 메시지입니다.',
|
text: '조회 권한이 없는 외교 메시지입니다.',
|
||||||
option: { action: 'noAggression', permissionRedacted: true },
|
option: { action: 'noAggression', permissionRedacted: true },
|
||||||
});
|
});
|
||||||
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
|
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
|
||||||
expect(old.diplomacy[0]).toMatchObject({
|
expect(old.diplomacy[0]).toMatchObject({
|
||||||
text: '조회 권한이 없는 외교 메시지입니다.',
|
text: '조회 권한이 없는 외교 메시지입니다.',
|
||||||
option: { action: 'noAggression', permissionRedacted: true },
|
option: { action: 'noAggression', permissionRedacted: true },
|
||||||
});
|
});
|
||||||
expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid');
|
expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid');
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
|
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
|
||||||
const queryRaw = vi.fn(async () => [{ id: 51 }]);
|
const queryRaw = vi.fn(async () => [{ id: 51 }]);
|
||||||
|
|||||||
@@ -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<string, unknown>,
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,26 @@ const destination = computed<MessageTarget>(
|
|||||||
|
|
||||||
const invalid = computed(() => props.message.option?.invalid === true);
|
const invalid = computed(() => props.message.option?.invalid === true);
|
||||||
const permissionRedacted = computed(() => props.message.option?.permissionRedacted === 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 hasAction = computed(() => typeof props.message.option?.action === 'string');
|
||||||
const nationDirection = computed(() => {
|
const nationDirection = computed(() => {
|
||||||
if (props.message.src.nationId === destination.value.nationId) {
|
if (props.message.src.nationId === destination.value.nationId) {
|
||||||
@@ -275,10 +295,11 @@ onBeforeUnmount(() => {
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<strong v-if="permissionRedacted" class="permission-redacted-label">권한 제한</strong>
|
<strong v-if="permissionRedacted" class="permission-redacted-label">권한 제한</strong>
|
||||||
{{ invalid ? '삭제된 메시지입니다' : message.text }}
|
{{ permissionRedacted ? message.text : invalid ? '삭제된 메시지입니다' : message.text }}
|
||||||
|
<div v-if="actionStatusText" class="message-action-status">{{ actionStatusText }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="hasAction && !invalid" class="message-response">
|
<div v-if="hasAction && !invalid && !permissionRedacted && !actionUnavailable" class="message-response">
|
||||||
<button
|
<button
|
||||||
class="prompt-yes legacy-button legacy-button--primary"
|
class="prompt-yes legacy-button legacy-button--primary"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -365,6 +365,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
(message) =>
|
(message) =>
|
||||||
message.src.nationId !== nextMessages.nationId &&
|
message.src.nationId !== nextMessages.nationId &&
|
||||||
!message.option?.invalid &&
|
!message.option?.invalid &&
|
||||||
|
!message.option?.used &&
|
||||||
!message.option?.permissionRedacted
|
!message.option?.permissionRedacted
|
||||||
)
|
)
|
||||||
.reduce((latest, message) => Math.max(latest, message.id), 0);
|
.reduce((latest, message) => Math.max(latest, message.id), 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user