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/auction/index.ts b/app/game-api/src/router/auction/index.ts index 5b2f30d..8f1ac28 100644 --- a/app/game-api/src/router/auction/index.ts +++ b/app/game-api/src/router/auction/index.ts @@ -594,7 +594,7 @@ export const auctionRouter = router({ auctionId: auction.id, generalId: general.id, amount: input.amount, - tryExtendCloseDate: input.tryExtendCloseDate ?? true, + tryExtendCloseDate: input.tryExtendCloseDate ?? false, }); if (!result || result.type !== 'auctionBid') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); 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/auctionRouter.test.ts b/app/game-api/test/auctionRouter.test.ts new file mode 100644 index 0000000..fb915be --- /dev/null +++ b/app/game-api/test/auctionRouter.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { GamePrisma, RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import type { TurnDaemonTransport } from '../src/daemon/transport.js'; +import { appRouter } from '../src/router.js'; + +const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ + id: 7, + userId: 'user-1', + name: '유비', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: null, + imageServer: 0, + leadership: 50, + strength: 50, + intel: 50, + injury: 0, + experience: 0, + dedication: 0, + officerLevel: 1, + gold: 10_000, + rice: 10_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: new Date('2026-07-26T00:00:00Z'), + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: {}, + penalty: {}, + createdAt: new Date('2026-07-26T00:00:00Z'), + updatedAt: new Date('2026-07-26T00:00:00Z'), + ...overrides, +}); + +const buildAuth = (userId = 'user-1'): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: '2026-07-26T00:00:00.000Z', + expiresAt: '2026-07-27T00:00:00.000Z', + sessionId: `session-${userId}`, + user: { + id: userId, + username: userId, + displayName: userId, + roles: [], + }, + sanctions: {}, +}); + +const sqlText = (query: GamePrisma.Sql): string => query.strings.join(' '); + +const buildContext = (options: { + auth?: GameSessionTokenPayload | null; + general?: GeneralRow | null; + auctions?: Array>; + queryRaw?: (query: GamePrisma.Sql) => Promise; +}) => { + const auth = options.auth === undefined ? buildAuth() : options.auth; + const general = options.general === undefined ? buildGeneral() : options.general; + const requestCommand = vi.fn(async (command: { type: string }) => { + if (command.type === 'auctionOpen') { + return { + type: 'auctionOpen' as const, + ok: true as const, + auctionId: 91, + closeAt: '2026-07-27T00:00:00.000Z', + }; + } + return { + type: 'auctionBid' as const, + ok: true as const, + auctionId: 91, + closeAt: '2026-07-27T00:00:00.000Z', + }; + }); + const queryRaw = vi.fn(options.queryRaw ?? (async () => [])); + const worldState = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 3600, + config: { + const: { + auctionName: ['청룡', '백호', '주작', '현무'], + allItems: { weapon: { che_무기_12_칠성검: 1 } }, + }, + }, + meta: { hiddenSeed: 'auction-hidden-seed' }, + updatedAt: new Date('2026-07-26T00:00:00Z'), + }; + const db = { + $queryRaw: queryRaw, + general: { + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => + general?.userId === where.userId ? general : null + ), + findMany: vi.fn(async ({ where }: { where: { id: { in: number[] } } }) => + where.id.in.map((id) => ({ id, name: id === 88 ? '관우' : '조조' })) + ), + }, + auction: { + findMany: vi.fn(async () => options.auctions ?? []), + findFirst: vi.fn(async () => null), + }, + worldState: { + findFirst: vi.fn(async () => worldState), + }, + inheritancePoint: { + findUnique: vi.fn(async () => ({ value: 10_000 })), + }, + logEntry: { + findMany: vi.fn(async () => []), + }, + }; + const redis = { + zAdd: vi.fn(async () => 1), + }; + const accessTokenStore = new RedisAccessTokenStore( + { + get: async () => null, + set: async () => null, + }, + 'che:default' + ); + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis: redis as unknown as RedisConnector['client'], + turnDaemon: { requestCommand } as unknown as TurnDaemonTransport, + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore, + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + return { context, db, queryRaw, redis, requestCommand }; +}; + +describe('auction router actor and permission boundaries', () => { + it('rejects unauthenticated auction reads', async () => { + const fixture = buildContext({ auth: null }); + + await expect(appRouter.createCaller(fixture.context).auction.getOverview()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it('rejects reads and mutations when the authenticated user owns no general', async () => { + const fixture = buildContext({ + auth: buildAuth('user-2'), + general: buildGeneral({ userId: 'user-1' }), + }); + const caller = appRouter.createCaller(fixture.context); + + await expect(caller.auction.getOverview()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + message: 'General not found.', + }); + await expect( + caller.auction.openBuyRice({ + amount: 1000, + closeTurnCnt: 3, + startBidAmount: 500, + finishBidAmount: 2000, + }) + ).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + message: 'General not found.', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it('derives the daemon actor from the session-owned general and ignores a forged generalId field', async () => { + const fixture = buildContext({ general: buildGeneral({ id: 7, userId: 'user-1' }) }); + const input = { + amount: 1000, + closeTurnCnt: 3, + startBidAmount: 500, + finishBidAmount: 2000, + generalId: 999, + }; + + await appRouter.createCaller(fixture.context).auction.openBuyRice(input); + + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'auctionOpen', + auctionType: 'BUY_RICE', + generalId: 7, + amount: 1000, + closeTurnCnt: 3, + startBidAmount: 500, + finishBidAmount: 2000, + }); + }); + + it('redacts real unique-auction identities while preserving caller markers', async () => { + const openedAt = new Date('2026-07-26T01:00:00Z'); + const fixture = buildContext({ + auctions: [ + { + id: 31, + type: 'UNIQUE_ITEM', + targetCode: 'che_무기_12_칠성검', + hostGeneralId: 7, + hostName: null, + detail: { title: '칠성검 경매', startBidAmount: 5000 }, + status: 'OPEN', + closeAt: new Date('2026-07-27T00:00:00Z'), + bids: [ + { + id: 41, + generalId: 88, + amount: 5500, + eventAt: openedAt, + }, + ], + }, + ], + }); + + const result = await appRouter.createCaller(fixture.context).auction.getOverview(); + const unique = result.uniqueAuctions[0]; + + expect(unique).toMatchObject({ + id: 31, + hostGeneralId: null, + isCallerHost: true, + highestBid: { amount: 5500, isCaller: false }, + }); + expect(unique?.hostName).not.toBe('유비'); + expect(unique?.highestBid?.bidderName).not.toBe('관우'); + expect(JSON.stringify(unique)).not.toContain('"generalId"'); + expect(JSON.stringify(unique)).not.toContain('"hostGeneralId":7'); + }); + + it('keeps the legacy default of no requested close extension for a unique bid', async () => { + const fixture = buildContext({ + queryRaw: async (query) => { + const text = sqlText(query); + if (text.includes('FROM auction') && text.includes('WHERE id =')) { + return [ + { + id: 31, + type: 'UNIQUE_ITEM', + targetCode: 'che_무기_12_칠성검', + hostGeneralId: 88, + detail: { startBidAmount: 100, isReverse: false }, + status: 'OPEN', + closeAt: new Date('2026-07-27T00:00:00Z'), + }, + ]; + } + if (text.includes('FROM auction_bid') && text.includes('general_id =')) { + return []; + } + if (text.includes('SELECT bid.auction_id')) { + return [{ auctionId: 31, generalId: 88, amount: 100 }]; + } + if (text.includes('FROM auction_bid')) { + return [{ id: 41, generalId: 88, amount: 100, meta: {} }]; + } + if (text.includes('SELECT id, target_code')) { + return [{ id: 31, targetCode: 'che_무기_12_칠성검' }]; + } + return []; + }, + }); + + await appRouter.createCaller(fixture.context).auction.bidUnique({ + auctionId: 31, + amount: 110, + }); + + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'auctionBid', + auctionId: 31, + generalId: 7, + amount: 110, + tryExtendCloseDate: false, + }); + }); +}); 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/e2e/auction.spec.ts b/app/game-frontend/e2e/auction.spec.ts new file mode 100644 index 0000000..7267ece --- /dev/null +++ b/app/game-frontend/e2e/auction.spec.ts @@ -0,0 +1,371 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +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')]; + +type AuctionFixture = { + failResourceBid?: boolean; + resourceBidCount: number; + uniqueBidCount: number; +}; + +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 url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const readReferenceImage = async (filename: string): Promise => { + for (const imageRoot of imageRoots) { + try { + return await readFile(resolve(imageRoot, filename)); + } catch { + // The main checkout and nested feature worktrees have different parents. + } + } + throw new Error(`Reference image not found: ${filename}`); +}; + +const overview = { + resourceAuctions: [ + { + id: 1, + type: 'BUY_RICE', + targetCode: '1000', + status: 'OPEN', + hostGeneralId: 11, + hostName: '조조', + isCallerHost: false, + closeAt: '2026-07-27T02:30:00.000Z', + detail: { + title: '쌀 1000 경매', + amount: 1000, + isReverse: false, + startBidAmount: 500, + finishBidAmount: 1800, + }, + highestBid: { + amount: 750, + bidderName: '관우', + isCaller: false, + eventAt: '2026-07-26T01:00:00.000Z', + }, + }, + { + id: 2, + type: 'SELL_RICE', + targetCode: '900', + status: 'OPEN', + hostGeneralId: 7, + hostName: '유비', + isCallerHost: true, + closeAt: '2026-07-27T03:00:00.000Z', + detail: { + title: '금 900 경매', + amount: 900, + isReverse: false, + startBidAmount: 600, + finishBidAmount: 1700, + }, + highestBid: null, + }, + ], + uniqueAuctions: [ + { + id: 10, + type: 'UNIQUE_ITEM', + targetCode: 'che_무기_12_칠성검', + status: 'OPEN', + hostGeneralId: null, + hostName: '청룡', + isCallerHost: false, + closeAt: '2026-07-27T04:00:00.000Z', + detail: { + title: '칠성검 경매', + startBidAmount: 5000, + remainCloseDateExtensionCnt: 1, + availableLatestBidCloseDate: '2026-07-27T04:30:00.000Z', + }, + highestBid: { + amount: 5500, + bidderName: '백호', + isCaller: false, + eventAt: '2026-07-26T02:00:00.000Z', + }, + }, + { + id: 9, + type: 'UNIQUE_ITEM', + targetCode: 'che_서적_15_손자병법', + status: 'FINISHED', + hostGeneralId: null, + hostName: '현무', + isCallerHost: true, + closeAt: '2026-07-25T04:00:00.000Z', + detail: { + title: '손자병법 경매', + startBidAmount: 5000, + remainCloseDateExtensionCnt: 0, + availableLatestBidCloseDate: '2026-07-25T04:30:00.000Z', + }, + highestBid: { + amount: 6000, + bidderName: '현무', + isCaller: true, + eventAt: '2026-07-25T03:00:00.000Z', + }, + }, + ], + callerAlias: '현무', + remainPoint: 9000, + recentLogs: [ + { + id: 1, + text: '●경매 1번 거래가 성사되었습니다.', + createdAt: '2026-07-25T00:00:00.000Z', + }, + ], +}; + +const uniqueDetail = { + auction: { + id: 10, + targetCode: 'che_무기_12_칠성검', + status: 'OPEN', + hostName: '청룡', + isCallerHost: false, + closeAt: '2026-07-27T04:00:00.000Z', + detail: { + title: '칠성검 경매', + startBidAmount: 5000, + remainCloseDateExtensionCnt: 1, + availableLatestBidCloseDate: '2026-07-27T04:30:00.000Z', + }, + }, + bids: [ + { + id: 101, + amount: 5500, + bidderName: '백호', + isCaller: false, + eventAt: '2026-07-26T02:00:00.000Z', + }, + { + id: 100, + amount: 5000, + bidderName: '현무', + isCaller: true, + eventAt: '2026-07-26T01:00:00.000Z', + }, + ], + callerAlias: '현무', + remainPoint: 9000, +}; + +const installFixture = async (page: Page, state: AuctionFixture) => { + await page.addInitScript(() => { + window.localStorage.setItem('sammo-game-token', 'ga_auction_playwright'); + window.localStorage.setItem('sammo-game-profile', 'che:default'); + }); + for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) { + await page.route(`**/image/game/${filename}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await readReferenceImage(filename), + }); + }); + } + await page.route('**/che/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'lobby.info') { + return response({ myGeneral: { id: 7, name: '유비' } }); + } + if (operation === 'join.getConfig') { + return response({}); + } + if (operation === 'auction.getOverview') { + return response(overview); + } + if (operation === 'auction.getUniqueDetail') { + return response(uniqueDetail); + } + if (operation === 'auction.bidBuyRice') { + if (state.failResourceBid) { + state.failResourceBid = false; + return errorResponse(operation, '금이 부족합니다.'); + } + state.resourceBidCount += 1; + return response({ ok: true }); + } + if (operation === 'auction.bidUnique') { + state.uniqueBidCount += 1; + return response({ ok: true }); + } + if (operation === 'auction.openBuyRice' || operation === 'auction.openSellRice') { + return response({ auctionId: 20, closeAt: '2026-07-28T00:00:00.000Z' }); + } + return errorResponse(operation, `Unhandled fixture operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); +}; + +const gotoAuction = async (page: Page, suffix = 'auction') => { + const lobbyResponse = page.waitForResponse((response) => response.url().includes('/trpc/lobby.info')); + await page.goto(suffix); + await lobbyResponse; + await expect(page.locator('#container')).toBeVisible(); +}; + +test('resource auction preserves the legacy desktop structure, geometry, and interaction states', async ({ page }) => { + const state = { failResourceBid: true, resourceBidCount: 0, uniqueBidCount: 0 }; + await installFixture(page, state); + await page.setViewportSize({ width: 1000, height: 800 }); + await gotoAuction(page); + await expect(page.getByRole('heading', { name: '경매장', exact: true })).toBeVisible(); + await expect(page.getByText('쌀 구매', { exact: true })).toBeVisible(); + await expect(page.getByText('쌀 판매', { exact: true })).toBeVisible(); + await expect(page.getByText('단가', { exact: true }).first()).toBeVisible(); + + const geometry = await page.locator('#container').evaluate((container) => { + const box = (selector: string) => { + const rect = container.querySelector(selector)!.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }; + const containerRect = container.getBoundingClientRect(); + const row = container.querySelector('.resource-row')!; + const rowRect = row.getBoundingClientRect(); + const cells = [...row.children].map((cell) => cell.getBoundingClientRect().width); + const button = container.querySelector('.tab-button')!; + const buttonStyle = getComputedStyle(button); + return { + container: { x: containerRect.x, width: containerRect.width }, + topBar: box('.top-back-bar'), + row: { width: rowRect.width, height: rowRect.height }, + cells, + button: { + height: button.getBoundingClientRect().height, + borderRadius: buttonStyle.borderRadius, + cursor: buttonStyle.cursor, + fontSize: buttonStyle.fontSize, + }, + }; + }); + expect(geometry.container).toEqual({ x: 0, width: 1000 }); + expect(geometry.topBar).toMatchObject({ x: 0, width: 1000, height: 32 }); + expect(geometry.row).toEqual({ width: 1000, height: 22 }); + expect(geometry.cells[0]).toBeCloseTo(66.66, 1); + expect(geometry.cells[1]).toBeCloseTo(133.34, 1); + expect(geometry.cells[6]).toBeCloseTo(200, 1); + expect(geometry.button).toEqual({ + height: 35.5, + borderRadius: '5.25px', + cursor: 'pointer', + fontSize: '14px', + }); + await page.screenshot({ path: 'test-results/auction/resource-desktop-initial.png', fullPage: true }); + + const firstRow = page.locator('.resource-row.clickable-row').first(); + await firstRow.click(); + const bidInput = page.getByRole('spinbutton', { name: '1번 경매 입찰가' }); + await bidInput.fill('800'); + await page.getByRole('button', { name: '입찰', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('금이 부족합니다.'); + await page.screenshot({ path: 'test-results/auction/resource-desktop-error.png', fullPage: true }); + expect(state.resourceBidCount).toBe(0); + + await page.getByRole('button', { name: '입찰', exact: true }).click(); + await expect(page.getByRole('status')).toContainText('입찰했습니다.'); + expect(state.resourceBidCount).toBe(1); + + await firstRow.hover(); + expect(await firstRow.evaluate((row) => getComputedStyle(row).cursor)).toBe('pointer'); + await page.screenshot({ path: 'test-results/auction/resource-desktop.png', fullPage: true }); +}); + +test('resource auction keeps the legacy 500px two-row grid', async ({ page }) => { + await installFixture(page, { resourceBidCount: 0, uniqueBidCount: 0 }); + await page.setViewportSize({ width: 500, height: 800 }); + await gotoAuction(page); + + const geometry = await page + .locator('.resource-row') + .first() + .evaluate((row) => { + const origin = row.getBoundingClientRect(); + const relative = (selector: string) => { + const rect = row.querySelector(selector)!.getBoundingClientRect(); + return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width, height: rect.height }; + }; + return { + row: { width: origin.width, height: origin.height }, + idx: relative('.idx'), + host: relative('.host'), + amount: relative('.amount'), + close: relative('.close-date'), + }; + }); + expect(geometry.row).toEqual({ width: 500, height: 43 }); + expect(geometry.idx).toEqual({ x: 0, y: 10.5, width: 41.65625, height: 21 }); + expect(geometry.host).toEqual({ x: 41.65625, y: 0, width: 125, height: 21 }); + expect(geometry.amount).toEqual({ x: 41.65625, y: 21, width: 125, height: 21 }); + expect(geometry.close).toEqual({ x: 416.65625, y: 10.5, width: 83.34375, height: 21 }); + await page.screenshot({ path: 'test-results/auction/resource-mobile.png', fullPage: true }); +}); + +test('unique auction separates ongoing and finished lists and auto-loads the legacy detail', async ({ page }) => { + const state = { resourceBidCount: 0, uniqueBidCount: 0 }; + await installFixture(page, state); + await page.setViewportSize({ width: 1000, height: 800 }); + await gotoAuction(page, 'auction?type=unique'); + + await expect(page.getByRole('heading', { name: '유니크 경매장', exact: true })).toBeVisible(); + await expect(page.locator('.caller-alias')).toContainText('내 가명: 현무'); + await expect(page.getByRole('heading', { name: '경매 10번 상세' })).toBeVisible(); + await expect(page.getByText('최대지연', { exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: '진행중인 경매 목록' })).toBeVisible(); + await expect(page.getByRole('heading', { name: '종료된 경매 목록' })).toBeVisible(); + await expect(page.getByText('남음', { exact: true })).toBeVisible(); + await expect(page.getByText('소진', { exact: true })).toBeVisible(); + + const aliasStyle = await page.locator('.caller-alias strong').evaluate((element) => { + const style = getComputedStyle(element); + return { color: style.color, fontWeight: style.fontWeight }; + }); + expect(aliasStyle).toEqual({ color: 'rgb(0, 255, 255)', fontWeight: '700' }); + + const input = page.getByRole('spinbutton', { name: '유산포인트' }); + await input.fill('5600'); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('칠성검 경매에 5600유산포인트를 입찰하시겠습니까?'); + await dialog.accept(); + }); + await page.getByRole('button', { name: '입찰', exact: true }).click(); + await expect(page.getByRole('status')).toContainText('입찰이 완료되었습니다.'); + expect(state.uniqueBidCount).toBe(1); + await page.screenshot({ path: 'test-results/auction/unique-desktop.png', fullPage: true }); +}); + +test('resource host cannot bid on the auction opened by its own general', async ({ page }) => { + await installFixture(page, { resourceBidCount: 0, uniqueBidCount: 0 }); + await gotoAuction(page); + + await page.locator('.resource-row.clickable-row').filter({ hasText: '유비' }).click(); + await expect(page.getByRole('button', { name: '입찰', exact: true })).toBeDisabled(); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 5ef6863..ea02b56 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -16,6 +16,7 @@ export default defineConfig({ 'nationOffices.spec.ts', 'nationGeneralSecret.spec.ts', 'npcPolicy.spec.ts', + 'auction.spec.ts', 'battleSimulator.spec.ts', 'battleSimulatorRef.spec.ts', ], diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 5d3a6bd..cac0d86 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -11,6 +11,7 @@ "test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs", "test:e2e:npc-policy": "playwright test npcPolicy.spec.ts --config e2e/playwright.config.mjs", "test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs", + "test:e2e:auction": "playwright test auction.spec.ts --config e2e/playwright.config.mjs", "test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", 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/AuctionView.vue b/app/game-frontend/src/views/AuctionView.vue index ca80e55..7bcf928 100644 --- a/app/game-frontend/src/views/AuctionView.vue +++ b/app/game-frontend/src/views/AuctionView.vue @@ -1,8 +1,7 @@ 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/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 4552131..08a10c8 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -817,6 +817,7 @@ export const adminRouter = router({ profileName: profile.profileName, apiRunning: false, daemonRunning: false, + auctionRunning: false, battleSimRunning: false, tournamentRunning: false, }, diff --git a/app/gateway-api/src/lobby/profileStatusService.ts b/app/gateway-api/src/lobby/profileStatusService.ts index 9814831..47ffc19 100644 --- a/app/gateway-api/src/lobby/profileStatusService.ts +++ b/app/gateway-api/src/lobby/profileStatusService.ts @@ -26,6 +26,7 @@ export type LobbyProfileStatus = { runtime: { apiRunning: boolean; daemonRunning: boolean; + auctionRunning: boolean; battleSimRunning: boolean; tournamentRunning: boolean; }; @@ -72,7 +73,13 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi row: GatewayProfileRecord, runtimeMap: Map< string, - { apiRunning: boolean; daemonRunning: boolean; battleSimRunning: boolean; tournamentRunning: boolean } + { + apiRunning: boolean; + daemonRunning: boolean; + auctionRunning: boolean; + battleSimRunning: boolean; + tournamentRunning: boolean; + } > ): LobbyProfileStatus { const meta = row.meta; @@ -85,6 +92,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi runtime: runtimeMap.get(row.profileName) ?? { apiRunning: false, daemonRunning: false, + auctionRunning: false, battleSimRunning: false, tournamentRunning: false, }, diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 804e411..125b84f 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -39,6 +39,7 @@ export interface GatewayOrchestratorOptions { export interface ProfileRuntimeState { apiRunning: boolean; daemonRunning: boolean; + auctionRunning: boolean; battleSimRunning: boolean; tournamentRunning: boolean; } @@ -70,6 +71,7 @@ export const planProfileReconcile = ( shouldStart: !( runtime.apiRunning && runtime.daemonRunning && + runtime.auctionRunning && runtime.battleSimRunning && runtime.tournamentRunning ), @@ -79,7 +81,11 @@ export const planProfileReconcile = ( return { shouldStart: false, shouldStop: - runtime.apiRunning || runtime.daemonRunning || runtime.battleSimRunning || runtime.tournamentRunning, + runtime.apiRunning || + runtime.daemonRunning || + runtime.auctionRunning || + runtime.battleSimRunning || + runtime.tournamentRunning, }; }; @@ -280,15 +286,20 @@ const parseInstallOptions = ( }; }; -const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'battle-sim' | 'tournament'): string => +const buildProcessName = ( + profileName: string, + role: 'api' | 'daemon' | 'auction' | 'battle-sim' | 'tournament' +): string => `sammo:${profileName}:${ role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' - : role === 'battle-sim' - ? 'battle-sim-worker' - : 'tournament-worker' + : role === 'auction' + ? 'auction-worker' + : role === 'battle-sim' + ? 'battle-sim-worker' + : 'tournament-worker' }`; const isMissingProcessError = (error: unknown): boolean => @@ -300,12 +311,14 @@ export const buildProcessDefinitions = ( ): { api: { name: string; script: string; cwd: string; env: Record }; daemon: { name: string; script: string; cwd: string; env: Record }; + auction: { name: string; script: string; cwd: string; env: Record }; battleSim: { name: string; script: string; cwd: string; env: Record }; tournament: { name: string; script: string; cwd: string; env: Record }; } => { const baseEnv = { ...(config.baseEnv ?? {}) }; const apiName = buildProcessName(profile.profileName, 'api'); const daemonName = buildProcessName(profile.profileName, 'daemon'); + const auctionName = buildProcessName(profile.profileName, 'auction'); const battleSimName = buildProcessName(profile.profileName, 'battle-sim'); const tournamentName = buildProcessName(profile.profileName, 'tournament'); const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot; @@ -344,6 +357,15 @@ export const buildProcessDefinitions = ( cwd: daemonCwd, env: daemonEnv, }, + auction: { + name: auctionName, + script: apiScript, + cwd: apiCwd, + env: { + ...apiEnv, + GAME_API_ROLE: 'auction-worker', + }, + }, battleSim: { name: battleSimName, script: apiScript, @@ -402,12 +424,14 @@ const mapRuntimeStates = (profileNames: string[], processNames: Map { const apiName = buildProcessName(profileName, 'api'); const daemonName = buildProcessName(profileName, 'daemon'); + const auctionName = buildProcessName(profileName, 'auction'); const battleSimName = buildProcessName(profileName, 'battle-sim'); const tournamentName = buildProcessName(profileName, 'tournament'); return { profileName, apiRunning: processNames.get(apiName) ?? false, daemonRunning: processNames.get(daemonName) ?? false, + auctionRunning: processNames.get(auctionName) ?? false, battleSimRunning: processNames.get(battleSimName) ?? false, tournamentRunning: processNames.get(tournamentName) ?? false, }; @@ -1009,6 +1033,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { try { await this.processManager.start(definitions.api); await this.processManager.start(definitions.daemon); + await this.processManager.start(definitions.auction); await this.processManager.start(definitions.battleSim); await this.processManager.start(definitions.tournament); await this.repository.updateLastError(profile.profileName, null); @@ -1025,11 +1050,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private async stopProfile(profile: GatewayProfileRecord): Promise { const apiName = buildProcessName(profile.profileName, 'api'); const daemonName = buildProcessName(profile.profileName, 'daemon'); + const auctionName = buildProcessName(profile.profileName, 'auction'); const battleSimName = buildProcessName(profile.profileName, 'battle-sim'); const tournamentName = buildProcessName(profile.profileName, 'tournament'); const existingNames = new Set((await this.processManager.list()).map((process) => process.name)); const failures: string[] = []; - for (const name of [apiName, daemonName, battleSimName, tournamentName]) { + for (const name of [apiName, daemonName, auctionName, battleSimName, tournamentName]) { if (!existingNames.has(name)) { continue; } diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index 787d20a..76bbf46 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -88,6 +88,7 @@ const createHarness = ( ? [ { name: 'sammo:che:2:game-api', status: 'online' }, { name: 'sammo:che:2:turn-daemon', status: 'online' }, + { name: 'sammo:che:2:auction-worker', status: 'online' }, { name: 'sammo:che:2:battle-sim-worker', status: 'online' }, { name: 'sammo:che:2:tournament-worker', status: 'online' }, ] @@ -148,6 +149,7 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.started.map((definition) => definition.name)).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); @@ -163,12 +165,14 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.stopped).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.deleted).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); @@ -194,6 +198,7 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.deleted).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); @@ -216,12 +221,14 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.stopped).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.deleted).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 431a840..5a4c915 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -29,6 +29,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('RUNNING', { apiRunning: true, daemonRunning: false, + auctionRunning: true, battleSimRunning: true, tournamentRunning: true, }) @@ -40,6 +41,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('PREOPEN', { apiRunning: false, daemonRunning: false, + auctionRunning: false, battleSimRunning: false, tournamentRunning: false, }) @@ -51,17 +53,31 @@ describe('planProfileReconcile', () => { planProfileReconcile('RUNNING', { apiRunning: true, daemonRunning: true, + auctionRunning: true, battleSimRunning: true, tournamentRunning: true, }) ).toEqual({ shouldStart: false, shouldStop: false }); }); + it('restarts a running profile when only the auction worker is missing', () => { + expect( + planProfileReconcile('RUNNING', { + apiRunning: true, + daemonRunning: true, + auctionRunning: false, + battleSimRunning: true, + tournamentRunning: true, + }) + ).toEqual({ shouldStart: true, shouldStop: false }); + }); + it('stops processes for non-running profiles', () => { expect( planProfileReconcile('STOPPED', { apiRunning: false, daemonRunning: true, + auctionRunning: false, battleSimRunning: false, tournamentRunning: false, }) @@ -73,6 +89,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('RESERVED', { apiRunning: false, daemonRunning: false, + auctionRunning: false, battleSimRunning: false, tournamentRunning: false, }) @@ -100,6 +117,11 @@ describe('buildProcessDefinitions', () => { }); expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine')); expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js')); + expect(definitions.auction).toMatchObject({ + cwd: path.join(buildWorkspace, 'app', 'game-api'), + script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'), + env: { GAME_API_ROLE: 'auction-worker' }, + }); expect(definitions.battleSim).toMatchObject({ cwd: path.join(buildWorkspace, 'app', 'game-api'), script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'), @@ -117,6 +139,7 @@ describe('buildProcessDefinitions', () => { expect(definitions.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine')); + expect(definitions.auction.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); expect(definitions.battleSim.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); }); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index 65d02c9..3f23cd4 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -38,6 +38,7 @@ const profile = (runtimeRunning: boolean) => ({ profileName: 'che:2', apiRunning: runtimeRunning, daemonRunning: runtimeRunning, + auctionRunning: runtimeRunning, battleSimRunning: runtimeRunning, tournamentRunning: runtimeRunning, }, @@ -213,7 +214,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true }); }); -test('starts and stops both runtime roles through the operation controls', async ({ page }) => { +test('starts and stops all runtime roles through the operation controls', async ({ page }) => { const state: FixtureState = { operations: [], runtimeRunning: false, requestBodies: [] }; await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index b2c9fa6..05a3fca 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -70,6 +70,7 @@ type AdminProfile = { runtime: { apiRunning: boolean; daemonRunning: boolean; + auctionRunning: boolean; battleSimRunning: boolean; tournamentRunning: boolean; }; @@ -1353,7 +1354,8 @@ onMounted(() => {
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} / - DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / BATTLE SIM: + DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / AUCTION: + {{ profile.runtime.auctionRunning ? 'ON' : 'OFF' }} / BATTLE SIM: {{ profile.runtime.battleSimRunning ? 'ON' : 'OFF' }} / TOURNAMENT: {{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index b39a048..bb9541a 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -19,6 +19,7 @@ type Profile = { runtime: { apiRunning: boolean; daemonRunning: boolean; + auctionRunning: boolean; battleSimRunning: boolean; tournamentRunning: boolean; }; @@ -382,6 +383,12 @@ onBeforeUnmount(() => { {{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }} +
+
Auction worker
+
+ {{ selectedProfile.runtime.auctionRunning ? 'RUNNING' : 'STOPPED' }} +
+
Battle sim worker
; 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/auction-ref-measure.mjs b/tools/frontend-legacy-parity/auction-ref-measure.mjs new file mode 100644 index 0000000..8114e29 --- /dev/null +++ b/tools/frontend-legacy-parity/auction-ref-measure.mjs @@ -0,0 +1,257 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from '@playwright/test'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const targetRoot = process.env.REF_AUCTION_URL ?? 'https://dev-sam-ref.hided.net/sam/'; +const secretRoot = process.env.REF_SECRET_ROOT; +const username = process.env.REF_USER_ID ?? 'refuser1'; +const passwordFile = process.env.REF_PASSWORD_FILE ?? 'user1_password'; +const allowGeneralCreate = process.env.REF_CREATE_GENERAL === '1'; +const outputRoot = resolve( + process.env.REF_AUCTION_ARTIFACT_DIR ?? resolve(repositoryRoot, 'test-results/auction-reference') +); + +if (!secretRoot) { + throw new Error('REF_SECRET_ROOT is required.'); +} + +const password = (await readFile(resolve(secretRoot, passwordFile), 'utf8')).trim(); +const viewports = [ + { name: 'desktop', width: 1000, height: 800 }, + { name: 'mobile', width: 500, height: 800 }, +]; + +const login = async (context) => { + const page = await context.newPage(); + await page.goto(targetRoot, { waitUntil: 'networkidle', timeout: 60_000 }); + const globalSalt = await page.locator('#global_salt').inputValue(); + const clientPasswordHash = createHash('sha512') + .update(globalSalt + password + globalSalt) + .digest('hex'); + const response = await context.request.post(new URL('api.php?path=Login/LoginByID', targetRoot).toString(), { + data: { username, password: clientPasswordHash }, + timeout: 60_000, + }); + const result = await response.json(); + if (!response.ok() || result.result !== true) { + throw new Error('Reference login failed.'); + } + if (allowGeneralCreate) { + const joinUrl = new URL('hwe/v_join.php', targetRoot).toString(); + await page.goto(joinUrl, { waitUntil: 'networkidle', timeout: 60_000 }); + if (page.url().includes('v_join.php')) { + const createButton = page.getByRole('button', { name: '장수 생성', exact: true }); + try { + await createButton.waitFor({ state: 'visible', timeout: 30_000 }); + } catch { + const pageText = (await page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 300); + throw new Error(`Reference general form did not render: ${page.url()} | ${pageText}`); + } + page.on('dialog', async (dialog) => dialog.accept()); + await createButton.click(); + await page.waitForURL((url) => !url.pathname.endsWith('/v_join.php'), { timeout: 60_000 }); + } + } + await page.close(); +}; + +const roundedRect = (rect) => ({ + x: Math.round(rect.x * 100) / 100, + y: Math.round(rect.y * 100) / 100, + width: Math.round(rect.width * 100) / 100, + height: Math.round(rect.height * 100) / 100, +}); + +const measurePage = async (page, type) => { + const diagnostics = []; + const onPageError = (error) => diagnostics.push(`pageerror: ${error.message}`); + const onConsole = (message) => { + if (message.type() === 'error') { + diagnostics.push(`console: ${message.text()}`); + } + }; + const onResponse = (response) => { + if (response.status() >= 400) { + diagnostics.push(`http ${response.status()}: ${response.url()}`); + } + }; + page.on('pageerror', onPageError); + page.on('console', onConsole); + page.on('response', onResponse); + const relative = type === 'unique' ? 'hwe/v_auction.php?type=unique' : 'hwe/v_auction.php'; + await page.goto(new URL(relative, targetRoot).toString(), { waitUntil: 'networkidle', timeout: 60_000 }); + try { + await page.locator('#container').waitFor({ state: 'visible', timeout: 30_000 }); + } catch { + const pageText = (await page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 300); + const scripts = await page.locator('script').evaluateAll((elements) => + elements.map((element) => ({ + src: element.getAttribute('src'), + type: element.getAttribute('type'), + length: element.textContent?.length ?? 0, + })) + ); + throw new Error( + `Reference auction did not render: ${page.url()} | ${await page.title()} | ${pageText} | ${JSON.stringify(scripts)} | ${diagnostics.slice(0, 8).join(' | ')}` + ); + } finally { + page.off('pageerror', onPageError); + page.off('console', onConsole); + page.off('response', onResponse); + } + await page.locator('button').first().waitFor({ state: 'visible' }); + await page.evaluate(() => document.fonts.ready); + + const measurement = await page.evaluate(() => { + const rect = (element) => { + if (!(element instanceof HTMLElement)) { + return null; + } + const value = element.getBoundingClientRect(); + return { x: value.x, y: value.y, width: value.width, height: value.height }; + }; + const style = (element) => { + if (!(element instanceof HTMLElement)) { + return null; + } + const value = getComputedStyle(element); + return { + color: value.color, + backgroundColor: value.backgroundColor, + backgroundImage: value.backgroundImage, + borderColor: value.borderColor, + borderWidth: value.borderWidth, + borderRadius: value.borderRadius, + fontFamily: value.fontFamily, + fontSize: value.fontSize, + fontWeight: value.fontWeight, + lineHeight: value.lineHeight, + padding: value.padding, + cursor: value.cursor, + }; + }; + const container = document.querySelector('#container'); + const topBar = container?.firstElementChild ?? null; + const topBarButtons = [...(topBar?.querySelectorAll('button') ?? [])].map((element) => ({ + text: element.textContent?.trim() ?? '', + rect: rect(element), + style: style(element), + })); + const firstButton = document.querySelector('button'); + const firstInput = [...document.querySelectorAll('input')].find( + (element) => element.getBoundingClientRect().width > 0 + ); + const firstAuctionRow = document.querySelector('.auctionItem'); + const firstAuctionRowChildren = [...(firstAuctionRow?.children ?? [])].map((element) => ({ + className: element.className, + rect: rect(element), + style: style(element), + })); + const firstSection = [...document.querySelectorAll('#container > div')].find((element) => + ['쌀 구매', '쌀 판매'].includes(element.textContent?.trim() ?? '') + ); + const directChildren = [...(container?.children ?? [])].slice(0, 12).map((element) => ({ + tag: element.tagName, + className: element.className, + text: element.textContent?.trim().replace(/\s+/g, ' ').slice(0, 80) ?? '', + rect: rect(element), + })); + return { + viewport: { width: window.innerWidth, height: window.innerHeight }, + document: { + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + }, + body: { rect: rect(document.body), style: style(document.body) }, + container: { rect: rect(container), style: style(container) }, + topBar: { rect: rect(topBar), style: style(topBar) }, + topBarButtons, + firstButton: { rect: rect(firstButton), style: style(firstButton) }, + firstInput: { rect: rect(firstInput), style: style(firstInput) }, + firstAuctionRow: { rect: rect(firstAuctionRow), style: style(firstAuctionRow) }, + firstAuctionRowChildren, + firstSection: { rect: rect(firstSection), style: style(firstSection) }, + directChildren, + }; + }); + + return { + ...measurement, + body: { + ...measurement.body, + rect: measurement.body.rect ? roundedRect(measurement.body.rect) : null, + }, + container: { + ...measurement.container, + rect: measurement.container.rect ? roundedRect(measurement.container.rect) : null, + }, + topBar: { + ...measurement.topBar, + rect: measurement.topBar.rect ? roundedRect(measurement.topBar.rect) : null, + }, + topBarButtons: measurement.topBarButtons.map((button) => ({ + ...button, + rect: button.rect ? roundedRect(button.rect) : null, + })), + firstButton: { + ...measurement.firstButton, + rect: measurement.firstButton.rect ? roundedRect(measurement.firstButton.rect) : null, + }, + firstInput: { + ...measurement.firstInput, + rect: measurement.firstInput.rect ? roundedRect(measurement.firstInput.rect) : null, + }, + firstAuctionRow: { + ...measurement.firstAuctionRow, + rect: measurement.firstAuctionRow.rect ? roundedRect(measurement.firstAuctionRow.rect) : null, + }, + firstAuctionRowChildren: measurement.firstAuctionRowChildren.map((child) => ({ + ...child, + rect: child.rect ? roundedRect(child.rect) : null, + })), + firstSection: { + ...measurement.firstSection, + rect: measurement.firstSection.rect ? roundedRect(measurement.firstSection.rect) : null, + }, + directChildren: measurement.directChildren.map((child) => ({ + ...child, + rect: child.rect ? roundedRect(child.rect) : null, + })), + }; +}; + +await mkdir(outputRoot, { recursive: true }); +const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); +const results = {}; +try { + for (const viewport of viewports) { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + colorScheme: 'dark', + }); + try { + await login(context); + const page = await context.newPage(); + for (const type of ['resource', 'unique']) { + results[`${viewport.name}-${type}`] = await measurePage(page, type); + await page.screenshot({ + path: resolve(outputRoot, `${viewport.name}-${type}.png`), + fullPage: true, + }); + } + } finally { + await context.close(); + } + } +} finally { + await browser.close(); +} + +const outputPath = resolve(outputRoot, 'computed-dom.json'); +await writeFile(outputPath, `${JSON.stringify(results, null, 2)}\n`); +console.log(JSON.stringify({ outputPath, views: Object.keys(results) })); 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/auctionFlow.test.ts b/tools/integration-tests/test/auctionFlow.test.ts index 763154e..782070c 100644 --- a/tools/integration-tests/test/auctionFlow.test.ts +++ b/tools/integration-tests/test/auctionFlow.test.ts @@ -451,6 +451,10 @@ describe('auction integration flow', () => { const initialScore = await redis.zScore(keys.timerKey, String(auction.id)); expect(Number(initialScore)).toBe(initialCloseAt.getTime()); + await expect(hostClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 300 })).rejects.toThrow( + '자신이 연 경매에 입찰할 수 없습니다.' + ); + const bidder1Client = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidder1.accessToken, }); 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;