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/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index b4172b0..f91041d 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -203,6 +203,8 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => { initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1), baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0), baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0), + generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], 0), + generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], 500), maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0), }; }; 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-api/test/nationBettingRouter.integration.test.ts b/app/game-api/test/nationBettingRouter.integration.test.ts index b1f6832..8acbd73 100644 --- a/app/game-api/test/nationBettingRouter.integration.test.ts +++ b/app/game-api/test/nationBettingRouter.integration.test.ts @@ -14,8 +14,12 @@ const integration = describe.skipIf(!databaseUrl); const bettingId = 990_071; const concurrentBettingId = 990_072; const generalId = 9_971; +const otherGeneralId = 9_972; const nationId = 990_071; +const otherNationId = 990_072; const userId = 'nation-betting-router-user'; +const otherUserId = 'nation-betting-router-other-user'; +const noGeneralUserId = 'nation-betting-router-no-general-user'; const auth: GameSessionTokenPayload = { version: 1, @@ -32,12 +36,34 @@ const auth: GameSessionTokenPayload = { sanctions: {}, }; +const otherAuth: GameSessionTokenPayload = { + ...auth, + sessionId: 'nation-betting-router-other-session', + user: { + ...auth.user, + id: otherUserId, + username: 'other-bettor', + displayName: 'Other Bettor', + }, +}; + +const noGeneralAuth: GameSessionTokenPayload = { + ...auth, + sessionId: 'nation-betting-router-no-general-session', + user: { + ...auth.user, + id: noGeneralUserId, + username: 'no-general', + displayName: 'No General', + }, +}; + integration('nation betting router', () => { let db: GamePrismaClient; let closeDb: (() => Promise) | undefined; let worldStateId: number; - const buildContext = (requestId: string): GameApiContext => { + const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload | null = auth): GameApiContext => { const redisClient = { get: async () => null, set: async () => null, @@ -52,7 +78,7 @@ integration('nation betting router', () => { uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, - auth, + auth: actorAuth, accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:2'), flushStore: new InMemoryFlushStore(), gameTokenSecret: 'test-secret', @@ -64,33 +90,55 @@ integration('nation betting router', () => { await connector.connect(); db = connector.prisma; closeDb = () => connector.disconnect(); - await db.inputEvent.deleteMany({ where: { actorUserId: userId } }); + await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } }); await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } }); - await db.rankData.deleteMany({ where: { generalId } }); - await db.inheritanceLog.deleteMany({ where: { userId } }); - await db.inheritancePoint.deleteMany({ where: { userId } }); - await db.general.deleteMany({ where: { id: generalId } }); - await db.nation.deleteMany({ where: { id: nationId } }); + await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } }); + await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); + await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); + await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } }); + await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } }); - await db.nation.create({ - data: { - id: nationId, - name: '베팅국', - color: '#123456', - level: 2, - }, + await db.nation.createMany({ + data: [ + { + id: nationId, + name: '베팅국', + color: '#123456', + level: 2, + }, + { + id: otherNationId, + name: '다른베팅국', + color: '#654321', + level: 6, + }, + ], }); - await db.general.create({ - data: { - id: generalId, - userId, - name: '베팅장수', - nationId, - cityId: 1, - npcState: 0, - turnTime: new Date('0200-01-01T00:00:00.000Z'), - meta: {}, - }, + await db.general.createMany({ + data: [ + { + id: generalId, + userId, + name: '베팅장수', + nationId, + cityId: 1, + npcState: 0, + officerLevel: 0, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: {}, + }, + { + id: otherGeneralId, + userId: otherUserId, + name: '다른국가수뇌', + nationId: otherNationId, + cityId: 1, + npcState: 0, + officerLevel: 12, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: {}, + }, + ], }); const world = await db.worldState.create({ data: { @@ -132,19 +180,22 @@ integration('nation betting router', () => { candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }], }, }); - await db.inheritancePoint.create({ - data: { userId, key: 'previous', value: 1_000 }, + await db.inheritancePoint.createMany({ + data: [ + { userId, key: 'previous', value: 1_000 }, + { userId: otherUserId, key: 'previous', value: 500 }, + ], }); }); afterAll(async () => { - await db.inputEvent.deleteMany({ where: { actorUserId: userId } }); + await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } }); await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } }); - await db.rankData.deleteMany({ where: { generalId } }); - await db.inheritanceLog.deleteMany({ where: { userId } }); - await db.inheritancePoint.deleteMany({ where: { userId } }); - await db.general.deleteMany({ where: { id: generalId } }); - await db.nation.deleteMany({ where: { id: nationId } }); + await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } }); + await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); + await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); + await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } }); + await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } }); await db.worldState.delete({ where: { id: worldStateId } }); await closeDb?.(); }); @@ -229,12 +280,107 @@ integration('nation betting router', () => { }), ]); expect(results.map((result) => result.status).sort()).toEqual(['fulfilled', 'rejected']); - expect(await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } })) - .toMatchObject({ _sum: { amount: 600 } }); + expect( + await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } }) + ).toMatchObject({ _sum: { amount: 600 } }); expect( await db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } }, }) ).toMatchObject({ value: 250 }); }); + + it('requires authentication and an owned player general for every betting operation', async () => { + await expect( + appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({ + req: 'bettingNation', + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + await expect( + appRouter + .createCaller(buildContext('nation-betting-anonymous-detail', null)) + .betting.getDetail({ bettingId }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + await expect( + appRouter.createCaller(buildContext('nation-betting-anonymous-bet', null)).betting.bet({ + bettingId, + bettingType: [0], + amount: 10, + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + await expect( + appRouter.createCaller(buildContext('nation-betting-no-general-list', noGeneralAuth)).betting.getList({ + req: 'bettingNation', + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' }); + await expect( + appRouter + .createCaller(buildContext('nation-betting-no-general-detail', noGeneralAuth)) + .betting.getDetail({ bettingId }) + ).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' }); + await expect( + appRouter.createCaller(buildContext('nation-betting-no-general-bet', noGeneralAuth)).betting.bet({ + bettingId, + bettingType: [0], + amount: 10, + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' }); + }); + + it('allows generals across nation and office levels while isolating each session user bet', async () => { + await expect( + appRouter.createCaller(buildContext('nation-betting-other-list', otherAuth)).betting.getList({ + req: 'bettingNation', + }) + ).resolves.toMatchObject({ + result: true, + bettingList: { + [bettingId]: { name: '천통국 예상' }, + }, + }); + await expect( + appRouter.createCaller(buildContext('nation-betting-other-bet', otherAuth)).betting.bet({ + bettingId, + bettingType: [0], + amount: 100, + }) + ).resolves.toEqual({ result: true }); + + const [firstUserDetail, otherUserDetail] = await Promise.all([ + appRouter.createCaller(buildContext('nation-betting-first-user-detail')).betting.getDetail({ bettingId }), + appRouter + .createCaller(buildContext('nation-betting-other-user-detail', otherAuth)) + .betting.getDetail({ bettingId }), + ]); + expect(firstUserDetail.myBetting).toEqual([['[0]', 150]]); + expect(otherUserDetail.myBetting).toEqual([['[0]', 100]]); + expect(firstUserDetail.bettingDetail).toEqual([['[0]', 250]]); + expect(otherUserDetail.bettingDetail).toEqual([['[0]', 250]]); + + expect( + await db.nationBet.findUniqueOrThrow({ + where: { + bettingId_userId_selectionKey: { + bettingId, + userId: otherUserId, + selectionKey: '[0]', + }, + }, + }) + ).toMatchObject({ + generalId: otherGeneralId, + userId: otherUserId, + amount: 100, + }); + expect( + await db.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId: otherUserId, key: 'previous' } }, + }) + ).toMatchObject({ value: 400 }); + expect( + await db.rankData.findUniqueOrThrow({ + where: { generalId_type: { generalId: otherGeneralId, type: 'inherit_spent_dyn' } }, + }) + ).toMatchObject({ nationId: otherNationId, value: 100 }); + }); }); diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index bbc7a0a..b3d5691 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -35,6 +35,8 @@ const DEFAULT_INITIAL_NATION_GEN_LIMIT = 10; const DEFAULT_MAX_TECH_LEVEL = 12; const DEFAULT_BASE_GOLD = 0; const DEFAULT_BASE_RICE = 2000; +const DEFAULT_GENERAL_MINIMUM_GOLD = 0; +const DEFAULT_GENERAL_MINIMUM_RICE = 500; const DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10000; const normalizeCode = (value: string | null | undefined): string | null => { @@ -132,6 +134,8 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1), baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD), baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE), + generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], DEFAULT_GENERAL_MINIMUM_GOLD), + generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], DEFAULT_GENERAL_MINIMUM_RICE), maxResourceActionAmount: resolveNumber( constValues, ['maxResourceActionAmount'], 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 8459ebc..db7960f 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); @@ -221,22 +221,26 @@ watch(
- - - +
@@ -256,22 +260,6 @@ watch(
세력 {{ lobbyInfo?.nationCnt ?? '-' }}
- - -
@@ -307,6 +295,26 @@ watch(
개인 기록 영역
+ @@ -401,6 +409,16 @@ button { gap: 16px; } +.desktop-message-panel { + grid-column: 1 / -1; +} + +.mobile-message-panel { + width: 100vw; + min-width: 0; + margin-left: -24px; +} + .layout-mobile { display: flex; flex-direction: column; diff --git a/app/game-frontend/src/views/NationBettingView.vue b/app/game-frontend/src/views/NationBettingView.vue index c776908..c128315 100644 --- a/app/game-frontend/src/views/NationBettingView.vue +++ b/app/game-frontend/src/views/NationBettingView.vue @@ -73,9 +73,7 @@ const listYearMonth = computed(() => { }); const listItems = computed(() => - list.value - ? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id) - : [] + list.value ? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id) : [] ); const info = computed(() => detail.value?.bettingInfo ?? null); @@ -112,9 +110,7 @@ const detailRows = computed(() => const myBetMap = computed(() => new Map(detail.value?.myBetting ?? [])); -const totalAmount = computed(() => - (detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0) -); +const totalAmount = computed(() => (detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0)); const pureAmount = computed(() => (detail.value?.bettingDetail ?? []).reduce( @@ -137,9 +133,7 @@ const candidateAmounts = computed(() => { return result; }); -const usedAmount = computed(() => - Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0) -); +const usedAmount = computed(() => Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0)); const selectedKey = computed(() => JSON.stringify([...selectedCandidates.value].sort((a, b) => a - b))); @@ -150,10 +144,7 @@ const getErrorMessage = (error: unknown): string => { return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.'; }; -const parseYearMonth = (yearMonth: number): [number, number] => [ - Math.floor(yearMonth / 12), - (yearMonth % 12) + 1, -]; +const parseYearMonth = (yearMonth: number): [number, number] => [Math.floor(yearMonth / 12), (yearMonth % 12) + 1]; const readSelection = (value: string): number[] => { try { @@ -171,8 +162,7 @@ const selectionLabel = (value: string): string => .map((index) => candidates.value[index]?.title ?? '-') .join(', '); -const isListOpen = (item: BettingListItem): boolean => - !item.finished && listYearMonth.value <= item.closeYearMonth; +const isListOpen = (item: BettingListItem): boolean => !item.finished && listYearMonth.value <= item.closeYearMonth; const isDetailOpen = computed(() => Boolean(info.value && !info.value.finished && currentYearMonth.value <= info.value.closeYearMonth) @@ -192,19 +182,72 @@ const rowColor = (key: string): string => { return matched === 0 ? 'red' : matched < info.value.selectCnt ? 'yellow' : 'green'; }; -const expectedMultiplier = (key: string, betAmount: number): string => { - if (betAmount <= 0) { - return '0.0'; +const rewardByMatch = computed(() => { + const selectCount = info.value?.selectCnt ?? 0; + const rewards = new Array(selectCount + 1).fill(0); + if (selectCount <= 0) { + return rewards; } + const amountByMatch = new Map(); + for (const [key, betAmount] of detailRows.value) { + const matched = matchCount(key); + amountByMatch.set(matched, (amountByMatch.get(matched) ?? 0) + betAmount); + } + if (selectCount === 1 || info.value?.isExclusive) { + rewards[selectCount] = totalAmount.value; + return rewards; + } + + let remainingReward = totalAmount.value; + let accumulatedReward = 0; + let nextReward = totalAmount.value; + for (let matched = selectCount; matched > 0; matched -= 1) { + nextReward /= 2; + accumulatedReward += nextReward; + if (!amountByMatch.has(matched)) { + continue; + } + rewards[matched] = accumulatedReward; + remainingReward -= accumulatedReward; + accumulatedReward = 0; + } + for (let matched = selectCount; matched >= 0; matched -= 1) { + if (!amountByMatch.has(matched)) { + continue; + } + rewards[matched] += remainingReward; + break; + } + return rewards; +}); + +const expectedReward = (key: string): number => { if (!info.value?.finished) { - const reward = info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2; - return (reward / betAmount).toFixed(1); + return info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2; } - const matched = matchCount(key); - const matchedAmount = detailRows.value - .filter(([candidateKey]) => matchCount(candidateKey) === matched) - .reduce((sum, [, value]) => sum + value, 0); - return matchedAmount > 0 ? (totalAmount.value / matchedAmount).toFixed(1) : '0.0'; + return rewardByMatch.value[matchCount(key)] ?? 0; +}; + +const rewardDivisor = (key: string, betAmount: number): number => + info.value?.finished + ? detailRows.value + .filter(([candidateKey]) => matchCount(candidateKey) === matchCount(key)) + .reduce((sum, [, value]) => sum + value, 0) + : betAmount; + +const expectedMultiplier = (key: string, betAmount: number): string => { + const divisor = rewardDivisor(key, betAmount); + return divisor > 0 ? (expectedReward(key) / divisor).toFixed(1) : '0.0'; +}; + +const myExpectedReward = (key: string, betAmount: number): string => { + const myAmount = myBetMap.value.get(key); + if (myAmount === undefined) { + return ''; + } + const divisor = rewardDivisor(key, betAmount); + const reward = divisor > 0 ? (myAmount * expectedReward(key)) / divisor : 0; + return `(${myAmount.toLocaleString('ko-KR')} -> ${reward.toFixed(1)})`; }; const loadList = async () => { @@ -295,7 +338,7 @@ onMounted(() => {
돌아가기 - +

국가 베팅장

@@ -309,32 +352,37 @@ onMounted(() => { {{ info.name }} (종료) - ({{ parseYearMonth(info.closeYearMonth)[0] }}년 - {{ parseYearMonth(info.closeYearMonth)[1] }}월까지) + ({{ parseYearMonth(info.closeYearMonth)[0] }}년 {{ parseYearMonth(info.closeYearMonth)[1] }}월까지) (베팅 마감) (총액: {{ totalAmount.toLocaleString('ko-KR') }})
- + +
@@ -361,7 +409,7 @@ onMounted(() => { {{ selectionLabel(key) }}
{{ betAmount.toLocaleString('ko-KR') }}
-
{{ myBetMap.get(key)?.toLocaleString('ko-KR') ?? '' }}
+
{{ myExpectedReward(key, betAmount) }}
{{ expectedMultiplier(key, betAmount) }}배
@@ -382,8 +430,7 @@ onMounted(() => { {{ item.name }} (종료) - ({{ parseYearMonth(item.closeYearMonth)[0] }}년 - {{ parseYearMonth(item.closeYearMonth)[1] }}월까지) + ({{ parseYearMonth(item.closeYearMonth)[0] }}년 {{ parseYearMonth(item.closeYearMonth)[1] }}월까지) (베팅 마감) @@ -400,12 +447,11 @@ onMounted(() => { .nation-betting-page { position: relative; width: 500px; - min-height: 100vh; margin: 0 auto; color: #fff; font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic'; font-size: 14px; - line-height: 1.3; + line-height: 1.5; overflow-x: hidden; } @@ -458,19 +504,32 @@ onMounted(() => { } .section-title { - min-height: 22px; + min-height: 21px; text-align: center; - line-height: 22px; + line-height: 21px; } .betting-candidates { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 4px; - padding: 4px; + display: flex; + flex-wrap: wrap; + margin-top: -3.5px; + margin-right: -1.75px; + margin-left: -1.75px; +} + +.betting-candidate-cell { + flex: 0 0 auto; + width: 33.33333333%; + max-width: 100%; + padding-right: 1.75px; + padding-left: 1.75px; + margin-top: 3.5px; } .betting-candidate { + width: 100%; + height: 100%; + min-height: 143px; min-width: 0; padding: 0; border: 1px solid gray; @@ -530,7 +589,7 @@ onMounted(() => { .betting-form input { grid-column: span 4; min-width: 0; - height: 30px; + height: 35.5px; border: 1px solid #777; background: #ddd; color: #303030; @@ -538,10 +597,11 @@ onMounted(() => { .betting-form button { grid-column: span 2; + height: 35.5px; } .payout-table { - margin-top: 6px; + margin-top: 0; } .payout-row { @@ -551,7 +611,7 @@ onMounted(() => { .payout-row > div { min-width: 0; - padding: 2px 4px; + padding: 0 4px; } .payout-row > div:not(:first-child) { @@ -572,7 +632,7 @@ onMounted(() => { .betting-item { display: block; - width: 100%; + width: auto; margin: 0.25em; border: 0; background: transparent; @@ -595,6 +655,7 @@ onMounted(() => { .betting-footer .legacy-nav-button { width: 90px; + height: 35.5px; } .betting-notice, @@ -602,6 +663,15 @@ onMounted(() => { padding: 6px 10px; } +.betting-notice { + position: fixed; + top: 8px; + right: 8px; + z-index: 20; + width: min(320px, calc(100vw - 16px)); + background: #303030; +} + .betting-notice.error { border: 1px solid #9b4848; color: #ffd0d0; @@ -617,8 +687,9 @@ onMounted(() => { width: 1000px; } - .betting-candidates { - grid-template-columns: repeat(6, minmax(0, 1fr)); + .betting-candidate-cell { + /* Legacy Bootstrap switches .col-4 to .col-lg-2 at 940px. */ + width: 16.66666667%; } .betting-form { diff --git a/docs/architecture/general-command-differential-testing.md b/docs/architecture/general-command-differential-testing.md index ac900c8..17f02a8 100644 --- a/docs/architecture/general-command-differential-testing.md +++ b/docs/architecture/general-command-differential-testing.md @@ -33,6 +33,9 @@ ref `next_execute` KV와 core general meta의 공통 projection으로 비교한 존재하지 않는 대상 경계 4개는 증여·등용의 장수 ID와 첩보·이동의 도시 ID를 고정해 원 명령 미완료, 휴식 fallback, RNG 무소비와 semantic delta를 비교한다. +자원 인자·보유량 경계 13개는 증여·헌납·군량매매의 100단위 반올림과 +100..max clamp 9개, 헌납의 보유량보다 큰 요청·최소 쌀 미달 2개, +증여의 최소 쌀 보존·자기 자신 거부 2개를 비교한다. 필수 인자 객체 자체를 생략한 요청은 ref runner가 제한 시간 안에 종료되지 않아 동적 호환 판정에서 제외한다. 나머지 명령별 제약 실패·값 경계와 전체 core PostgreSQL 재조회가 완료 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/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index 52d3154..f7d66b6 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -214,6 +214,11 @@ Four missing-target cases cover nonexistent general IDs for gift and employment plus nonexistent city IDs for spying and movement. Both engines reject the requested command, execute rest without command RNG, and produce the same semantic state delta. +Thirteen resource argument and balance cases cover 100-unit rounding and +minimum/maximum clamps for gift, donation, and rice trade; donation against +available and minimum resources; and gift reserve and self-target rejection. +They compare normalized last-turn arguments, RNG, fallback, and semantic state +deltas. Requests that omit the required argument object remain unverified because the reference runner did not terminate within the bounded comparison run. This is not yet a claim that every command-specific diff --git a/packages/logic/src/actions/turn/commandEnv.ts b/packages/logic/src/actions/turn/commandEnv.ts index 4e12491..01c8ebc 100644 --- a/packages/logic/src/actions/turn/commandEnv.ts +++ b/packages/logic/src/actions/turn/commandEnv.ts @@ -51,6 +51,8 @@ export interface TurnCommandEnv { initialAllowedTechLevel?: number; baseGold: number; baseRice: number; + generalMinimumGold?: number; + generalMinimumRice?: number; maxResourceActionAmount: number; itemCatalog?: Record; generalActionModules?: Array; diff --git a/packages/logic/src/actions/turn/general/che_군량매매.ts b/packages/logic/src/actions/turn/general/che_군량매매.ts index 5bce071..957d7f2 100644 --- a/packages/logic/src/actions/turn/general/che_군량매매.ts +++ b/packages/logic/src/actions/turn/general/che_군량매매.ts @@ -16,6 +16,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; +import { normalizeResourceActionAmount } from './resourceAmount.js'; export interface TradeEnvironment { exchangeFee?: number; @@ -46,8 +47,10 @@ export class ActionDefinition< if (!parsed || !Number.isFinite(parsed.amount)) { return null; } - const maxAmount = this.env.maxResourceActionAmount ?? 10_000; - const amount = Math.max(100, Math.min(Math.round(parsed.amount / 100) * 100, maxAmount)); + const amount = normalizeResourceActionAmount(parsed.amount, this.env.maxResourceActionAmount ?? 10_000); + if (amount === null) { + return null; + } return { buyRice: parsed.buyRice, amount }; } diff --git a/packages/logic/src/actions/turn/general/che_증여.ts b/packages/logic/src/actions/turn/general/che_증여.ts index 32eac34..2e3d6fe 100644 --- a/packages/logic/src/actions/turn/general/che_증여.ts +++ b/packages/logic/src/actions/turn/general/che_증여.ts @@ -1,6 +1,7 @@ import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { + denyWithReason, existsDestGeneral, friendlyDestGeneral, notBeNeutral, @@ -19,15 +20,13 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; +import { normalizeResourceActionAmount } from './resourceAmount.js'; const ACTION_NAME = '증여'; const ACTION_KEY = 'che_증여'; const ARGS_SCHEMA = z.object({ isGold: z.boolean(), - amount: z.preprocess( - (value) => (typeof value === 'number' ? Math.floor(value / 100) * 100 : value), - z.number().int().positive() - ), + amount: z.number(), destGeneralID: z.number().int().positive(), }); export type GiftArgs = z.infer; @@ -51,10 +50,13 @@ export class ActionDefinition< if (!parsed) { return null; } - const maxAmount = this.env.maxResourceActionAmount > 0 ? this.env.maxResourceActionAmount : 10000; + const amount = normalizeResourceActionAmount(parsed.amount, this.env.maxResourceActionAmount); + if (amount === null) { + return null; + } return { ...parsed, - amount: Math.max(100, Math.min(parsed.amount, maxAmount)), + amount, }; } @@ -62,9 +64,12 @@ export class ActionDefinition< return [notBeNeutral(), occupiedCity(), suppliedCity()]; } - buildConstraints(_ctx: ConstraintContext, args: GiftArgs): Constraint[] { - const minGold = this.env.baseGold > 0 ? this.env.baseGold : 1000; - const minRice = this.env.baseRice > 0 ? this.env.baseRice : 1000; + buildConstraints(ctx: ConstraintContext, args: GiftArgs): Constraint[] { + if (ctx.actorId === args.destGeneralID) { + return [denyWithReason('본인입니다')]; + } + const minGold = this.env.generalMinimumGold ?? 0; + const minRice = this.env.generalMinimumRice ?? 500; return [ notBeNeutral(), @@ -83,8 +88,8 @@ export class ActionDefinition< throw new Error('증여 대상 장수가 없습니다.'); } - const minGold = this.env.baseGold > 0 ? this.env.baseGold : 1000; - const minRice = this.env.baseRice > 0 ? this.env.baseRice : 1000; + const minGold = this.env.generalMinimumGold ?? 0; + const minRice = this.env.generalMinimumRice ?? 500; const resKey = args.isGold ? 'gold' : 'rice'; const resName = args.isGold ? '금' : '쌀'; diff --git a/packages/logic/src/actions/turn/general/che_헌납.ts b/packages/logic/src/actions/turn/general/che_헌납.ts index 1ae132d..8f7849d 100644 --- a/packages/logic/src/actions/turn/general/che_헌납.ts +++ b/packages/logic/src/actions/turn/general/che_헌납.ts @@ -21,6 +21,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; +import { normalizeResourceActionAmount } from './resourceAmount.js'; const ACTION_NAME = '헌납'; const ACTION_KEY = 'che_헌납'; @@ -96,12 +97,23 @@ export class ActionDefinition< public readonly name = ACTION_NAME; private readonly resolver: ActionResolver; - constructor() { + constructor(private readonly env: TurnCommandEnv) { this.resolver = new ActionResolver(); } parseArgs(raw: unknown): DonateArgs | null { - return parseArgsWithSchema(ARGS_SCHEMA, raw); + const parsed = parseArgsWithSchema(ARGS_SCHEMA, raw); + if (!parsed) { + return null; + } + const amount = normalizeResourceActionAmount(parsed.amount, this.env.maxResourceActionAmount); + if (amount === null) { + return null; + } + return { + ...parsed, + amount, + }; } buildMinConstraints(_ctx: ConstraintContext, _args: DonateArgs): Constraint[] { @@ -109,10 +121,12 @@ export class ActionDefinition< } buildConstraints(_ctx: ConstraintContext, args: DonateArgs): Constraint[] { + const minGold = this.env.generalMinimumGold ?? 0; + const minRice = this.env.generalMinimumRice ?? 500; if (args.isGold) { - return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => args.amount)]; + return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => minGold)]; } - return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralRice(() => args.amount)]; + return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralRice(() => minRice)]; } resolve(context: GeneralActionResolveContext, args: DonateArgs): GeneralActionOutcome { @@ -128,5 +142,5 @@ export const commandSpec: GeneralTurnCommandSpec = { reqArg: true, availabilityArgs: { isGold: true, amount: 0 }, argsSchema: ARGS_SCHEMA, - createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/resourceAmount.ts b/packages/logic/src/actions/turn/general/resourceAmount.ts new file mode 100644 index 0000000..9742a82 --- /dev/null +++ b/packages/logic/src/actions/turn/general/resourceAmount.ts @@ -0,0 +1,14 @@ +const DEFAULT_MIN_RESOURCE_ACTION_AMOUNT = 100; +const DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10_000; +const RESOURCE_ACTION_AMOUNT_UNIT = 100; + +export const normalizeResourceActionAmount = (amount: number, configuredMaxAmount: number): number | null => { + if (!Number.isFinite(amount)) { + return null; + } + + const maxAmount = configuredMaxAmount > 0 ? configuredMaxAmount : DEFAULT_MAX_RESOURCE_ACTION_AMOUNT; + const roundedAmount = Math.round(amount / RESOURCE_ACTION_AMOUNT_UNIT) * RESOURCE_ACTION_AMOUNT_UNIT; + + return Math.max(DEFAULT_MIN_RESOURCE_ACTION_AMOUNT, Math.min(roundedAmount, maxAmount)); +}; 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..d4b5558 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,16 +191,16 @@ 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', }); @@ -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/nation-betting-ref-measure.mjs b/tools/frontend-legacy-parity/nation-betting-ref-measure.mjs new file mode 100644 index 0000000..4a951d6 --- /dev/null +++ b/tools/frontend-legacy-parity/nation-betting-ref-measure.mjs @@ -0,0 +1,237 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { chromium } from '@playwright/test'; + +const baseUrl = process.env.REF_NATION_BETTING_URL ?? 'https://dev-sam-ref.hided.net/sam/'; +const staticBaseUrl = process.env.REF_NATION_BETTING_STATIC_BASE_URL; +const username = process.env.REF_NATION_BETTING_USER ?? 'refuser1'; +const passwordFile = process.env.REF_NATION_BETTING_PASSWORD_FILE; +const artifactRoot = resolve(process.env.REF_NATION_BETTING_ARTIFACT_DIR ?? 'test-results/reference-nation-betting'); + +if (!staticBaseUrl && !passwordFile) { + throw new Error('REF_NATION_BETTING_PASSWORD_FILE is required.'); +} + +const password = passwordFile ? (await readFile(passwordFile, 'utf8')).trim() : ''; + +const bettingList = { + result: true, + bettingList: { + 7: { + id: 7, + type: 'bettingNation', + name: '천통국 예상', + finished: false, + selectCnt: 2, + isExclusive: false, + reqInheritancePoint: true, + openYearMonth: 2316, + closeYearMonth: 2340, + winner: null, + totalAmount: 800, + }, + }, + year: 193, + month: 1, +}; + +const bettingDetail = { + result: true, + bettingInfo: { + id: 7, + type: 'bettingNation', + name: '천통국 예상', + finished: false, + selectCnt: 2, + isExclusive: false, + reqInheritancePoint: true, + openYearMonth: 2316, + closeYearMonth: 2340, + candidates: [ + { title: '촉', info: '국력: 1200
장수 수: 8
도시 수: 5', isHtml: true }, + { title: '위', info: '국력: 1100
장수 수: 7
도시 수: 4', isHtml: true }, + { title: '오', info: '국력: 900
장수 수: 6
도시 수: 3', isHtml: true }, + { title: '연', info: '국력: 700
장수 수: 5
도시 수: 2', isHtml: true }, + { title: '양', info: '국력: 650
장수 수: 4
도시 수: 2', isHtml: true }, + { title: '형', info: '국력: 600
장수 수: 3
도시 수: 1', isHtml: true }, + ], + winner: null, + }, + bettingDetail: [ + ['[-1]', 500], + ['[0,1]', 200], + ['[1,2]', 100], + ], + myBetting: [['[0,1]', 50]], + remainPoint: 1200, + year: 193, + month: 1, +}; + +const login = async (context, page) => { + await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 }); + const globalSalt = await page.locator('#global_salt').inputValue(); + // The reference entrance polls install status and can keep the PHP session + // occupied. Leave it before the login request so the session lock is free. + await page.goto('about:blank'); + await context.clearCookies(); + 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 }, + timeout: 60_000, + }); + const result = await response.json(); + if (!response.ok() || result.result !== true) { + throw new Error('Reference login failed.'); + } +}; + +const installBettingFixture = async (page) => { + await page.route('**/api.php*', async (route) => { + const path = new URL(route.request().url()).searchParams.get('path'); + if (path === 'Betting/GetBettingList') { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(bettingList) }); + return; + } + if (path === 'Betting/GetBettingDetail') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(bettingDetail), + }); + return; + } + await route.continue(); + }); +}; + +const mountStaticReference = async (page) => { + const hweUrl = new URL('hwe/', staticBaseUrl); + const assetUrl = new URL('dist_js/hwe_dynamic/vue/', staticBaseUrl); + await page.setContent( + ` + + + + + + + + + + + + +
+ + + + + + + `, + { waitUntil: 'networkidle' } + ); +}; + +const measure = async (browser, viewport) => { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + colorScheme: 'dark', + locale: 'ko-KR', + timezoneId: 'UTC', + ignoreHTTPSErrors: true, + }); + try { + const page = await context.newPage(); + await installBettingFixture(page); + if (staticBaseUrl) { + await mountStaticReference(page); + } else { + await login(context, page); + await page.goto(new URL('hwe/v_nationBetting.php', baseUrl).toString(), { + waitUntil: 'networkidle', + timeout: 60_000, + }); + } + await page.locator('.bettingItem').click(); + await page.locator('.bettingCandidate').first().waitFor({ state: 'visible' }); + + const geometry = await page.locator('#container').evaluate((container) => { + const rect = (element) => { + const value = element.getBoundingClientRect(); + return { x: value.x, y: value.y, width: value.width, height: value.height }; + }; + const cards = Array.from(container.querySelectorAll('.bettingCandidate')); + const firstCard = cards[0]; + const cardStyle = getComputedStyle(firstCard); + const titleStyle = getComputedStyle(firstCard.querySelector('.title')); + const optionalRect = (selector) => { + const element = container.querySelector(selector); + return element ? rect(element) : null; + }; + return { + container: rect(container), + topBar: rect(container.querySelector('.back_bar')), + candidateCells: Array.from(container.querySelectorAll('.bettingCandidates > div')).map(rect), + candidates: cards.map(rect), + bettingForm: optionalRect('.bettingCandidates + .row'), + payoutTable: optionalRect('.bettingCandidates + .row + div'), + bettingList: optionalRect('.bettingList'), + bottomBar: optionalRect('.bottom_bar, .bg0[style]'), + cardStyle: { + borderWidth: cardStyle.borderWidth, + borderRadius: cardStyle.borderRadius, + cursor: cardStyle.cursor, + fontSize: cardStyle.fontSize, + lineHeight: cardStyle.lineHeight, + }, + titleStyle: { + fontWeight: titleStyle.fontWeight, + textAlign: titleStyle.textAlign, + }, + }; + }); + + await page.locator('.bettingCandidate').first().click(); + const pickedStyle = await page + .locator('.bettingCandidate') + .first() + .evaluate((candidate) => { + const style = getComputedStyle(candidate); + return { + borderColor: style.borderColor, + outlineWidth: style.outlineWidth, + titleWeight: getComputedStyle(candidate.querySelector('.title')).fontWeight, + }; + }); + + const screenshotPath = resolve(artifactRoot, `nation-betting-ref-${viewport.name}.png`); + await page.screenshot({ path: screenshotPath, fullPage: true, animations: 'disabled' }); + return { geometry, pickedStyle, screenshotPath }; + } finally { + await context.close(); + } +}; + +await mkdir(artifactRoot, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +try { + const result = {}; + for (const viewport of [ + { name: 'desktop', width: 1280, height: 900 }, + { name: 'mobile', width: 500, height: 900 }, + ]) { + result[viewport.name] = await measure(browser, viewport); + } + const outputPath = resolve(artifactRoot, 'computed-dom.json'); + await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, outputPath })}\n`); +} finally { + await browser.close(); +} diff --git a/tools/frontend-legacy-parity/playwright.config.mjs b/tools/frontend-legacy-parity/playwright.config.mjs index fc57f96..b257ea6 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', 'inheritance-management.spec.ts', ], diff --git a/tools/frontend-legacy-parity/public-gaps.spec.ts b/tools/frontend-legacy-parity/public-gaps.spec.ts index 0977f8d..db128e9 100644 --- a/tools/frontend-legacy-parity/public-gaps.spec.ts +++ b/tools/frontend-legacy-parity/public-gaps.spec.ts @@ -4,10 +4,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -const imageRoots = [ - resolve(repositoryRoot, '../image/game'), - resolve(repositoryRoot, '../../image/game'), -]; +const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')]; const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR; const response = (data: unknown) => ({ result: { data } }); @@ -214,12 +211,39 @@ test('nation betting matches the legacy desktop geometry and preserves a failed const geometry = await page.locator('#nation-betting-container').evaluate((container) => { const containerRect = container.getBoundingClientRect(); const bar = container.querySelector('.legacy-top-bar')!.getBoundingClientRect(); + const detail = container.querySelector('.betting-detail')!; + const detailRect = detail.getBoundingClientRect(); + const candidateRowElement = container.querySelector('.betting-candidates')!; + const candidateRow = candidateRowElement.getBoundingClientRect(); + const candidateCells = Array.from(container.querySelectorAll('.betting-candidate-cell')); const cards = Array.from(container.querySelectorAll('.betting-candidate')); const cardStyle = getComputedStyle(cards[0]!); + const optionalRect = (selector: string) => { + const element = container.querySelector(selector); + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }; return { - container: { x: containerRect.x, width: containerRect.width }, + container: { x: containerRect.x, width: containerRect.width, height: containerRect.height }, bar: { width: bar.width, height: bar.height }, - cardWidths: cards.map((card) => card.getBoundingClientRect().width), + detail: { x: detailRect.x, width: detailRect.width }, + candidateRow: { + x: candidateRow.x, + width: candidateRow.width, + }, + candidateCells: candidateCells.map((cell) => { + const rect = cell.getBoundingClientRect(); + return { x: rect.x, width: rect.width }; + }), + cards: cards.map((card) => { + const rect = card.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }), + bettingForm: optionalRect('.betting-form'), + payoutTable: optionalRect('.payout-table'), + bettingList: optionalRect('.betting-list'), + bottomBar: optionalRect('.betting-footer'), cardStyle: { borderWidth: cardStyle.borderWidth, borderRadius: cardStyle.borderRadius, @@ -229,24 +253,43 @@ test('nation betting matches the legacy desktop geometry and preserves a failed }, }; }); - - expect(geometry.container).toEqual({ x: 140, width: 1000 }); + expect(geometry.container).toEqual({ x: 140, width: 1000, height: 435 }); expect(geometry.bar).toEqual({ width: 1000, height: 32 }); - expect(geometry.cardWidths.every((width) => Math.abs(width - 162) < 1)).toBe(true); + expect(geometry.detail).toEqual({ x: 140, width: 1000 }); + expect(geometry.candidateRow).toEqual({ x: 138.25, width: 1003.5 }); + expect(geometry.candidateCells.map(({ width }) => width)).toEqual(Array(6).fill(167.25)); + expect(geometry.cards.map(({ width }) => width)).toEqual(Array(6).fill(163.75)); + expect(geometry.cards.map(({ height }) => height)).toEqual(Array(6).fill(143)); + expect(geometry.cards.map(({ y }) => y)).toEqual(Array(6).fill(53)); + expect(geometry.bettingForm).toEqual({ x: 140, y: 196, width: 1000, height: 35.5 }); + expect(geometry.payoutTable).toEqual({ x: 140, y: 231.5, width: 1000, height: 85 }); + expect(geometry.bettingList).toEqual({ x: 140, y: 330.5, width: 1000, height: 45.5 }); + expect(geometry.bottomBar).toEqual({ x: 140, y: 379.5, width: 1000, height: 55.5 }); expect(geometry.cardStyle).toEqual({ borderWidth: '1px', borderRadius: '7px', cursor: 'pointer', fontSize: '14px', - lineHeight: '18.2px', + lineHeight: '21px', }); + await expect(page.locator('.legacy-top-bar .legacy-nav-button')).toHaveCount(1); + await expect(page.locator('.payout-row:not(.payout-head)').first().locator('div').nth(2)).toHaveText( + '(50 -> 100.0)' + ); await page.locator('.betting-candidate').nth(0).click(); await page.locator('.betting-candidate').nth(1).click(); - const pickedStyle = await page.locator('.betting-candidate').first().evaluate((candidate) => { - const style = getComputedStyle(candidate); - return { borderColor: style.borderColor, outlineWidth: style.outlineWidth, titleWeight: getComputedStyle(candidate.querySelector('.candidate-title')!).fontWeight }; - }); + const pickedStyle = await page + .locator('.betting-candidate') + .first() + .evaluate((candidate) => { + const style = getComputedStyle(candidate); + return { + borderColor: style.borderColor, + outlineWidth: style.outlineWidth, + titleWeight: getComputedStyle(candidate.querySelector('.candidate-title')!).fontWeight, + }; + }); expect(pickedStyle.borderColor).toBe('rgb(255, 255, 255)'); // Chromium snaps the legacy 1.5px CSS outline to one device pixel at DSF 1. expect(pickedStyle.outlineWidth).toBe('1px'); @@ -281,6 +324,7 @@ test('nation betting keeps the legacy 500px three-column mobile contract', async return { x: rect.x, width: rect.width, + firstX: cards[0]!.getBoundingClientRect().x, firstWidth: cards[0]!.getBoundingClientRect().width, fourthY: cards[3]!.getBoundingClientRect().y, firstY: cards[0]!.getBoundingClientRect().y, @@ -288,7 +332,8 @@ test('nation betting keeps the legacy 500px three-column mobile contract', async }); expect(geometry.x).toBe(0); expect(geometry.width).toBe(500); - expect(geometry.firstWidth).toBeCloseTo(161.328125, 3); + expect(geometry.firstX).toBe(0); + expect(geometry.firstWidth).toBe(164.328125); expect(geometry.fourthY).toBeGreaterThan(geometry.firstY); if (artifactRoot) { @@ -325,17 +370,7 @@ test('NPC list matches the legacy table geometry, sorting and error retention', expect(geometry.tableWidth).toBe(1000); // The legacy width attributes total 974px; Chromium proportionally expands them into the 1000px table. expect(geometry.headerWidths).toEqual([ - 104.609375, - 104.609375, - 69.734375, - 121.015625, - 69.734375, - 90.25, - 69.734375, - 69.734375, - 69.734375, - 69.734375, - 80, + 104.609375, 104.609375, 69.734375, 121.015625, 69.734375, 90.25, 69.734375, 69.734375, 69.734375, 69.734375, 80, 80.109375, ]); expect(geometry.headerStyle).toEqual({ @@ -346,7 +381,10 @@ test('NPC list matches the legacy table geometry, sorting and error retention', lineHeight: '18.2px', }); await expect(page.locator('.npc-table tbody tr').first()).toContainText('관우'); - await expect(page.locator('.npc-table tbody tr').first().locator('td').first()).toHaveCSS('color', 'rgb(135, 206, 235)'); + await expect(page.locator('.npc-table tbody tr').first().locator('td').first()).toHaveCSS( + 'color', + 'rgb(135, 206, 235)' + ); const personality = page.locator('.npc-table tbody tr').first().locator('.trait-tooltip').first(); await personality.hover(); await expect(personality.getByRole('tooltip')).toBeVisible(); 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(); +} diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 0db84a0..8035dce 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -348,6 +348,8 @@ const buildWorldInput = ( maxGeneral: 500, baseGold: 0, baseRice: 2_000, + generalMinimumGold: 0, + generalMinimumRice: 500, maxResourceActionAmount: 10_000, maxTechLevel: 12, maxLevel: 255, diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 9e507f5..3ebcdf6 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -1109,6 +1109,194 @@ integration('general command missing-target fallback matrix', () => { ); }); +const resourceAmountCases: Array<{ + name: string; + action: string; + args: Record; + expectedAmount: number; +}> = [ + { + name: 'gift rounds a half unit up', + action: 'che_증여', + args: { isGold: true, amount: 150, destGeneralID: 3 }, + expectedAmount: 200, + }, + { + name: 'gift clamps below the minimum', + action: 'che_증여', + args: { isGold: true, amount: 1, destGeneralID: 3 }, + expectedAmount: 100, + }, + { + name: 'gift clamps above the maximum', + action: 'che_증여', + args: { isGold: true, amount: 10_050, destGeneralID: 3 }, + expectedAmount: 10_000, + }, + { + name: 'donation rounds a half unit up', + action: 'che_헌납', + args: { isGold: true, amount: 150 }, + expectedAmount: 200, + }, + { + name: 'donation clamps below the minimum', + action: 'che_헌납', + args: { isGold: true, amount: 1 }, + expectedAmount: 100, + }, + { + name: 'donation clamps above the maximum', + action: 'che_헌납', + args: { isGold: true, amount: 10_050 }, + expectedAmount: 10_000, + }, + { + name: 'trade rounds a half unit up', + action: 'che_군량매매', + args: { buyRice: true, amount: 150 }, + expectedAmount: 200, + }, + { + name: 'trade clamps below the minimum', + action: 'che_군량매매', + args: { buyRice: true, amount: 1 }, + expectedAmount: 100, + }, + { + name: 'trade clamps above the maximum', + action: 'che_군량매매', + args: { buyRice: true, amount: 10_050 }, + expectedAmount: 10_000, + }, +]; + +integration('general command resource amount normalization matrix', () => { + it.each(resourceAmountCases)( + '$name matches legacy rounding and clamp semantics', + async ({ action, args, expectedAmount }) => { + const request = buildRequest(action, args); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + const referenceActor = reference.after.generals.find((entry) => entry.id === 1); + const coreActor = core.after.generals.find((entry) => entry.id === 1); + const referenceLastTurn = referenceActor?.lastTurn as { arg?: Record } | null | undefined; + const coreLastTurn = coreActor?.lastTurn as { arg?: Record } | null | undefined; + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey: action, + usedFallback: false, + }); + expect(referenceLastTurn?.arg).toMatchObject({ amount: expectedAmount }); + expect(coreLastTurn?.arg).toMatchObject({ amount: expectedAmount }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, + 120_000 + ); +}); + +integration('general command donation resource boundaries', () => { + it('donates the available resource when the normalized request exceeds the current amount', async () => { + const request = buildRequest('che_헌납', { isGold: true, amount: 10_000 }, { gold: 5_000 }); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_헌납', + actionKey: 'che_헌납', + usedFallback: false, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, 120_000); + + it('falls back when current rice is below the legacy minimum even for a small request', async () => { + const request = buildRequest('che_헌납', { isGold: false, amount: 100 }, { rice: 499 }); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed: false }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_헌납', + actionKey: '휴식', + usedFallback: true, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, 120_000); +}); + +integration('general command gift resource and target boundaries', () => { + it('keeps the legacy minimum rice reserve while gifting the available amount', async () => { + const request = buildRequest('che_증여', { isGold: false, amount: 10_000, destGeneralID: 3 }, { rice: 600 }); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_증여', + actionKey: 'che_증여', + usedFallback: false, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, 120_000); + + it('rejects gifting to the actor and falls back without command RNG', async () => { + const request = buildRequest('che_증여', { isGold: true, amount: 100, destGeneralID: 1 }); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed: false }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_증여', + actionKey: '휴식', + usedFallback: true, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, 120_000); +}); + type GeneralConstraintCase = { name: string; action: string;