From 185057ea43f8e54bf1f86db01efe6ab24c41eafe Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:31:25 +0000 Subject: [PATCH 1/2] feat: complete in-game message parity --- app/game-api/src/messages/targets.ts | 3 +- app/game-api/src/router/messages/index.ts | 194 ++++++- app/game-api/test/messagesRouter.test.ts | 339 +++++++++++- .../src/components/main/MessagePanel.vue | 510 ++++++++++++------ .../src/components/main/MessagePlate.vue | 431 +++++++++++++++ app/game-frontend/src/stores/mainDashboard.ts | 162 +++++- app/game-frontend/src/views/MainView.vue | 83 +-- docs/architecture/todo.md | 4 +- .../ingame-message-parity.spec.ts | 384 +++++++++++++ .../instant-diplomacy-message.spec.ts | 35 +- .../playwright.config.mjs | 1 + .../reference-ingame-message.mjs | 178 ++++++ 12 files changed, 2080 insertions(+), 244 deletions(-) create mode 100644 app/game-frontend/src/components/main/MessagePlate.vue create mode 100644 tools/frontend-legacy-parity/ingame-message-parity.spec.ts create mode 100644 tools/frontend-legacy-parity/reference-ingame-message.mjs diff --git a/app/game-api/src/messages/targets.ts b/app/game-api/src/messages/targets.ts index 9c9f167..fa5c815 100644 --- a/app/game-api/src/messages/targets.ts +++ b/app/game-api/src/messages/targets.ts @@ -23,13 +23,14 @@ export const resolveNationInfo = async ( export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise => { const nation = await resolveNationInfo(db, general.nationId); + const picture = general.picture?.trim() || 'default.jpg'; return { generalId: general.id, generalName: general.name, nationId: general.nationId, nationName: nation.name, color: nation.color, - icon: '', + icon: general.imageServer ? `d_pic/${picture}` : `/image/icons/${picture}`, }; }; diff --git a/app/game-api/src/router/messages/index.ts b/app/game-api/src/router/messages/index.ts index 16a3dcf..b6a49aa 100644 --- a/app/game-api/src/router/messages/index.ts +++ b/app/game-api/src/router/messages/index.ts @@ -1,5 +1,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; +import { asRecord } from '@sammo-ts/common'; +import type { UserSanctions } from '@sammo-ts/common/auth/gameToken'; import { authedProcedure, router } from '../../trpc.js'; import { @@ -26,6 +28,75 @@ import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']); +const redactDiplomacyMessages = (messages: MessageView[], permission: number): MessageView[] => { + if (permission >= 3) { + return messages; + } + return messages.map((message) => { + if (!message.dest || message.dest.nationId === 0) { + return message; + } + return { + ...message, + text: '(외교 메시지입니다)', + option: { + ...(message.option ?? {}), + invalid: true, + }, + }; + }); +}; + +const isFutureDate = (value: string | undefined, now = Date.now()): boolean => { + if (!value) { + return false; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) && parsed > now; +}; + +const isMessageFeatureBlocked = (sanctions: UserSanctions, profileNames: string[]): boolean => { + if ( + isFutureDate(sanctions.mutedUntil) || + isFutureDate(sanctions.suspendedUntil) || + isFutureDate(sanctions.bannedUntil) + ) { + return true; + } + for (const profileName of profileNames) { + const restriction = sanctions.serverRestrictions?.[profileName]; + if (!restriction) { + continue; + } + if (restriction.until && !isFutureDate(restriction.until)) { + continue; + } + if (restriction.blockedFeatures?.includes('messages')) { + return true; + } + } + return false; +}; + +const readPenaltyNumber = (penalty: unknown, key: string, fallback: number): number => { + const value = asRecord(penalty)[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return fallback; +}; + +const hasPenalty = (penalty: unknown, key: string): boolean => { + const value = asRecord(penalty)[key]; + return value === true || value === 1 || value === '1'; +}; + export const messagesRouter = router({ getRecent: authedProcedure .input( @@ -85,11 +156,12 @@ export const messagesRouter = router({ : null, ]); + const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1; const messageBuckets: Record = { private: privateMessages, public: publicMessages, national: nationalMessages, - diplomacy: diplomacyMessages, + diplomacy: redactDiplomacyMessages(diplomacyMessages, permission), }; let nextSequence = sequence; @@ -128,10 +200,8 @@ export const messagesRouter = router({ sequence: nextSequence, nationId: nationId, generalName: general.name, - canRespondDiplomacy: - general.officerLevel > 4 && - nation !== null && - resolveNationPermission(general, nation.meta, false) >= 4, + permission, + canRespondDiplomacy: permission >= 4 && general.officerLevel > 4, latestRead: { diplomacy: readState?.latestDiplomacyMessage ?? 0, private: readState?.latestPrivateMessage ?? 0, @@ -178,6 +248,7 @@ export const messagesRouter = router({ ]; return { nation: nationList.map((nation) => ({ + nationId: nation.id, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + nation.id, name: nation.name, color: nation.color, @@ -234,14 +305,24 @@ export const messagesRouter = router({ if (message.payload.src.generalId !== general.id) { throw new TRPCError({ code: 'FORBIDDEN', message: '본인의 메시지만 삭제할 수 있습니다.' }); } - if (message.msgType === 'diplomacy' || message.payload.option?.deletable === false) { + if (message.msgType === 'diplomacy' && message.payload.option?.action) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '시스템 외교 메시지는 삭제할 수 없습니다.', + }); + } + if (message.payload.option?.deletable === false) { throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' }); } if (Date.now() - message.time.getTime() > 5 * 60 * 1000) { throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' }); } const receiverMessageId = message.payload.option?.receiverMessageID; - const ids = [message.id, ...(typeof receiverMessageId === 'number' ? [receiverMessageId] : [])]; + const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national'; + const ids = [ + message.id, + ...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []), + ]; await invalidateMessages(ctx.db, ids); return { ok: true, deletedIds: ids }; }), @@ -291,6 +372,14 @@ export const messagesRouter = router({ const general = await getOwnedGeneral(ctx, input.generalId); const nationId = general.nationId; + const nation = + nationId > 0 + ? await ctx.db.nation.findUnique({ + where: { id: nationId }, + select: { meta: true }, + }) + : null; + const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1; const mailboxes = { private: general.id, public: MESSAGE_MAILBOX_PUBLIC, @@ -312,7 +401,8 @@ export const messagesRouter = router({ toSeq: input.to, limit: 15, }); - messageBuckets[input.type] = messages; + messageBuckets[input.type] = + input.type === 'diplomacy' ? redactDiplomacyMessages(messages, permission) : messages; return { result: true, @@ -320,6 +410,7 @@ export const messagesRouter = router({ sequence: 0, nationId, generalName: general.name, + permission, ...messageBuckets, }; }), @@ -333,6 +424,12 @@ export const messagesRouter = router({ ) .mutation(async ({ ctx, input }) => { const general = await getOwnedGeneral(ctx, input.generalId); + if (!ctx.auth || isMessageFeatureBlocked(ctx.auth.sanctions, [ctx.profile.name, ctx.profile.id])) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: '메시지 전송이 제한된 계정입니다.', + }); + } const src = await buildTargetFromGeneral(ctx.db, general); const now = new Date(); @@ -340,28 +437,93 @@ export const messagesRouter = router({ let msgType: MessageType; let dest = src; + let receiverMailbox = input.mailbox; if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) { - msgType = 'public'; - } else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) { - const destNationId = input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE; - if (destNationId <= 0) { + if (hasPenalty(general.penalty, 'noSendPublicMsg')) { throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'Invalid nation mailbox.', + code: 'FORBIDDEN', + message: '공개 메세지를 보낼 수 없습니다.', }); } + msgType = 'public'; + } else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) { + const sourceNation = + general.nationId > 0 + ? await ctx.db.nation.findUnique({ + where: { id: general.nationId }, + select: { meta: true }, + }) + : null; + const permission = + general.nationId > 0 && sourceNation ? resolveNationPermission(general, sourceNation.meta) : -1; + const destNationId = permission < 4 ? general.nationId : input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE; const nationInfo = await resolveNationInfo(ctx.db, destNationId); + if (destNationId > 0) { + const destNation = await ctx.db.nation.findUnique({ where: { id: destNationId } }); + if (!destNation) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: '존재하지 않는 국가입니다.', + }); + } + } dest = buildNationTarget(destNationId, nationInfo.name, nationInfo.color); msgType = destNationId === general.nationId ? 'national' : 'diplomacy'; + receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + destNationId; } else if (input.mailbox > 0) { + if (hasPenalty(general.penalty, 'noSendPrivateMsg')) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: '개인 메세지를 보낼 수 없습니다.', + }); + } + const intervalSeconds = Math.max( + 0, + Math.ceil(readPenaltyNumber(general.penalty, 'sendPrivateMsgDelay', 2)) + ); + if (intervalSeconds > 0) { + const rateLimitKey = `game:${ctx.profile.name}:message:private:${ctx.auth.sessionId}`; + const acquired = await ctx.redis.set(rateLimitKey, '1', { + NX: true, + PX: intervalSeconds * 1000, + }); + if (acquired === null) { + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + message: `개인메세지는 ${intervalSeconds}초당 1건만 보낼 수 있습니다!`, + }); + } + } const destGeneral = await ctx.db.general.findUnique({ where: { id: input.mailbox }, }); if (!destGeneral) { throw new TRPCError({ code: 'NOT_FOUND', - message: 'Destination general not found.', + message: '존재하지 않는 유저입니다.', + }); + } + const [sourceNation, destNation] = await Promise.all([ + general.nationId > 0 + ? ctx.db.nation.findUnique({ where: { id: general.nationId }, select: { meta: true } }) + : null, + destGeneral.nationId > 0 + ? ctx.db.nation.findUnique({ where: { id: destGeneral.nationId }, select: { meta: true } }) + : null, + ]); + const sourcePermission = + sourceNation && general.nationId > 0 + ? resolveNationPermission(general, sourceNation.meta, false) + : -1; + const destPermission = + destNation && destGeneral.nationId > 0 + ? resolveNationPermission(destGeneral, destNation.meta, false) + : -1; + if (sourcePermission === 4 && destPermission === 4 && destGeneral.nationId !== general.nationId) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: '외교권자끼리는 메시지를 보낼 수 없습니다.', }); } dest = await buildTargetFromGeneral(ctx.db, destGeneral); @@ -394,7 +556,7 @@ export const messagesRouter = router({ await publishRealtimeEvent(ctx.redis, ctx.profile.name, { type: 'messageCreated', at: now.toISOString(), - mailbox: input.mailbox, + mailbox: receiverMailbox, msgType, messageId: result.receiverId, senderId: general.id, diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index 9ba2940..b96645c 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -30,7 +30,7 @@ const auth: GameSessionTokenPayload = { sanctions: {}, }; -const buildContext = (overrides: Record = {}) => { +const buildContext = (overrides: Record = {}, contextOverrides: Record = {}) => { const executeRaw = vi.fn(async () => 1); const updateMany = vi.fn(async () => ({ count: 1 })); const db = { @@ -55,11 +55,15 @@ const buildContext = (overrides: Record = {}) => { $executeRaw: executeRaw, ...overrides, }; + const redis = { + set: vi.fn(async () => 'OK'), + publish: vi.fn(async () => 1), + }; const context = { db, auth, profile: { id: 'che', scenario: 'default', name: 'che:default' }, - redis: {}, + redis, turnDaemon: {}, battleSim: {}, uploadDir: 'uploads', @@ -68,8 +72,9 @@ const buildContext = (overrides: Record = {}) => { accessTokenStore: {}, flushStore: {}, gameTokenSecret: 'test-secret', + ...contextOverrides, } as unknown as GameApiContext; - return { caller: appRouter.createCaller(context), db, executeRaw, updateMany }; + return { caller: appRouter.createCaller(context), db, executeRaw, updateMany, redis }; }; describe('messages router missing-flow compatibility', () => { @@ -99,6 +104,291 @@ describe('messages router missing-flow compatibility', () => { expect(result.canRespondDiplomacy).toBe(true); }); + it('lists an appointed ambassador as permission 4 but keeps responses limited to officers', async () => { + const ambassador = { + ...general, + officerLevel: 1, + meta: { permission: 'ambassador' }, + } as GeneralRow; + const { caller } = buildContext({ + general: { + findUnique: vi.fn(async () => ambassador), + findMany: vi.fn(async () => []), + }, + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async () => ({ meta: {} })), + }, + }); + + const result = await caller.messages.getRecent({ generalId: ambassador.id }); + + expect(result.permission).toBe(4); + 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: '', + }, + dest: { + generalId: 0, + generalName: '', + nationId: 1, + nationName: '위', + color: '#ffffff', + icon: '', + }, + 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, + }); + + expect(recent.permission).toBe(2); + expect(recent.diplomacy[0]).toMatchObject({ + text: '(외교 메시지입니다)', + option: { action: 'noAggression', invalid: true }, + }); + expect(old.diplomacy[0]).toMatchObject({ + text: '(외교 메시지입니다)', + option: { action: 'noAggression', invalid: true }, + }); + }); + + it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => { + const queryRaw = vi.fn(async () => [{ id: 51 }]); + const findNation = vi.fn(async ({ where }: { where: { id: number } }) => ({ + id: where.id, + name: where.id === 1 ? '위' : '촉', + color: '#112233', + meta: {}, + })); + const { caller } = buildContext({ + $queryRaw: queryRaw, + nation: { + findMany: vi.fn(async () => []), + findUnique: findNation, + }, + }); + + const result = await caller.messages.send({ + generalId: general.id, + mailbox: 9002, + text: '국가 메시지', + }); + + expect(result.msgType).toBe('national'); + expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national'])); + }); + + it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => { + const ambassador = { + ...general, + officerLevel: 1, + meta: { permission: 'ambassador' }, + } as GeneralRow; + const queryRaw = vi.fn(async () => [{ id: 52 }]); + const { caller } = buildContext({ + $queryRaw: queryRaw, + general: { + findUnique: vi.fn(async () => ambassador), + findMany: vi.fn(async () => []), + }, + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => ({ + id: where.id, + name: where.id === 1 ? '위' : '촉', + color: '#112233', + meta: {}, + })), + }, + }); + + const result = await caller.messages.send({ + generalId: ambassador.id, + mailbox: 9002, + text: '외교 메시지', + }); + + expect(result.msgType).toBe('diplomacy'); + expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy'])); + }); + + it('blocks private messages between foreign ambassadors', async () => { + const ambassador = { + ...general, + officerLevel: 1, + meta: { permission: 'ambassador' }, + } as GeneralRow; + const foreignAmbassador = { + ...ambassador, + id: 8, + userId: 'user-8', + name: '상대 외교관', + nationId: 2, + } as GeneralRow; + const { caller } = buildContext({ + general: { + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => + where.id === ambassador.id ? ambassador : foreignAmbassador + ), + findMany: vi.fn(async () => []), + }, + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => ({ + id: where.id, + name: where.id === 1 ? '위' : '촉', + color: '#112233', + meta: {}, + })), + }, + }); + + await expect( + caller.messages.send({ + generalId: ambassador.id, + mailbox: foreignAmbassador.id, + text: '개인 메시지', + }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + message: '외교권자끼리는 메시지를 보낼 수 없습니다.', + }); + }); + + it.each([ + ['public', { noSendPublicMsg: 1 }, 9999, '공개 메세지를 보낼 수 없습니다.'], + ['private', { noSendPrivateMsg: 1 }, 8, '개인 메세지를 보낼 수 없습니다.'], + ])('enforces the general %s-message penalty', async (_type, penalty, mailbox, message) => { + const penalized = { ...general, penalty } as GeneralRow; + const { caller } = buildContext({ + general: { + findUnique: vi.fn(async () => penalized), + findMany: vi.fn(async () => []), + }, + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#fff', meta: {} })), + }, + }); + + await expect( + caller.messages.send({ + generalId: penalized.id, + mailbox, + text: '차단 메시지', + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN', message }); + }); + + it('enforces the legacy private-message interval through Redis without touching lifecycle', async () => { + const redis = { + set: vi.fn(async () => null), + publish: vi.fn(async () => 1), + }; + const { caller } = buildContext( + { + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#fff', meta: {} })), + }, + }, + { redis } + ); + + await expect( + caller.messages.send({ + generalId: general.id, + mailbox: 8, + text: '너무 빠른 메시지', + }) + ).rejects.toMatchObject({ + code: 'TOO_MANY_REQUESTS', + message: '개인메세지는 2초당 1건만 보낼 수 있습니다!', + }); + }); + + it('blocks sends for a muted authenticated user independently of general permission', async () => { + const mutedAuth = { + ...auth, + sanctions: { mutedUntil: '2099-01-01T00:00:00.000Z' }, + }; + const { caller } = buildContext({}, { auth: mutedAuth }); + + await expect( + caller.messages.send({ + generalId: general.id, + mailbox: 9999, + text: '사용자 mute', + }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + message: '메시지 전송이 제한된 계정입니다.', + }); + }); + + it('rejects every remaining general-scoped message mutation for another user general', async () => { + const foreignGeneral = { ...general, userId: 'user-8' } as GeneralRow; + const { caller } = buildContext({ + general: { + findUnique: vi.fn(async () => foreignGeneral), + findMany: vi.fn(async () => []), + }, + }); + + await expect(caller.messages.getContacts({ generalId: foreignGeneral.id })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect( + caller.messages.readLatest({ + generalId: foreignGeneral.id, + type: 'private', + messageId: 1, + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect(caller.messages.delete({ generalId: foreignGeneral.id, messageId: 1 })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect( + caller.messages.respond({ + generalId: foreignGeneral.id, + messageId: 1, + response: true, + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + it('persists latest-read updates through the monotonic upsert', async () => { const { caller, executeRaw } = buildContext(); @@ -154,6 +444,49 @@ describe('messages router missing-flow compatibility', () => { }); }); + it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => { + const queryRaw = vi.fn(async () => [ + { + id: 25, + mailbox: 9001, + type: 'diplomacy', + src: 9001, + dest: 9002, + time: new Date(), + valid_until: new Date('9999-12-31T00:00:00Z'), + message: { + src: { + generalId: general.id, + generalName: general.name, + nationId: 1, + nationName: '위', + color: '#fff', + icon: '', + }, + dest: { + generalId: 0, + generalName: '', + nationId: 2, + nationName: '촉', + color: '#000', + icon: '', + }, + text: '일반 외교 메시지', + option: { receiverMessageID: 26 }, + }, + }, + ]); + const { caller, updateMany } = buildContext({ $queryRaw: queryRaw }); + + const result = await caller.messages.delete({ generalId: general.id, messageId: 25 }); + + expect(result.deletedIds).toEqual([25]); + expect(updateMany).toHaveBeenCalledWith({ + where: { id: { in: [25] } }, + data: { validUntil: expect.any(Date) }, + }); + }); + it('rejects deleting another general message', async () => { const queryRaw = vi.fn(async () => [ { diff --git a/app/game-frontend/src/components/main/MessagePanel.vue b/app/game-frontend/src/components/main/MessagePanel.vue index d97f9cd..2ab2bd1 100644 --- a/app/game-frontend/src/components/main/MessagePanel.vue +++ b/app/game-frontend/src/components/main/MessagePanel.vue @@ -1,13 +1,25 @@ diff --git a/app/game-frontend/src/components/main/MessagePlate.vue b/app/game-frontend/src/components/main/MessagePlate.vue new file mode 100644 index 0000000..fc665d0 --- /dev/null +++ b/app/game-frontend/src/components/main/MessagePlate.vue @@ -0,0 +1,431 @@ + + + + + diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 042482c..9f5803d 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -23,6 +23,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { type MapLayout = Awaited>; type CommandTable = Awaited>; type MessageBundle = Awaited>; + type MessageContacts = Awaited>; type BoardAccess = Awaited>; type ReservedTurnView = Awaited>[number]; @@ -37,12 +38,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const mapLayout = ref(null); const commandTable = ref(null); const messages = ref(null); + const messageContacts = ref(null); const boardAccess = ref(null); const reservedGeneralTurns = ref(null); const reservedNationTurns = ref(null); const messageDraftText = ref(''); const targetMailbox = ref(MESSAGE_MAILBOX_PUBLIC); + let initializedMailboxGeneralId: number | null = null; const general = computed(() => generalContext.value?.general ?? null); const city = computed(() => generalContext.value?.city ?? null); @@ -86,18 +89,85 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } as const; }); - const mailboxOptions = computed(() => { - const options: Array<{ label: string; value: number; disabled?: boolean }> = [ - { label: '공공', value: MESSAGE_MAILBOX_PUBLIC }, + const mailboxGroups = computed(() => { + type MailboxOption = { + label: string; + value: number; + disabled?: boolean; + color?: string; + }; + type MailboxGroup = { + label: string; + color?: string; + options: MailboxOption[]; + }; + + const ownNationId = general.value?.nationId ?? 0; + const ownMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + ownNationId; + const permission = messages.value?.permission ?? -1; + const contacts = messageContacts.value?.nation ?? []; + const ownNation = contacts.find((nation) => nation.mailbox === ownMailbox); + const groups: MailboxGroup[] = [ + { + label: '즐겨찾기', + color: '#000000', + options: [ + { + label: '【 아국 메세지 】', + value: ownMailbox, + color: ownNation?.color ?? '#000000', + }, + { + label: '【 전체 메세지 】', + value: MESSAGE_MAILBOX_PUBLIC, + color: '#000000', + }, + ], + }, ]; - if (nationId.value) { - options.push({ label: '국가', value: MESSAGE_MAILBOX_NATIONAL_BASE + nationId.value }); - } else { - options.push({ label: '국가', value: -1, disabled: true }); + + if (permission >= 4) { + groups.push({ + label: '외교메시지', + color: '#000000', + options: contacts + .filter((nation) => nation.mailbox !== ownMailbox && nation.nationId > 0) + .map((nation) => ({ + label: nation.name, + value: nation.mailbox, + color: nation.color, + })), + }); } - options.push({ label: '외교', value: -2, disabled: true }); - options.push({ label: '개인', value: -3, disabled: true }); - return options; + + const sortedContacts = [...contacts].sort((left, right) => { + if (left.mailbox === ownMailbox) return -1; + if (right.mailbox === ownMailbox) return 1; + return left.mailbox - right.mailbox; + }); + for (const nation of sortedContacts) { + const options = [...nation.general] + .filter(([id]) => id !== generalId.value) + .sort((left, right) => left[1].localeCompare(right[1], 'ko')) + .map(([id, name, flags]) => { + const ruler = Boolean(flags & 1); + const ambassador = Boolean(flags & 4); + return { + label: ruler ? `*${name}*` : ambassador ? `#${name}#` : name, + value: id, + disabled: permission === 4 && ambassador && nation.mailbox !== ownMailbox, + color: nation.color, + }; + }); + if (options.length > 0) { + groups.push({ + label: nation.name, + color: nation.color, + options, + }); + } + } + return groups; }); const statusLine = computed(() => { @@ -147,25 +217,32 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { context.general.nationId > 0 && context.general.officerLevel >= 5 ? trpc.turns.reserved.getNation.query({ generalId: id }) : Promise.resolve(null); - const [layout, lobby, map, commands, messageData, access, generalTurns, nationTurns] = await Promise.all([ - layoutPromise, - trpc.lobby.info.query(), - trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }), - trpc.turns.getCommandTable.query({ generalId: id }), - trpc.messages.getRecent.query({ generalId: id }), - trpc.board.getAccess.query(), - generalTurnsPromise, - nationTurnsPromise, - ]); + const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns] = + await Promise.all([ + layoutPromise, + trpc.lobby.info.query(), + trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }), + trpc.turns.getCommandTable.query({ generalId: id }), + trpc.messages.getRecent.query({ generalId: id }), + trpc.messages.getContacts.query({ generalId: id }), + trpc.board.getAccess.query(), + generalTurnsPromise, + nationTurnsPromise, + ]); mapLayout.value = layout; lobbyInfo.value = lobby; worldMap.value = map; commandTable.value = commands; messages.value = messageData; + messageContacts.value = contacts; boardAccess.value = access; reservedGeneralTurns.value = generalTurns; reservedNationTurns.value = nationTurns; + if (initializedMailboxGeneralId !== id) { + targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId; + initializedMailboxGeneralId = id; + } } catch (err) { error.value = resolveErrorMessage(err); } finally { @@ -200,12 +277,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } try { + messageDraftText.value = ''; await trpc.messages.send.mutate({ generalId: id, mailbox, text, }); - messageDraftText.value = ''; await refreshMessages(); } catch (err) { error.value = resolveErrorMessage(err); @@ -260,6 +337,44 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } }; + const readLatestMessage = async (type: 'private' | 'diplomacy', messageId: number) => { + const id = generalId.value; + if (!id || messageId <= 0) { + return; + } + try { + await trpc.messages.readLatest.mutate({ + generalId: id, + type, + messageId, + }); + if (messages.value) { + messages.value = { + ...messages.value, + latestRead: { + ...messages.value.latestRead, + [type]: Math.max(messages.value.latestRead[type], messageId), + }, + }; + } + } catch (err) { + error.value = resolveErrorMessage(err); + } + }; + + const deleteMessage = async (messageId: number) => { + const id = generalId.value; + if (!id) { + return; + } + try { + await trpc.messages.delete.mutate({ generalId: id, messageId }); + await refreshMessages(); + } catch (err) { + error.value = resolveErrorMessage(err); + } + }; + const setGeneralTurn = async (turnIndex: number, action: string) => { const id = generalId.value; if (!id) { @@ -484,12 +599,13 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { selectedCity, commandTable, messages, + messageContacts, boardAccess, reservedGeneralTurns, reservedNationTurns, messageDraftText, targetMailbox, - mailboxOptions, + mailboxGroups, statusLine, realtimeLabel, setRealtimeEnabled, @@ -498,6 +614,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { sendMessage, loadOlderMessages, respondToMessage, + readLatestMessage, + deleteMessage, setGeneralTurn, shiftGeneralTurns, setNationTurn, diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index c6e139c..5beb0e2 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -50,7 +50,7 @@ const { reservedNationTurns, messageDraftText, targetMailbox, - mailboxOptions, + mailboxGroups, statusLine, realtimeLabel, } = storeToRefs(dashboard); @@ -216,22 +216,26 @@ watch(
- - - +
@@ -251,22 +255,6 @@ watch(
세력 {{ lobbyInfo?.nationCnt ?? '-' }}
- - -
@@ -302,6 +290,26 @@ watch(
개인 기록 영역
+ @@ -396,6 +404,15 @@ button { gap: 16px; } +.desktop-message-panel { + grid-column: 1 / -1; +} + +.mobile-message-panel { + width: calc(100% + 48px); + margin-left: -24px; +} + .layout-mobile { display: flex; flex-direction: column; diff --git a/docs/architecture/todo.md b/docs/architecture/todo.md index 53fe248..db2881d 100644 --- a/docs/architecture/todo.md +++ b/docs/architecture/todo.md @@ -49,7 +49,7 @@ Move items into the main docs once they are finalized. - [AI suggestion] Define gateway login handoff + profile selection flow for the game frontend (token delivery, auto-login, cookie vs localStorage policy). - [AI suggestion] Implement Public 화면: 캐싱된 지도/중원정세/세력일람 + 제한된 장수일람 API/뷰. - [AI suggestion] Define main screen SSE contract + 실시간 동기화 토글 연동 (지도/명령/도시/국가/장수/메시지/동향/기록). -- [AI suggestion] Port legacy main UI components into `app/game-frontend` (MapViewer, CommandSelectForm, MessagePanel 등). +- [AI suggestion] Port remaining legacy main UI components into `app/game-frontend` (MessagePanel 이관 완료; 나머지 패널 추적). - [AI suggestion] Provide map city name/position data for MapViewer (API or scenario export) and replace placeholder layout. - [AI suggestion] Implement join/빙의 UI and post-creation refresh flow. - [AI suggestion] Build and maintain a legacy-to-SPA route mapping table with data requirements. @@ -57,7 +57,7 @@ Move items into the main docs once they are finalized. - [AI suggestion] Wire `realtimeEnabled` to an SSE or polling channel and update main dashboard data buckets (map/lobby/messages/commands). - [AI suggestion] Finalize static asset and web base URLs (`VITE_GAME_WEB_URL`, `VITE_GAME_ASSET_URL`) and document deployment mapping for legacy images. - [AI suggestion] Expand Join UI to cover inherit options (특기/도시/턴타임/보너스 스탯) using `join.getConfig` and `join.createGeneral` inputs. -- [AI suggestion] Extend MessagePanel to support private/diplomacy targets and surface sender/receiver metadata from message payloads. +- [x] Extend MessagePanel to support private/diplomacy targets and surface sender/receiver metadata from message payloads. - [AI suggestion] Port legacy TipTap-based editors (국가 방침/임관 권유) into game-frontend and reuse the new board image upload policy. ## Runtime and Operations (Lower Priority) diff --git a/tools/frontend-legacy-parity/ingame-message-parity.spec.ts b/tools/frontend-legacy-parity/ingame-message-parity.spec.ts new file mode 100644 index 0000000..726494a --- /dev/null +++ b/tools/frontend-legacy-parity/ingame-message-parity.spec.ts @@ -0,0 +1,384 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +import { canonicalFrontendFixture as fixture } from './fixtures/canonical'; + +const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'; +const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string) => ({ + error: { + message, + code: -32000, + data: { code: 'BAD_REQUEST', httpStatus: 400, path }, + }, +}); + +const operationNames = (route: Route): string[] => { + const pathname = new URL(route.request().url()).pathname; + return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const general = { + id: 1, + name: '테스트장수', + npcState: 0, + nationId: 1, + cityId: 1, + troopId: 0, + picture: 'default.jpg', + imageServer: 0, + officerLevel: 1, + stats: { leadership: 80, strength: 70, intelligence: 90 }, + gold: 1000, + rice: 1000, + crew: 500, + train: 100, + atmos: 100, + injury: 0, + experience: 1200, + dedication: 900, + items: { horse: null, weapon: null, book: null, item: null }, +}; + +const generalContext = { + general, + city: { + id: 1, + name: '낙양', + level: 7, + nationId: 1, + population: 50000, + agriculture: 5000, + commerce: 5000, + security: 5000, + defence: 5000, + wall: 5000, + supplyState: 1, + frontState: 2, + }, + nation: { + id: 1, + name: '테스트국', + color: '#d32f2f', + level: 5, + gold: 10000, + rice: 10000, + tech: 1200, + typeCode: 'che_군벌', + capitalCityId: 1, + }, + settings: {}, + penalties: {}, +}; + +const target = (generalId: number, generalName: string, nationId: number, nationName: string, color: string) => ({ + generalId, + generalName, + nationId, + nationName, + color, + icon: '/image/icons/default.jpg', +}); + +const ownTarget = target(1, '테스트장수', 1, '테스트국', '#d32f2f'); +const foreignTarget = target(8, '상대장수', 2, '상대국', '#2457a6'); +const messageTime = new Date().toISOString().replace('T', ' ').slice(0, 19); + +const buildMessages = (permission: number) => ({ + result: true, + public: [ + { + id: 101, + msgType: 'public', + src: ownTarget, + dest: null, + text: '전체 메시지 본문', + option: {}, + time: messageTime, + }, + ], + national: [ + { + id: 102, + msgType: 'national', + src: ownTarget, + dest: target(0, '', 1, '테스트국', '#d32f2f'), + text: '국가 메시지 본문', + option: {}, + time: messageTime, + }, + ], + private: [ + { + id: 103, + msgType: 'private', + src: foreignTarget, + dest: ownTarget, + text: '개인 메시지 본문', + option: {}, + time: messageTime, + }, + ], + diplomacy: [ + { + id: 104, + msgType: 'diplomacy', + src: foreignTarget, + dest: target(0, '', 1, '테스트국', '#d32f2f'), + text: permission >= 3 ? '외교 메시지 본문' : '(외교 메시지입니다)', + option: + permission >= 3 + ? { action: 'noAggression', deletable: false } + : { action: 'noAggression', deletable: false, invalid: true }, + time: messageTime, + }, + ], + sequence: 104, + nationId: 1, + generalName: general.name, + permission, + canRespondDiplomacy: permission >= 4 && general.officerLevel > 4, + latestRead: { private: 0, diplomacy: 0 }, +}); + +const contacts = { + nation: [ + { + nationId: 0, + mailbox: 9000, + name: '재야', + color: '#000000', + general: [], + }, + { + nationId: 1, + mailbox: 9001, + name: '테스트국', + color: '#d32f2f', + general: [ + [1, '테스트장수', 4], + [2, '아군군주', 1], + ], + }, + { + nationId: 2, + mailbox: 9002, + name: '상대국', + color: '#2457a6', + general: [ + [8, '상대외교관', 4], + [9, '상대일반', 0], + ], + }, + ], +}; + +const installFixture = async ( + page: Page, + options: { permission: number; sendError?: string } +): Promise> => { + const mutations: Array<{ operation: string; body: unknown }> = []; + await page.addInitScript( + ({ gameToken, profile }) => { + window.localStorage.setItem('sammo-game-token', gameToken); + window.localStorage.setItem('sammo-game-profile', profile); + }, + { + gameToken: fixture.game.session.gameToken, + profile: fixture.game.session.profile, + } + ); + await page.route('**/image/**', (route) => route.fulfill({ status: 204, body: '' })); + await page.route('**/che/api/events**', (route) => route.abort()); + await page.route('**/che/api/trpc/**', async (route) => { + const body = route.request().postDataJSON(); + const results = operationNames(route).map((operation) => { + if (operation === 'lobby.info') { + return response({ ...fixture.game.lobby, myGeneral: general }); + } + if (operation === 'general.me') return response(generalContext); + if (operation === 'world.getMapLayout') return response(fixture.game.mapLayout); + if (operation === 'world.getMap') { + return response({ ...fixture.game.map, myCity: 1, myNation: 1 }); + } + if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); + if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') { + return response([]); + } + if (operation === 'messages.getRecent') return response(buildMessages(options.permission)); + if (operation === 'messages.getContacts') return response(contacts); + if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true }); + if (operation === 'tournament.getState') return response({ stage: 0 }); + if ( + operation === 'messages.send' || + operation === 'messages.readLatest' || + operation === 'messages.delete' || + operation === 'messages.respond' + ) { + mutations.push({ operation, body }); + if (operation === 'messages.send' && options.sendError) { + return errorResponse(operation, options.sendError); + } + return response(operation === 'messages.respond' ? { result: true, reason: 'success' } : { ok: true }); + } + return errorResponse(operation, `Unhandled message fixture operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); + return mutations; +}; + +const openMessages = async (page: Page, viewport: { width: number; height: number }) => { + await page.setViewportSize(viewport); + await page.goto(`http://127.0.0.1:${gamePort}/che/`); + await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); + if (viewport.width <= 1024) { + await page.getByRole('button', { name: '메시지', exact: true }).click(); + } + await expect(page.locator('.MessagePanel')).toBeVisible(); +}; + +for (const viewport of [ + { width: 1000, height: 900 }, + { width: 500, height: 900 }, +]) { + test(`matches the reference message computed DOM at ${viewport.width}px Chromium viewport`, async ({ page }) => { + await installFixture(page, { permission: 4 }); + await openMessages(page, viewport); + const geometry = await page.locator('.MessagePanel').evaluate((panel) => { + const required = (selector: string) => panel.querySelector(selector)!; + const rect = (element: Element) => { + const box = element.getBoundingClientRect(); + return { x: box.x, y: box.y, width: box.width, height: box.height }; + }; + const panelStyle = getComputedStyle(panel); + const header = required('.BoardHeader'); + const plate = required('.msg-plate'); + const icon = required('.general-icon'); + return { + panel: rect(panel), + inputForm: rect(required('.MessageInputForm')), + select: rect(required('.message-select')), + input: rect(required('.message-text')), + submit: rect(required('.message-send')), + publicSection: rect(required('.PublicTalk')), + nationalSection: rect(required('.NationalTalk')), + firstHeader: rect(header), + firstPlate: rect(plate), + firstIcon: rect(icon), + computed: { + panelDisplay: panelStyle.display, + panelColumns: panelStyle.gridTemplateColumns, + panelFontSize: panelStyle.fontSize, + headerColor: getComputedStyle(header).color, + headerOutlineWidth: getComputedStyle(header).outlineWidth, + plateBackgroundColor: getComputedStyle(plate).backgroundColor, + plateFontSize: getComputedStyle(plate).fontSize, + plateMinHeight: getComputedStyle(plate).minHeight, + iconObjectFit: getComputedStyle(icon).objectFit, + }, + }; + }); + + expect(geometry.panel.x).toBeCloseTo(0, 0); + expect(geometry.panel.width).toBeCloseTo(viewport.width, 0); + expect(geometry.inputForm.width).toBeCloseTo(viewport.width, 0); + expect(geometry.select.height).toBeCloseTo(35.5, 0); + expect(geometry.submit.height).toBeCloseTo(35.5, 0); + expect(geometry.firstHeader.height).toBeCloseTo(25, 0); + expect(geometry.firstPlate.height).toBeGreaterThanOrEqual(64); + expect(geometry.firstIcon).toMatchObject({ width: 64, height: 64 }); + expect(geometry.computed).toMatchObject({ + panelFontSize: '14px', + headerColor: 'rgb(255, 255, 255)', + headerOutlineWidth: '1px', + plateBackgroundColor: 'rgb(20, 28, 101)', + plateFontSize: '12.5px', + plateMinHeight: '64px', + iconObjectFit: 'fill', + }); + + if (viewport.width === 1000) { + expect(geometry.computed.panelDisplay).toBe('grid'); + expect(geometry.computed.panelColumns).toBe('500px 500px'); + expect(geometry.select.width).toBeCloseTo(166.66, 0); + expect(geometry.input.width).toBeCloseTo(666.66, 0); + expect(geometry.submit.width).toBeCloseTo(166.66, 0); + expect(geometry.publicSection.width).toBeCloseTo(500, 0); + expect(geometry.nationalSection.x).toBeCloseTo(500, 0); + } else { + expect(geometry.computed.panelDisplay).toBe('block'); + expect(geometry.select.width).toBeCloseTo(250, 0); + expect(geometry.input.width).toBeCloseTo(500, 0); + expect(geometry.input.height).toBeCloseTo(33.5, 0); + expect(geometry.submit.width).toBeCloseTo(250, 0); + } + + const submit = page.locator('.message-send'); + await submit.hover(); + expect( + await submit.evaluate((element) => ({ + cursor: getComputedStyle(element).cursor, + backgroundColor: getComputedStyle(element).backgroundColor, + })) + ).toEqual({ cursor: 'pointer', backgroundColor: 'rgb(55, 90, 127)' }); + await submit.focus(); + expect( + await submit.evaluate((element) => ({ + outlineWidth: getComputedStyle(element).outlineWidth, + boxShadow: getComputedStyle(element).boxShadow, + })) + ).toEqual({ outlineWidth: '0px', boxShadow: 'none' }); + }); +} + +test('exposes ambassador targets, reply, read, delete, and successful send interactions', async ({ page }) => { + const mutations = await installFixture(page, { permission: 4 }); + await openMessages(page, { width: 500, height: 900 }); + + const select = page.getByLabel('메시지 수신 대상'); + await expect(select.locator('option[value="9002"]')).toHaveCount(1); + await expect(select.locator('option[value="8"]')).toBeDisabled(); + await expect(select.locator('option[value="9"]')).toBeEnabled(); + + await page.locator('.PrivateTalk .msg-target').filter({ hasText: '상대장수' }).click(); + await expect(select).toHaveValue('8'); + + await page.locator('.PrivateTalk').getByRole('button', { name: '모두 읽음' }).click(); + await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.readLatest').length).toBe(1); + + const deleteButton = page.locator('.PublicTalk .delete-message'); + page.once('dialog', (dialog) => dialog.accept()); + await deleteButton.click(); + await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.delete').length).toBe(1); + + await select.selectOption('9999'); + await page.getByLabel('메시지 입력').fill('전송 성공'); + await page.getByRole('button', { name: '서신전달&갱신' }).click(); + await expect(page.getByLabel('메시지 입력')).toHaveValue(''); + await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1); +}); + +test('redacts diplomacy for a low-permission general and preserves the failed-send error flow', async ({ page }) => { + const mutations = await installFixture(page, { + permission: 2, + sendError: '공개 메세지를 보낼 수 없습니다.', + }); + await openMessages(page, { width: 500, height: 900 }); + + const select = page.getByLabel('메시지 수신 대상'); + await expect(select.locator('option[value="9002"]')).toHaveCount(0); + await expect(page.locator('.DiplomacyTalk')).toContainText('삭제된 메시지입니다'); + await expect(page.locator('.DiplomacyTalk')).not.toContainText('외교 메시지 본문'); + await expect(page.locator('.DiplomacyTalk .message-response button').first()).toBeDisabled(); + + await select.selectOption('9999'); + await page.getByLabel('메시지 입력').fill('차단될 메시지'); + await page.getByRole('button', { name: '서신전달&갱신' }).click(); + await expect(page.getByLabel('메시지 입력')).toHaveValue(''); + await expect(page.locator('.error')).toHaveText('공개 메세지를 보낼 수 없습니다.'); + await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1); +}); diff --git a/tools/frontend-legacy-parity/instant-diplomacy-message.spec.ts b/tools/frontend-legacy-parity/instant-diplomacy-message.spec.ts index f15c6ef..dc526bf 100644 --- a/tools/frontend-legacy-parity/instant-diplomacy-message.spec.ts +++ b/tools/frontend-legacy-parity/instant-diplomacy-message.spec.ts @@ -5,6 +5,7 @@ import { resolve } from 'node:path'; import { canonicalFrontendFixture as fixture } from './fixtures/canonical'; const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR; +const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'; const response = (data: unknown) => ({ result: { data } }); const operationNames = (route: Route): string[] => { @@ -90,6 +91,7 @@ const messageBundle = (visible: boolean, canRespondDiplomacy = true) => ({ sequence: visible ? diplomacyMessage.id : -1, nationId: 1, generalName: general.name, + permission: canRespondDiplomacy ? 4 : 2, canRespondDiplomacy, latestRead: { diplomacy: 0, private: 0 }, }); @@ -131,6 +133,9 @@ const installFixture = async ( if (operation === 'messages.getRecent') { return response(messageBundle(visible, options.canRespondDiplomacy)); } + if (operation === 'messages.getContacts') return response({ nation: [] }); + if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true }); + if (operation === 'tournament.getState') return response({ stage: 0 }); if (operation === 'messages.respond') { mutations.push({ operation, body: requestBody }); if (options.acceptResponse) { @@ -151,9 +156,9 @@ const installFixture = async ( }; const openDiplomacyTab = async (page: Page) => { - await page.goto('http://127.0.0.1:15102/che/'); + await page.goto(`http://127.0.0.1:${gamePort}/che/`); await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); - await page.getByRole('button', { name: '외교', exact: true }).last().click(); + await expect(page.locator('.DiplomacyTalk')).toBeVisible(); await expect(page.getByText(diplomacyMessage.text)).toBeVisible(); }; @@ -186,20 +191,20 @@ test.describe('instant diplomacy response UI', () => { }); expect(geometry.buttons).toHaveLength(2); - expect(geometry.buttons[1]!.x - (geometry.buttons[0]!.x + geometry.buttons[0]!.width)).toBeCloseTo(4, 0); + expect(geometry.buttons[1]!.x - (geometry.buttons[0]!.x + geometry.buttons[0]!.width)).toBeCloseTo(0, 0); expect(geometry.buttons[0]).toMatchObject({ - color: 'rgb(143, 209, 143)', - fontSize: '11.2px', + color: 'rgb(255, 255, 255)', + fontSize: '12.5px', borderWidth: '1px', cursor: 'pointer', }); expect(geometry.buttons[1]).toMatchObject({ - color: 'rgb(224, 154, 154)', - fontSize: '11.2px', + color: 'rgb(255, 255, 255)', + fontSize: '12.5px', borderWidth: '1px', cursor: 'pointer', }); - expect(geometry.buttons.every((button) => button.height >= 22 && button.height <= 26)).toBe(true); + expect(geometry.buttons.every((button) => button.height >= 20 && button.height <= 22)).toBe(true); await decline.hover(); expect(await decline.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer'); @@ -234,18 +239,17 @@ test.describe('instant diplomacy response UI', () => { test('keeps the message and exposes a rejected response on mobile Chromium', async ({ page }) => { const mutations = await installFixture(page, { acceptResponse: false }); await page.setViewportSize({ width: 390, height: 844 }); - await page.goto('http://127.0.0.1:15102/che/'); + await page.goto(`http://127.0.0.1:${gamePort}/che/`); await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); await page.getByRole('button', { name: '메시지', exact: true }).click(); - await page.getByRole('button', { name: '외교', exact: true }).click(); const responseRow = page.locator('.message-response'); await expect(responseRow).toBeVisible(); const itemWidth = await page - .locator('.message-item') + .locator('.DiplomacyTalk .msg-plate') .evaluate((element) => element.getBoundingClientRect().width); - expect(itemWidth).toBeGreaterThan(320); - expect(itemWidth).toBeLessThanOrEqual(342); + expect(itemWidth).toBeGreaterThanOrEqual(389); + expect(itemWidth).toBeLessThanOrEqual(390); page.once('dialog', async (dialog) => { expect(dialog.message()).toBe('거절하시겠습니까?'); @@ -272,10 +276,9 @@ test.describe('instant diplomacy response UI', () => { canRespondDiplomacy: false, }); await page.setViewportSize({ width: 390, height: 844 }); - await page.goto('http://127.0.0.1:15102/che/'); + await page.goto(`http://127.0.0.1:${gamePort}/che/`); await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); await page.getByRole('button', { name: '메시지', exact: true }).click(); - await page.getByRole('button', { name: '외교', exact: true }).click(); const accept = page.locator('.message-response').getByRole('button', { name: '수락' }); await expect(accept).toBeDisabled(); @@ -284,7 +287,7 @@ test.describe('instant diplomacy response UI', () => { const style = getComputedStyle(element); return { cursor: style.cursor, opacity: style.opacity }; }) - ).toEqual({ cursor: 'not-allowed', opacity: '0.5' }); + ).toEqual({ cursor: 'not-allowed', opacity: '0.65' }); await accept.click({ force: true }); expect(mutations).toHaveLength(0); }); diff --git a/tools/frontend-legacy-parity/playwright.config.mjs b/tools/frontend-legacy-parity/playwright.config.mjs index eece2a1..b42bc03 100644 --- a/tools/frontend-legacy-parity/playwright.config.mjs +++ b/tools/frontend-legacy-parity/playwright.config.mjs @@ -12,6 +12,7 @@ export default defineConfig({ 'visual-parity.spec.ts', 'public-gaps.spec.ts', 'instant-diplomacy-message.spec.ts', + 'ingame-message-parity.spec.ts', 'tournament-betting.spec.ts', ], fullyParallel: false, diff --git a/tools/frontend-legacy-parity/reference-ingame-message.mjs b/tools/frontend-legacy-parity/reference-ingame-message.mjs new file mode 100644 index 0000000..45c147e --- /dev/null +++ b/tools/frontend-legacy-parity/reference-ingame-message.mjs @@ -0,0 +1,178 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +import { chromium } from '@playwright/test'; + +const baseUrl = process.env.REF_MESSAGE_URL ?? 'http://127.0.0.1:3400/sam/'; +const username = process.env.REF_MESSAGE_USER ?? 'refuser1'; +const passwordFile = process.env.REF_MESSAGE_PASSWORD_FILE; +const artifactRoot = process.env.REF_MESSAGE_ARTIFACT_DIR; + +if (!passwordFile) { + throw new Error('REF_MESSAGE_PASSWORD_FILE is required.'); +} + +const password = (await readFile(passwordFile, 'utf8')).trim(); + +const login = async (context, page) => { + await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 }); + const globalSalt = await page.locator('#global_salt').inputValue(); + const passwordHash = createHash('sha512') + .update(globalSalt + password + globalSalt) + .digest('hex'); + const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), { + data: { username, password: passwordHash }, + }); + const result = await response.json(); + if (!response.ok() || result.result !== true) { + throw new Error('Reference login failed.'); + } +}; + +const ensureGeneral = async (page) => { + await page.goto(new URL('hwe/index.php', baseUrl).toString(), { + waitUntil: 'networkidle', + timeout: 60_000, + }); + if (await page.locator('.MessagePanel').isVisible()) { + return; + } + + await page.goto(new URL('hwe/v_join.php', baseUrl).toString(), { + waitUntil: 'networkidle', + timeout: 60_000, + }); + const create = page.getByRole('button', { name: '장수 생성', exact: true }); + await create.waitFor({ state: 'visible', timeout: 30_000 }); + page.once('dialog', (dialog) => dialog.accept()); + await create.click(); + await page.locator('.MessagePanel').waitFor({ state: 'visible', timeout: 60_000 }); +}; + +const measure = async (browser, name, viewport) => { + const context = await browser.newContext({ + viewport, + deviceScaleFactor: 1, + colorScheme: 'dark', + locale: 'ko-KR', + timezoneId: 'UTC', + ignoreHTTPSErrors: true, + }); + try { + const page = await context.newPage(); + await login(context, page); + await ensureGeneral(page); + await page.locator('.MessagePanel').waitFor({ state: 'visible', timeout: 30_000 }); + await page.locator('.BoardHeader').first().waitFor({ state: 'visible' }); + const marker = `computed-dom-${name}-${Date.now()}`; + await page.locator('.MessageInputForm select').selectOption('9999'); + await page.locator('.MessageInputForm input').fill(marker); + await page.getByRole('button', { name: '서신전달&갱신' }).click(); + await page.getByText(marker, { exact: true }).waitFor({ state: 'visible', timeout: 30_000 }); + + if (artifactRoot) { + const path = resolve(artifactRoot, `message-ref-${name}.png`); + await mkdir(dirname(path), { recursive: true }); + await page.locator('.MessagePanel').screenshot({ + path, + animations: 'disabled', + }); + } + + const result = await page.evaluate(() => { + const rect = (element) => { + const box = element.getBoundingClientRect(); + return { + x: box.x, + y: box.y, + width: box.width, + height: box.height, + }; + }; + const required = (selector) => { + const element = document.querySelector(selector); + if (!element) throw new Error(`Missing reference selector: ${selector}`); + return element; + }; + const optionalRect = (selector) => { + const element = document.querySelector(selector); + return element ? rect(element) : null; + }; + const style = (selector) => getComputedStyle(required(selector)); + const input = required('.MessageInputForm input'); + const select = required('.MessageInputForm select'); + const submit = required('#msg_submit-col button'); + const firstPlate = document.querySelector('.msg_plate'); + const firstIcon = document.querySelector('.msg_plate .generalIcon'); + const panelStyle = style('.MessagePanel'); + const headerStyle = style('.BoardHeader'); + const plateStyle = firstPlate ? getComputedStyle(firstPlate) : null; + const iconStyle = firstIcon ? getComputedStyle(firstIcon) : null; + return { + panel: rect(required('.MessagePanel')), + inputForm: rect(required('.MessageInputForm')), + select: rect(select), + input: rect(input), + submit: rect(submit), + publicSection: rect(required('.PublicTalk')), + nationalSection: rect(required('.NationalTalk')), + privateSection: rect(required('.PrivateTalk')), + diplomacySection: rect(required('.DiplomacyTalk')), + firstHeader: rect(required('.BoardHeader')), + firstPlate: optionalRect('.msg_plate'), + firstIcon: optionalRect('.msg_plate .generalIcon'), + computed: { + panelDisplay: panelStyle.display, + panelColumns: panelStyle.gridTemplateColumns, + panelFontSize: panelStyle.fontSize, + headerColor: headerStyle.color, + headerOutlineWidth: headerStyle.outlineWidth, + headerBackgroundImage: headerStyle.backgroundImage, + plateBackgroundColor: plateStyle?.backgroundColor ?? null, + plateFontSize: plateStyle?.fontSize ?? null, + plateMinHeight: plateStyle?.minHeight ?? null, + iconObjectFit: iconStyle?.objectFit ?? null, + }, + }; + }); + + const submit = page.locator('#msg_submit-col button'); + await submit.hover(); + const hover = await submit.evaluate((element) => { + const style = getComputedStyle(element); + return { + cursor: style.cursor, + backgroundColor: style.backgroundColor, + }; + }); + await submit.focus(); + const focus = await submit.evaluate((element) => { + const style = getComputedStyle(element); + return { + outline: style.outline, + boxShadow: style.boxShadow, + }; + }); + const markerPlate = page.locator('.msg_plate').filter({ hasText: marker }); + const deleteButton = markerPlate.locator('.btn-delete-msg'); + if (await deleteButton.isVisible()) { + page.once('dialog', (dialog) => dialog.accept()); + await deleteButton.click(); + } + return { ...result, interaction: { hover, focus } }; + } finally { + await context.close(); + } +}; + +const browser = await chromium.launch({ headless: true }); +try { + const measurements = { + desktop: await measure(browser, 'desktop', { width: 1000, height: 900 }), + mobile: await measure(browser, 'mobile', { width: 500, height: 900 }), + }; + process.stdout.write(`${JSON.stringify(measurements, null, 2)}\n`); +} finally { + await browser.close(); +} From a0f2f431ec17302a8c24ed5cc634986978702570 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:35:27 +0000 Subject: [PATCH 2/2] test: stabilize message parity after main integration --- app/game-frontend/src/views/MainView.vue | 3 ++- tools/frontend-legacy-parity/instant-diplomacy-message.spec.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 84602cf..db7960f 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -414,7 +414,8 @@ button { } .mobile-message-panel { - width: calc(100% + 48px); + width: 100vw; + min-width: 0; margin-left: -24px; } diff --git a/tools/frontend-legacy-parity/instant-diplomacy-message.spec.ts b/tools/frontend-legacy-parity/instant-diplomacy-message.spec.ts index dc526bf..d4b5558 100644 --- a/tools/frontend-legacy-parity/instant-diplomacy-message.spec.ts +++ b/tools/frontend-legacy-parity/instant-diplomacy-message.spec.ts @@ -204,7 +204,7 @@ test.describe('instant diplomacy response UI', () => { borderWidth: '1px', cursor: 'pointer', }); - expect(geometry.buttons.every((button) => button.height >= 20 && button.height <= 22)).toBe(true); + expect(geometry.buttons.every((button) => button.height >= 22 && button.height <= 26)).toBe(true); await decline.hover(); expect(await decline.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');