diff --git a/app/game-api/src/battleSim/inMemoryTransport.ts b/app/game-api/src/battleSim/inMemoryTransport.ts index 6ad068d..efec98c 100644 --- a/app/game-api/src/battleSim/inMemoryTransport.ts +++ b/app/game-api/src/battleSim/inMemoryTransport.ts @@ -4,16 +4,20 @@ import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportRes import { processBattleSimJob } from './processor.js'; export class InMemoryBattleSimTransport { - private readonly results = new Map(); + private readonly results = new Map(); - public async simulate(payload: BattleSimJobPayload): Promise { + public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise { const jobId = crypto.randomUUID(); const result = processBattleSimJob(payload); - this.results.set(jobId, result); + this.results.set(jobId, { requesterUserId, payload: result }); return { status: 'completed', jobId, payload: result }; } - public async getSimulationResult(jobId: string): Promise { - return this.results.get(jobId) ?? null; + public async getSimulationResult(jobId: string, requesterUserId: string): Promise { + const result = this.results.get(jobId); + if (!result || result.requesterUserId !== requesterUserId) { + return null; + } + return result.payload; } } diff --git a/app/game-api/src/battleSim/redisTransport.ts b/app/game-api/src/battleSim/redisTransport.ts index db7436e..295fcd2 100644 --- a/app/game-api/src/battleSim/redisTransport.ts +++ b/app/game-api/src/battleSim/redisTransport.ts @@ -44,16 +44,16 @@ export class RedisBattleSimTransport { this.resultTtlSeconds = options.resultTtlSeconds; } - private buildResultKey(jobId: string): string { - return `${this.keys.resultKeyPrefix}${jobId}`; + private buildResultKey(jobId: string, requesterUserId: string): string { + return `${this.keys.resultKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`; } - private buildNotifyKey(jobId: string): string { - return `${this.keys.notifyKeyPrefix}${jobId}`; + private buildNotifyKey(jobId: string, requesterUserId: string): string { + return `${this.keys.notifyKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`; } - private async readResult(jobId: string): Promise { - const raw = await this.client.get(this.buildResultKey(jobId)); + private async readResult(jobId: string, requesterUserId: string): Promise { + const raw = await this.client.get(this.buildResultKey(jobId, requesterUserId)); if (!raw) { return null; } @@ -64,44 +64,49 @@ export class RedisBattleSimTransport { } } - private async waitForResult(jobId: string, timeoutMs: number): Promise { - const existing = await this.readResult(jobId); + private async waitForResult( + jobId: string, + requesterUserId: string, + timeoutMs: number + ): Promise { + const existing = await this.readResult(jobId, requesterUserId); if (existing) { return existing; } - const notifyKey = this.buildNotifyKey(jobId); + const notifyKey = this.buildNotifyKey(jobId, requesterUserId); const timeoutSec = toTimeoutSeconds(timeoutMs); const signal = await this.client.blPop(notifyKey, timeoutSec); if (!parseBlPopValue(signal)) { return null; } - return this.readResult(jobId); + return this.readResult(jobId, requesterUserId); } - public async simulate(payload: BattleSimJobPayload): Promise { + public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise { const jobId = crypto.randomUUID(); const job = { jobId, + requesterUserId, requestedAt: new Date().toISOString(), payload, }; await this.client.rPush(this.keys.queueKey, JSON.stringify(job)); - const result = await this.waitForResult(jobId, this.requestTimeoutMs); + const result = await this.waitForResult(jobId, requesterUserId, this.requestTimeoutMs); if (result) { return { status: 'completed', jobId, payload: result }; } return { status: 'queued', jobId }; } - public async getSimulationResult(jobId: string): Promise { - return this.readResult(jobId); + public async getSimulationResult(jobId: string, requesterUserId: string): Promise { + return this.readResult(jobId, requesterUserId); } - public async pushResult(jobId: string, payload: BattleSimResultPayload): Promise { - const resultKey = this.buildResultKey(jobId); - const notifyKey = this.buildNotifyKey(jobId); + public async pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload): Promise { + const resultKey = this.buildResultKey(jobId, requesterUserId); + const notifyKey = this.buildNotifyKey(jobId, requesterUserId); await this.client.set(resultKey, JSON.stringify(payload), { EX: this.resultTtlSeconds, }); diff --git a/app/game-api/src/battleSim/transport.ts b/app/game-api/src/battleSim/transport.ts index cd6592a..0932b81 100644 --- a/app/game-api/src/battleSim/transport.ts +++ b/app/game-api/src/battleSim/transport.ts @@ -1,6 +1,6 @@ import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js'; export interface BattleSimTransport { - simulate(payload: BattleSimJobPayload): Promise; - getSimulationResult(jobId: string): Promise; + simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise; + getSimulationResult(jobId: string, requesterUserId: string): Promise; } diff --git a/app/game-api/src/battleSim/types.ts b/app/game-api/src/battleSim/types.ts index 6850b20..41d522b 100644 --- a/app/game-api/src/battleSim/types.ts +++ b/app/game-api/src/battleSim/types.ts @@ -134,6 +134,7 @@ export interface BattleSimResultPayload { export interface BattleSimJob { jobId: string; + requesterUserId: string; requestedAt: string; payload: BattleSimJobPayload; } diff --git a/app/game-api/src/battleSim/worker.ts b/app/game-api/src/battleSim/worker.ts index 920339e..7338153 100644 --- a/app/game-api/src/battleSim/worker.ts +++ b/app/game-api/src/battleSim/worker.ts @@ -18,7 +18,11 @@ const parseBlPopValue = (result: RedisBlPopResult): string | null => { return result.element ?? null; }; -export const runBattleSimWorker = async (): Promise => { +export interface BattleSimWorkerOptions { + signal?: AbortSignal; +} + +export const runBattleSimWorker = async (options: BattleSimWorkerOptions = {}): Promise => { const config = resolveGameApiConfigFromEnv(); const redis = createRedisConnector(resolveRedisConfigFromEnv()); await redis.connect(); @@ -30,35 +34,49 @@ export const runBattleSimWorker = async (): Promise => { resultTtlSeconds: config.battleSimResultTtlSeconds, }); - const handleExit = async () => { - await redis.disconnect(); + let stopped = options.signal?.aborted ?? false; + const handleExit = () => { + stopped = true; + }; + const handleAbort = () => { + stopped = true; }; process.on('SIGINT', handleExit); process.on('SIGTERM', handleExit); + options.signal?.addEventListener('abort', handleAbort, { once: true }); - while (true) { - const item = await redis.client.blPop(keys.queueKey, 0); - const raw = parseBlPopValue(item); - if (!raw) { - continue; - } + try { + while (!stopped) { + // A finite block lets SIGTERM and test AbortSignal stop the worker without + // leaving a Redis operation or a detached lifecycle process behind. + const item = await redis.client.blPop(keys.queueKey, 1); + const raw = parseBlPopValue(item); + if (!raw) { + continue; + } - let job: BattleSimJob; - try { - job = JSON.parse(raw) as BattleSimJob; - } catch { - continue; - } + let job: BattleSimJob; + try { + job = JSON.parse(raw) as BattleSimJob; + } catch { + continue; + } - try { - const result = processBattleSimJob(job.payload); - await transport.pushResult(job.jobId, result); - } catch (error) { - const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류'; - await transport.pushResult(job.jobId, { - result: false, - reason, - }); + try { + const result = processBattleSimJob(job.payload); + await transport.pushResult(job.jobId, job.requesterUserId, result); + } catch (error) { + const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류'; + await transport.pushResult(job.jobId, job.requesterUserId, { + result: false, + reason, + }); + } } + } finally { + process.off('SIGINT', handleExit); + process.off('SIGTERM', handleExit); + options.signal?.removeEventListener('abort', handleAbort); + await redis.disconnect(); } }; 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/battle/index.ts b/app/game-api/src/router/battle/index.ts index 172e595..f77da37 100644 --- a/app/game-api/src/router/battle/index.ts +++ b/app/game-api/src/router/battle/index.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { asRecord } from '@sammo-ts/common'; import { getDexLevel } from '@sammo-ts/logic'; -import { authedProcedure, procedure, router } from '../../trpc.js'; +import { authedProcedure, readOnlyAuthedProcedure, router } from '../../trpc.js'; import { buildBattleSimEnvironment, buildBattleSimJobPayload } from '../../battleSim/environment.js'; import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js'; import { @@ -30,6 +30,14 @@ const normalizeOptionalKey = (value: string | null): string | null => { return value; }; +const getAuthenticatedUserId = (auth: { user: { id: string } } | null): string => { + const userId = auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' }); + } + return userId; +}; + const resolveExpLevel = (meta: Record, experience: number): number => { const expLevel = meta.explevel ?? meta.expLevel; if (typeof expLevel === 'number' && Number.isFinite(expLevel)) { @@ -48,7 +56,7 @@ const resolveDexValue = (meta: Record, key: string): number => }; export const battleRouter = router({ - simulate: procedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => { + simulate: readOnlyAuthedProcedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => { const worldState = await ctx.db.worldState.findFirst(); if (!worldState) { throw new TRPCError({ @@ -58,10 +66,10 @@ export const battleRouter = router({ } const payload = await buildBattleSimJobPayload(worldState, input, ctx.profile.id); - return ctx.battleSim.simulate(payload); + return ctx.battleSim.simulate(payload, getAuthenticatedUserId(ctx.auth)); }), - getSimulation: procedure.input(zBattleSimJobId).query(async ({ ctx, input }) => { - const result = await ctx.battleSim.getSimulationResult(input.jobId); + getSimulation: readOnlyAuthedProcedure.input(zBattleSimJobId).query(async ({ ctx, input }) => { + const result = await ctx.battleSim.getSimulationResult(input.jobId, getAuthenticatedUserId(ctx.auth)); if (!result) { return { status: 'queued', jobId: input.jobId }; } diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 7c5328f..4405b25 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -2,7 +2,6 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { LogCategory, LogScope } from '@sammo-ts/infra'; -import type { GamePrisma } from '@sammo-ts/infra'; import { asRecord } from '@sammo-ts/common'; import { authedProcedure, router } from '../../trpc.js'; @@ -38,15 +37,19 @@ const normalizeItemCode = (value: string | null): string | null => { }; const resolveUserSettings = (meta: Record) => { - const settings = asRecord(meta.userSettings); - const mysetRaw = settings.myset; + // The legacy general columns are persisted at the top level of General.meta. + // Keep reading the short-lived nested shape for installations that ran the + // initial rewrite implementation before this compatibility fix. + const nestedSettings = asRecord(meta.userSettings); + const readSetting = (key: string): unknown => meta[key] ?? nestedSettings[key]; + const mysetRaw = readSetting('myset'); const myset = typeof mysetRaw === 'number' && Number.isFinite(mysetRaw) ? mysetRaw : null; return { - tnmt: readNumber(settings.tnmt, 1), - defence_train: readNumber(settings.defence_train, 80), - use_treatment: readNumber(settings.use_treatment, 10), - use_auto_nation_turn: readNumber(settings.use_auto_nation_turn, 1), + tnmt: readNumber(readSetting('tnmt'), 1), + defence_train: readNumber(readSetting('defence_train'), 80), + use_treatment: readNumber(readSetting('use_treatment'), 10), + use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1), myset, }; }; @@ -263,28 +266,6 @@ export const generalRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason }); } - const metaRecord = asRecord(general.meta); - const prevSettings = asRecord(metaRecord.userSettings); - const prevMyset = - typeof prevSettings.myset === 'number' && Number.isFinite(prevSettings.myset) ? prevSettings.myset : null; - const nextSettings = { - ...prevSettings, - ...input, - } as Record; - if (typeof prevMyset === 'number') { - nextSettings.myset = Math.max(0, prevMyset - 1); - } - - await ctx.db.general.update({ - where: { id: general.id }, - data: { - meta: { - ...metaRecord, - userSettings: nextSettings, - } as GamePrisma.InputJsonValue, - }, - }); - return { ok: true }; }), dropItem: authedProcedure.input(z.object({ itemType: z.string() })).mutation(async ({ ctx, input }) => { 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/router/public/index.ts b/app/game-api/src/router/public/index.ts index e7a0f59..f4ef4d8 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -42,6 +42,14 @@ type NationCountRow = { type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; +type TrafficHistoryItem = { + year: number; + month: number; + refresh: number; + online: number; + date: string; +}; + const PUBLIC_CACHE_TTL_SECONDS = 600; const buildPublicCacheKey = (ctx: GameApiContext, key: string): string => @@ -163,6 +171,26 @@ const readFiniteMetaNumber = (meta: Record, key: string): numbe return typeof value === 'number' && Number.isFinite(value) ? value : 0; }; +const parseTrafficHistory = (value: unknown): TrafficHistoryItem[] => { + if (!Array.isArray(value)) { + return []; + } + + const result: TrafficHistoryItem[] = []; + for (const item of value) { + const row = asRecord(item); + const year = readFiniteMetaNumber(row, 'year'); + const month = readFiniteMetaNumber(row, 'month'); + const refresh = readFiniteMetaNumber(row, 'refresh'); + const online = readFiniteMetaNumber(row, 'online'); + const date = typeof row.date === 'string' ? row.date : ''; + if (year > 0 && month > 0 && date) { + result.push({ year, month, refresh, online, date }); + } + } + return result; +}; + const compareString = (left: string, right: string): number => { if (left === right) { return 0; @@ -222,6 +250,95 @@ export const publicRouter = router({ getNationList: procedure.query(async ({ ctx }) => { return loadCachedNationList(ctx); }), + getTraffic: procedure.query(async ({ ctx }) => { + const worldState = await ctx.db.worldState.findFirst(); + if (!worldState) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'World state is not initialized.', + }); + } + + const meta = asRecord(worldState.meta); + const rawOnlineSince = meta.lastTurnTime ?? meta.turntime; + const parsedOnlineSince = + typeof rawOnlineSince === 'string' || rawOnlineSince instanceof Date + ? new Date(rawOnlineSince) + : null; + const onlineSince = + parsedOnlineSince && Number.isFinite(parsedOnlineSince.getTime()) + ? parsedOnlineSince + : new Date(Date.now() - worldState.tickSeconds * 1_000); + const [accessTotal, currentOnline, topAccess] = await Promise.all([ + ctx.db.generalAccessLog.aggregate({ + _sum: { + refresh: true, + refreshScoreTotal: true, + }, + }), + ctx.db.generalAccessLog.count({ + where: { + lastRefresh: { + gte: onlineSince, + }, + }, + }), + ctx.db.generalAccessLog.findMany({ + orderBy: [{ refresh: 'desc' }, { generalId: 'asc' }], + take: 5, + select: { + generalId: true, + refresh: true, + refreshScoreTotal: true, + }, + }), + ]); + + const generalIds = topAccess.map((entry) => entry.generalId); + const generalRows = + generalIds.length > 0 + ? await ctx.db.general.findMany({ + where: { id: { in: generalIds } }, + select: { id: true, name: true }, + }) + : []; + const generalName = new Map(generalRows.map((general) => [general.id, general.name])); + const totalRefresh = accessTotal._sum.refresh ?? 0; + const totalRefreshScore = accessTotal._sum.refreshScoreTotal ?? 0; + const currentRefresh = Math.max(readFiniteMetaNumber(meta, 'refresh'), totalRefresh); + const history = parseTrafficHistory(meta.recentTraffic); + history.push({ + year: worldState.currentYear, + month: worldState.currentMonth, + refresh: currentRefresh, + online: currentOnline, + date: new Date().toISOString(), + }); + + return { + history, + maxRefresh: Math.max( + 1, + readFiniteMetaNumber(meta, 'maxrefresh'), + ...history.map((entry) => entry.refresh) + ), + maxOnline: Math.max(1, readFiniteMetaNumber(meta, 'maxonline'), ...history.map((entry) => entry.online)), + suspects: [ + { + generalId: null, + name: '접속자 총합', + refresh: totalRefresh, + refreshScoreTotal: totalRefreshScore, + }, + ...topAccess.map((entry) => ({ + generalId: entry.generalId, + name: generalName.get(entry.generalId) ?? `장수 ${entry.generalId}`, + refresh: entry.refresh, + refreshScoreTotal: entry.refreshScoreTotal, + })), + ], + }; + }), getGeneralList: procedure.query(async ({ ctx }) => { const [generals, nations] = await Promise.all([ ctx.db.general.findMany({ diff --git a/app/game-api/src/router/ranking/index.ts b/app/game-api/src/router/ranking/index.ts index e73f240..b170a83 100644 --- a/app/game-api/src/router/ranking/index.ts +++ b/app/game-api/src/router/ranking/index.ts @@ -2,8 +2,11 @@ import { z } from 'zod'; import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common'; import { ITEM_KEYS, ItemLoader, loadItemModules } from '@sammo-ts/logic/items/index.js'; +import type { ItemModule } from '@sammo-ts/logic/items/types.js'; +import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; +import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js'; -import { procedure, router } from '../../trpc.js'; +import { authedProcedure, procedure, router } from '../../trpc.js'; const DEFAULT_BG_COLOR = '#2b2b2b'; const DEFAULT_FG_COLOR = '#ffffff'; @@ -23,31 +26,31 @@ const readMetaNumber = (value: unknown): number => { const percentText = (value: number): string => `${(value * 100).toFixed(2)}%`; +const readOwnerDisplayName = (value: unknown): string | null => { + const meta = asRecord(value); + if (typeof meta.ownerName === 'string' && meta.ownerName.length > 0) { + return meta.ownerName; + } + if (typeof meta.owner_name === 'string' && meta.owner_name.length > 0) { + return meta.owner_name; + } + return null; +}; + const itemLoader = new ItemLoader(); -let cachedUniqueItems: Promise< - Array<{ key: string; name: string; slot: string; unique: boolean; buyable: boolean; info: string }> -> | null = null; +let cachedUniqueItems: Promise | null = null; const loadUniqueItems = () => { if (!cachedUniqueItems) { cachedUniqueItems = loadItemModules([...ITEM_KEYS], itemLoader).then((modules) => - modules - .filter((module) => module.unique && !module.buyable) - .map((module) => ({ - key: module.key, - name: module.name, - slot: module.slot, - unique: module.unique, - buyable: module.buyable, - info: module.info, - })) + modules.filter((module) => module.unique && !module.buyable) ); } return cachedUniqueItems; }; export const rankingRouter = router({ - getBestGeneral: procedure + getBestGeneral: authedProcedure .input( z .object({ @@ -57,7 +60,7 @@ export const rankingRouter = router({ ) .query(async ({ ctx, input }) => { const worldState = await ctx.db.worldState.findFirst({ - select: { meta: true }, + select: { meta: true, config: true }, }); const meta = asRecord(worldState?.meta); const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0; @@ -76,6 +79,7 @@ export const rankingRouter = router({ userId: true, picture: true, imageServer: true, + meta: true, experience: true, dedication: true, horseCode: true, @@ -185,7 +189,7 @@ export const rankingRouter = router({ let display = { id: general.id, name: general.name, - ownerName: general.userId ?? null, + ownerName: isUnited ? readOwnerDisplayName(general.meta) : null, nationName: nation?.name ?? '재야', bgColor: nation?.color ?? DEFAULT_BG_COLOR, fgColor: DEFAULT_FG_COLOR, @@ -217,46 +221,91 @@ export const rankingRouter = router({ }); const uniqueItems = await loadUniqueItems(); - const itemEntries = uniqueItems.map((item) => { - const owners = generals.filter((general) => { - if (item.slot === 'horse') { - return general.horseCode === item.key; + const itemRegistry = new Map(uniqueItems.map((item) => [item.key, item])); + const uniqueConfig = resolveUniqueConfig(asRecord(asRecord(worldState?.config).const)); + if (Object.keys(uniqueConfig.allItems).length === 0) { + uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry); + } + const activeAuctions = await ctx.db.auction.findMany({ + where: { + type: 'UNIQUE_ITEM', + status: { in: ['OPEN', 'FINALIZING'] }, + targetCode: { not: null }, + }, + select: { targetCode: true }, + }); + const auctionCounts = new Map(); + for (const auction of activeAuctions) { + if (auction.targetCode) { + auctionCounts.set(auction.targetCode, (auctionCounts.get(auction.targetCode) ?? 0) + 1); + } + } + const slotTitles = { + horse: '명 마', + weapon: '명 검', + book: '명 서', + item: '도 구', + } as const; + const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => { + const configuredItems = Object.entries(uniqueConfig.allItems[slot] ?? {}).reverse(); + const entries = configuredItems.flatMap(([itemKey, rawCount]) => { + const item = itemRegistry.get(itemKey); + if (!item || item.buyable) { + return []; } - if (item.slot === 'weapon') { - return general.weaponCode === item.key; + const owners = generals + .filter((general) => { + if (slot === 'horse') { + return general.horseCode === itemKey; + } + if (slot === 'weapon') { + return general.weaponCode === itemKey; + } + if (slot === 'book') { + return general.bookCode === itemKey; + } + return general.itemCode === itemKey; + }) + .map((general) => { + const nation = nationMap.get(general.nationId) ?? null; + return { + id: general.id, + name: general.name, + nationName: nation?.name ?? '재야', + bgColor: nation?.color ?? DEFAULT_BG_COLOR, + fgColor: DEFAULT_FG_COLOR, + picture: general.picture ?? null, + imageServer: general.imageServer ?? 0, + }; + }); + for (let index = 0; index < (auctionCounts.get(itemKey) ?? 0); index += 1) { + owners.push({ + id: 0, + name: '경매중', + nationName: '-', + bgColor: '#00582c', + fgColor: '#ffffff', + picture: null, + imageServer: 0, + }); } - if (item.slot === 'book') { - return general.bookCode === item.key; - } - return general.itemCode === item.key; + const count = Math.max(0, Math.floor(rawCount)); + return Array.from({ length: count }, (_, index) => ({ + itemKey, + itemName: item.name, + itemInfo: item.info, + owner: owners[index] ?? { + id: 0, + name: '미발견', + nationName: '-', + bgColor: DEFAULT_BG_COLOR, + fgColor: DEFAULT_FG_COLOR, + picture: null, + imageServer: 0, + }, + })); }); - - const displayOwners = owners.length - ? owners.map((general) => { - const nation = nationMap.get(general.nationId) ?? null; - return { - id: general.id, - name: general.name, - nationName: nation?.name ?? '재야', - bgColor: nation?.color ?? DEFAULT_BG_COLOR, - fgColor: DEFAULT_FG_COLOR, - }; - }) - : [ - { - id: 0, - name: '미발견', - nationName: '-', - bgColor: DEFAULT_BG_COLOR, - fgColor: DEFAULT_FG_COLOR, - }, - ]; - - return { - title: item.name, - slot: item.slot, - owners: displayOwners, - }; + return { title: slotTitles[slot], slot, entries }; }); return { @@ -339,6 +388,10 @@ export const rankingRouter = router({ return { generalId: row.generalNo, name: String(aux.name ?? ''), + ownerName: + typeof aux.ownerDisplayName === 'string' && aux.ownerDisplayName.length > 0 + ? aux.ownerDisplayName + : null, nationName: String(aux.nationName ?? ''), bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR), fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR), diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index a8ce3e9..4a3e8dc 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -7,6 +7,21 @@ import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundar const t = initTRPC.context().create(); +const requireAuthMiddleware = t.middleware(({ ctx, next }) => { + if (!ctx.auth) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Unauthorized', + }); + } + return next({ + ctx: { + ...ctx, + auth: ctx.auth, + }, + }); +}); + const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => { if (type !== 'mutation' || !ctx.db.$transaction) { return next(); @@ -46,17 +61,8 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => { export const router = t.router; export const procedure = t.procedure.use(inputEventMiddleware); -export const authedProcedure: typeof procedure = procedure.use(({ ctx, next }) => { - if (!ctx.auth) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'Unauthorized', - }); - } - return next({ - ctx: { - ...ctx, - auth: ctx.auth, - }, - }); -}); +export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware); + +// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과 +// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다. +export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware); 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/battleSimRouter.test.ts b/app/game-api/test/battleSimRouter.test.ts index d0d0f49..3d7192d 100644 --- a/app/game-api/test/battleSimRouter.test.ts +++ b/app/game-api/test/battleSimRouter.test.ts @@ -19,19 +19,30 @@ const profile: GameProfile = { class QueuedBattleSimTransport implements BattleSimTransport { public simulateCalls = 0; public lastPayload: BattleSimJobPayload | null = null; + public lastRequesterUserId: string | null = null; + private readonly owners = new Map(); private readonly results = new Map(); - async simulate(payload: BattleSimJobPayload) { + async simulate(payload: BattleSimJobPayload, requesterUserId: string) { this.simulateCalls += 1; this.lastPayload = payload; - return { status: 'queued', jobId: 'job-1' } as const; + this.lastRequesterUserId = requesterUserId; + const jobId = `job-${this.simulateCalls}`; + this.owners.set(jobId, requesterUserId); + return { status: 'queued', jobId } as const; } - async getSimulationResult(jobId: string) { + async getSimulationResult(jobId: string, requesterUserId: string) { + if (this.owners.get(jobId) !== requesterUserId) { + return null; + } return this.results.get(jobId) ?? null; } - pushResult(jobId: string, payload: BattleSimResultPayload) { + pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload) { + if (this.owners.get(jobId) !== requesterUserId) { + throw new Error('requester mismatch'); + } this.results.set(jobId, payload); } } @@ -194,8 +205,13 @@ const buildBattleRequest = () => ({ }, }); -const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTransport }): GameApiContext => { - const db = { +const buildContext = (options: { + state: WorldStateRow; + battleSim: BattleSimTransport; + userId?: string | null; + db?: Partial; +}): GameApiContext => { + const db = options.db ?? { worldState: { findFirst: async () => options.state, }, @@ -207,20 +223,23 @@ const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTrans }, profile.name ); - const auth: GameSessionTokenPayload = { - version: 1, - profile: profile.name, - issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(), - expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(), - sessionId: 'session-1', - user: { - id: 'user-1', - username: 'tester', - displayName: 'Tester', - roles: [], - }, - sanctions: {}, - }; + const auth: GameSessionTokenPayload | null = + options.userId === null + ? null + : { + version: 1, + profile: profile.name, + issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(), + expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(), + sessionId: 'session-1', + user: { + id: options.userId ?? 'user-1', + username: 'tester', + displayName: 'Tester', + roles: [], + }, + sanctions: {}, + }; return { db: db as unknown as DatabaseClient, turnDaemon: new InMemoryTurnDaemonTransport(), @@ -255,14 +274,204 @@ describe('battle router orchestration', () => { const response = await caller.battle.simulate(buildBattleRequest()); expect(response.status).toBe('queued'); expect(battleSim.simulateCalls).toBe(1); + expect(battleSim.lastRequesterUserId).toBe('user-1'); const queued = await caller.battle.getSimulation({ jobId: response.jobId }); expect(queued.status).toBe('queued'); - battleSim.pushResult(response.jobId, { result: true, reason: 'success', avgWar: 1 }); + battleSim.pushResult(response.jobId, 'user-1', { result: true, reason: 'success', avgWar: 1 }); const completed = await caller.battle.getSimulation({ jobId: response.jobId }); expect(completed.status).toBe('completed'); expect(completed.payload?.result).toBe(true); }); + + it('requires login, allows a user without a general, and does not open an input-event transaction', async () => { + const battleSim = new QueuedBattleSimTransport(); + const state: WorldStateRow = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + updatedAt: new Date('2026-01-01T00:00:00Z'), + }; + let transactionCalls = 0; + const db = { + worldState: { findFirst: async () => state }, + $transaction: async () => { + transactionCalls += 1; + throw new Error('simulation must not create an input event transaction'); + }, + } as unknown as DatabaseClient; + + const anonymous = appRouter.createCaller(buildContext({ state, battleSim, userId: null, db })); + await expect(anonymous.battle.simulate(buildBattleRequest())).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + + const noGeneralUser = appRouter.createCaller( + buildContext({ state, battleSim, userId: 'user-without-general', db }) + ); + await expect(noGeneralUser.battle.simulate(buildBattleRequest())).resolves.toMatchObject({ + status: 'queued', + }); + expect(transactionCalls).toBe(0); + expect(battleSim.lastRequesterUserId).toBe('user-without-general'); + }); + + it('does not expose queued results across authenticated users', async () => { + const battleSim = new QueuedBattleSimTransport(); + const state: WorldStateRow = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + updatedAt: new Date('2026-01-01T00:00:00Z'), + }; + const owner = appRouter.createCaller(buildContext({ state, battleSim, userId: 'owner-user' })); + const other = appRouter.createCaller(buildContext({ state, battleSim, userId: 'other-user' })); + const response = await owner.battle.simulate(buildBattleRequest()); + battleSim.pushResult(response.jobId, 'owner-user', { result: true, reason: 'success', avgWar: 7 }); + + await expect(owner.battle.getSimulation({ jobId: response.jobId })).resolves.toMatchObject({ + status: 'completed', + payload: { avgWar: 7 }, + }); + await expect(other.battle.getSimulation({ jobId: response.jobId })).resolves.toEqual({ + status: 'queued', + jobId: response.jobId, + }); + }); +}); + +describe('battle simulator general import permissions', () => { + const state: WorldStateRow = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + updatedAt: new Date('2026-01-01T00:00:00Z'), + }; + + const buildGeneral = (overrides: Record) => ({ + id: 1, + userId: 'same-nation-user', + name: '관전자', + npcState: 0, + nationId: 1, + leadership: 70, + strength: 71, + intel: 72, + officerLevel: 1, + injury: 0, + rice: 9000, + crew: 5000, + crewTypeId: 100, + atmos: 100, + train: 100, + experience: 400, + horseCode: null, + weaponCode: null, + bookCode: null, + itemCode: null, + personalCode: null, + special2Code: null, + meta: {}, + ...overrides, + }); + + const actor = buildGeneral({ id: 1, userId: 'same-nation-user', nationId: 1 }); + const ally = buildGeneral({ + id: 2, + userId: 'ally-user', + name: '아군 장수', + nationId: 1, + officerLevel: 4, + rice: 4321, + crew: 3210, + train: 97, + atmos: 96, + horseCode: 'che_적토마', + weaponCode: 'che_의천검', + bookCode: 'che_손자병법', + itemCode: 'che_옥새', + meta: { + dex1: 10000, + rank_warnum: 33, + rank_killnum: 22, + rank_killcrew: 1111, + }, + }); + const foreignActor = buildGeneral({ id: 3, userId: 'foreign-user', nationId: 2 }); + const generals = [actor, ally, foreignActor]; + const db = { + worldState: { findFirst: async () => state }, + general: { + findFirst: async ({ where }: { where: { userId: string } }) => + generals.find((general) => general.userId === where.userId) ?? null, + findUnique: async ({ where }: { where: { id: number } }) => + generals.find((general) => general.id === where.id) ?? null, + }, + } as unknown as DatabaseClient; + + it('returns full ally details to the same nation but redacts them for another nation', async () => { + const battleSim = new QueuedBattleSimTransport(); + const sameNation = appRouter.createCaller(buildContext({ state, battleSim, userId: 'same-nation-user', db })); + const foreign = appRouter.createCaller(buildContext({ state, battleSim, userId: 'foreign-user', db })); + + const visible = await sameNation.battle.getGeneralDetail({ generalId: ally.id }); + expect(visible.general).toMatchObject({ + name: '아군 장수', + officer_level: 4, + horse: 'che_적토마', + crew: 3210, + rice: 4321, + train: 97, + atmos: 96, + warnum: 33, + killnum: 22, + killcrew: 1111, + }); + + const redacted = await foreign.battle.getGeneralDetail({ generalId: ally.id }); + expect(redacted.general).toMatchObject({ + name: '아군 장수', + officer_level: 1, + horse: null, + weapon: null, + book: null, + item: null, + crew: 0, + rice: 10000, + dex1: 0, + warnum: 0, + killnum: 0, + killcrew: 0, + }); + }); + + it('requires a game general only for server-side general import', async () => { + const caller = appRouter.createCaller( + buildContext({ + state, + battleSim: new QueuedBattleSimTransport(), + userId: 'user-without-general', + db, + }) + ); + + await expect(caller.battle.getGeneralDetail({ generalId: ally.id })).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'General not found', + }); + }); }); diff --git a/app/game-api/test/battleSimTransport.test.ts b/app/game-api/test/battleSimTransport.test.ts new file mode 100644 index 0000000..1f0eb54 --- /dev/null +++ b/app/game-api/test/battleSimTransport.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { buildBattleSimQueueKeys } from '../src/battleSim/keys.js'; +import { RedisBattleSimTransport } from '../src/battleSim/redisTransport.js'; +import type { BattleSimJob, BattleSimJobPayload } from '../src/battleSim/types.js'; + +class FakeRedisClient { + readonly values = new Map(); + readonly lists = new Map(); + + async rPush(key: string, value: string): Promise { + const list = this.lists.get(key) ?? []; + list.push(value); + this.lists.set(key, list); + return list.length; + } + + async blPop(): Promise { + return null; + } + + async set(key: string, value: string): Promise<'OK'> { + this.values.set(key, value); + return 'OK'; + } + + async get(key: string): Promise { + return this.values.get(key) ?? null; + } + + async expire(): Promise { + return 1; + } +} + +describe('RedisBattleSimTransport requester isolation', () => { + it('records the requester on queued jobs and scopes completed results to that user', async () => { + const client = new FakeRedisClient(); + const keys = buildBattleSimQueueKeys('che:test'); + const transport = new RedisBattleSimTransport(client, { + keys, + requestTimeoutMs: 1, + resultTtlSeconds: 60, + }); + + const response = await transport.simulate({} as BattleSimJobPayload, 'user/one'); + expect(response.status).toBe('queued'); + + const queuedRaw = client.lists.get(keys.queueKey)?.[0]; + expect(queuedRaw).toBeTruthy(); + expect(JSON.parse(queuedRaw ?? '{}') as BattleSimJob).toMatchObject({ + jobId: response.jobId, + requesterUserId: 'user/one', + }); + + await transport.pushResult(response.jobId, 'user/one', { + result: true, + reason: 'success', + avgWar: 3, + }); + + await expect(transport.getSimulationResult(response.jobId, 'user/one')).resolves.toMatchObject({ + result: true, + avgWar: 3, + }); + await expect(transport.getSimulationResult(response.jobId, 'user/two')).resolves.toBeNull(); + expect(Array.from(client.values.keys()).some((key) => key.includes('user%2Fone'))).toBe(true); + }); +}); diff --git a/app/game-api/test/battleSimWorker.integration.test.ts b/app/game-api/test/battleSimWorker.integration.test.ts new file mode 100644 index 0000000..6a8ccf8 --- /dev/null +++ b/app/game-api/test/battleSimWorker.integration.test.ts @@ -0,0 +1,94 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { buildBattleSimEnvironment } from '../src/battleSim/environment.js'; +import { buildBattleSimQueueKeys } from '../src/battleSim/keys.js'; +import { RedisBattleSimTransport } from '../src/battleSim/redisTransport.js'; +import type { BattleSimRequestPayload } from '../src/battleSim/types.js'; +import { runBattleSimWorker } from '../src/battleSim/worker.js'; +import type { WorldStateRow } from '../src/context.js'; + +const liveDescribe = process.env.REDIS_URL ? describe : describe.skip; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +liveDescribe('battle simulator worker with live Redis', () => { + it('consumes an isolated queue, produces a result, and stops cleanly', { timeout: 30_000 }, async () => { + const scenario = `battle-sim-e2e-${randomUUID()}`; + const profileName = `che:${scenario}`; + const requesterUserId = 'worker-e2e-user'; + vi.stubEnv('PROFILE', 'che'); + vi.stubEnv('SCENARIO', scenario); + vi.stubEnv('GAME_TOKEN_SECRET', 'battle-sim-test-only'); + + const fixturePath = path.resolve( + process.cwd(), + '../../tools/integration-tests/fixtures/battle/basic-infantry.json' + ); + const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8')) as BattleSimRequestPayload & { + startYear: number; + }; + const { startYear, ...request } = fixture; + const worldState: WorldStateRow = { + id: 1, + scenarioCode: 'default', + currentYear: request.year, + currentMonth: request.month, + tickSeconds: 600, + config: {}, + meta: { scenarioMeta: { startYear } }, + updatedAt: new Date(), + }; + const environment = await buildBattleSimEnvironment(worldState, 'che'); + const payload = { + ...request, + unitSet: environment.unitSet, + config: environment.config, + time: { year: request.year, month: request.month, startYear }, + }; + + const clientConnector = createRedisConnector(resolveRedisConfigFromEnv()); + await clientConnector.connect(); + const keys = buildBattleSimQueueKeys(profileName); + const transport = new RedisBattleSimTransport(clientConnector.client, { + keys, + requestTimeoutMs: 15_000, + resultTtlSeconds: 60, + }); + const abortController = new AbortController(); + const worker = runBattleSimWorker({ signal: abortController.signal }); + let jobId: string | null = null; + + try { + const result = await transport.simulate(payload, requesterUserId); + jobId = result.jobId; + expect(result.status).toBe('completed'); + if (result.status === 'completed') { + expect(result.payload).toMatchObject({ + result: true, + reason: 'success', + avgWar: 1, + }); + expect(result.payload.phase).toBeGreaterThan(0); + } + } finally { + abortController.abort(); + await worker; + if (jobId) { + const encodedRequester = encodeURIComponent(requesterUserId); + await clientConnector.client.del([ + keys.queueKey, + `${keys.resultKeyPrefix}${encodedRequester}:${jobId}`, + `${keys.notifyKeyPrefix}${encodedRequester}:${jobId}`, + ]); + } + await clientConnector.disconnect(); + } + }); +}); diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts new file mode 100644 index 0000000..ea2263c --- /dev/null +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { 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 now = new Date('2026-01-01T00:00:00.000Z'); +const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ + id: 7, + userId: 'user-7', + name: '검증장수', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: 'default.jpg', + imageServer: 0, + leadership: 70, + strength: 60, + intel: 50, + injury: 0, + experience: 10, + dedication: 20, + officerLevel: 1, + gold: 1_000, + rice: 1_000, + crew: 100, + crewTypeId: 0, + train: 80, + atmos: 80, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: now, + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: { + belong: 1, + permission: 'normal', + myset: 3, + tnmt: 0, + defence_train: 80, + use_treatment: 21, + use_auto_nation_turn: 1, + }, + penalty: {}, + createdAt: now, + updatedAt: now, + ...overrides, +}); + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:default', + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + 86_400_000).toISOString(), + sessionId: 'session-7', + user: { id: 'user-7', username: 'tester', displayName: 'Tester', roles: [] }, + sanctions: {}, +}; + +const createContext = (options: { + me?: GeneralRow; + targets?: GeneralRow[]; + nationMeta?: Record; + requestCommand?: ReturnType; +}) => { + const me = options.me ?? buildGeneral(); + const targets = options.targets ?? [me]; + const requestCommand = + options.requestCommand ?? vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: me.id })); + const generalFindUnique = vi.fn( + async ({ where }: { where: { id: number } }) => targets.find((general) => general.id === where.id) ?? null + ); + const db = { + general: { + findFirst: vi.fn(async () => me), + findUnique: generalFindUnique, + findMany: vi.fn(async () => targets.filter((general) => general.nationId === me.nationId)), + update: vi.fn(), + }, + city: { findUnique: vi.fn(async () => null) }, + nation: { + findUnique: vi.fn(async () => ({ + id: 1, + name: '위', + color: '#777777', + level: 3, + gold: 10_000, + rice: 20_000, + tech: 100, + typeCode: 'che_법가', + capitalCityId: 1, + meta: options.nationMeta ?? { secretlimit: 3 }, + })), + }, + worldState: { + findFirst: vi.fn(async () => ({ + currentYear: 185, + currentMonth: 1, + tickSeconds: 600, + })), + }, + logEntry: { + groupBy: vi.fn(async () => []), + findMany: vi.fn(async () => [{ id: 1, text: '기록' }]), + }, + }; + const redisClient = { get: async () => null, set: async () => null }; + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis: {} 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: new RedisAccessTokenStore(redisClient, 'che:default'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + return { context, db, requestCommand }; +}; + +describe('in-game my information ownership', () => { + it('reads legacy top-level settings and dispatches only the session-owned general', async () => { + const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 })); + const fixture = createContext({ requestCommand }); + const caller = appRouter.createCaller(fixture.context); + + const me = await caller.general.me(); + expect(me?.settings).toEqual({ + tnmt: 0, + defence_train: 80, + use_treatment: 21, + use_auto_nation_turn: 1, + myset: 3, + }); + + await caller.general.setMySetting({ tnmt: 1, defence_train: 999 }); + expect(requestCommand).toHaveBeenCalledWith({ + type: 'setMySetting', + generalId: 7, + settings: { tnmt: 1, defence_train: 999 }, + }); + expect(fixture.db.general.update).not.toHaveBeenCalled(); + }); + + it('uses the authenticated user for both the page and its logs without accepting a target general id', async () => { + const otherUser = buildGeneral({ id: 8, userId: 'user-8', name: '타유저' }); + const fixture = createContext({ targets: [buildGeneral(), otherUser] }); + const caller = appRouter.createCaller(fixture.context); + + await expect(caller.general.me()).resolves.toMatchObject({ + general: { id: 7, name: '검증장수' }, + }); + await expect(caller.general.getMyLog({ type: 'generalAction' })).resolves.toMatchObject({ + type: 'generalAction', + logs: [{ id: 1 }], + }); + + expect(fixture.db.general.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: 'user-7' }, + }) + ); + expect(fixture.db.logEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ generalId: 7 }), + }) + ); + }); +}); + +describe('battle-center general and user permissions', () => { + it('distinguishes an ordinary member, a tenured member, and an auditor', async () => { + const ordinary = createContext({ + me: buildGeneral({ officerLevel: 1, meta: { belong: 1, permission: 'normal' } }), + nationMeta: { secretlimit: 3 }, + }); + await expect(appRouter.createCaller(ordinary.context).nation.getBattleCenter()).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + + const tenured = createContext({ + me: buildGeneral({ officerLevel: 1, meta: { belong: 3, permission: 'normal' } }), + nationMeta: { secretlimit: 3 }, + }); + await expect(appRouter.createCaller(tenured.context).nation.getBattleCenter()).resolves.toMatchObject({ + me: { id: 7, permissionLevel: 1 }, + }); + + const auditor = createContext({ + me: buildGeneral({ officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }), + nationMeta: { secretlimit: 3 }, + }); + await expect(appRouter.createCaller(auditor.context).nation.getBattleCenter()).resolves.toMatchObject({ + me: { id: 7, permissionLevel: 3 }, + }); + }); + + it('redacts another user action log while allowing own, NPC, chief, and non-private logs', async () => { + const me = buildGeneral({ meta: { belong: 3, permission: 'normal' } }); + const otherUser = buildGeneral({ id: 8, userId: 'user-8', name: '타유저', npcState: 0 }); + const npc = buildGeneral({ id: 9, userId: null, name: 'NPC', npcState: 2 }); + const foreign = buildGeneral({ id: 10, userId: 'user-10', name: '타국', nationId: 2 }); + const memberFixture = createContext({ + me, + targets: [me, otherUser, npc, foreign], + nationMeta: { secretlimit: 3 }, + }); + const member = appRouter.createCaller(memberFixture.context); + + await expect(member.nation.getGeneralLog({ generalId: me.id, type: 'generalAction' })).resolves.toMatchObject({ + generalId: me.id, + }); + await expect( + member.nation.getGeneralLog({ generalId: otherUser.id, type: 'generalAction' }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect( + member.nation.getGeneralLog({ generalId: otherUser.id, type: 'battleDetail' }) + ).resolves.toMatchObject({ generalId: otherUser.id }); + await expect(member.nation.getGeneralLog({ generalId: npc.id, type: 'generalAction' })).resolves.toMatchObject({ + generalId: npc.id, + }); + await expect( + member.nation.getGeneralLog({ generalId: foreign.id, type: 'battleDetail' }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + + const chiefFixture = createContext({ + me: buildGeneral({ officerLevel: 5 }), + targets: [buildGeneral({ officerLevel: 5 }), otherUser], + nationMeta: { secretlimit: 3 }, + }); + await expect( + appRouter + .createCaller(chiefFixture.context) + .nation.getGeneralLog({ generalId: otherUser.id, type: 'generalAction' }) + ).resolves.toMatchObject({ generalId: otherUser.id }); + }); +}); diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index 9ba2940..b96645c 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -30,7 +30,7 @@ const auth: GameSessionTokenPayload = { sanctions: {}, }; -const buildContext = (overrides: Record = {}) => { +const buildContext = (overrides: Record = {}, contextOverrides: Record = {}) => { const executeRaw = vi.fn(async () => 1); const updateMany = vi.fn(async () => ({ count: 1 })); const db = { @@ -55,11 +55,15 @@ const buildContext = (overrides: Record = {}) => { $executeRaw: executeRaw, ...overrides, }; + const redis = { + set: vi.fn(async () => 'OK'), + publish: vi.fn(async () => 1), + }; const context = { db, auth, profile: { id: 'che', scenario: 'default', name: 'che:default' }, - redis: {}, + redis, turnDaemon: {}, battleSim: {}, uploadDir: 'uploads', @@ -68,8 +72,9 @@ const buildContext = (overrides: Record = {}) => { accessTokenStore: {}, flushStore: {}, gameTokenSecret: 'test-secret', + ...contextOverrides, } as unknown as GameApiContext; - return { caller: appRouter.createCaller(context), db, executeRaw, updateMany }; + return { caller: appRouter.createCaller(context), db, executeRaw, updateMany, redis }; }; describe('messages router missing-flow compatibility', () => { @@ -99,6 +104,291 @@ describe('messages router missing-flow compatibility', () => { expect(result.canRespondDiplomacy).toBe(true); }); + it('lists an appointed ambassador as permission 4 but keeps responses limited to officers', async () => { + const ambassador = { + ...general, + officerLevel: 1, + meta: { permission: 'ambassador' }, + } as GeneralRow; + const { caller } = buildContext({ + general: { + findUnique: vi.fn(async () => ambassador), + findMany: vi.fn(async () => []), + }, + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async () => ({ meta: {} })), + }, + }); + + const result = await caller.messages.getRecent({ generalId: ambassador.id }); + + expect(result.permission).toBe(4); + expect(result.canRespondDiplomacy).toBe(false); + }); + + it('redacts recent and old diplomacy content below secret permission 3', async () => { + const diplomacyRow = { + id: 19, + mailbox: 9001, + type: 'diplomacy', + src: 9002, + dest: 9001, + time: new Date(), + valid_until: new Date('9999-12-31T00:00:00Z'), + message: { + src: { + generalId: 8, + generalName: '외교관', + nationId: 2, + nationName: '촉', + color: '#000000', + icon: '', + }, + dest: { + generalId: 0, + generalName: '', + nationId: 1, + nationName: '위', + color: '#ffffff', + icon: '', + }, + text: '보이면 안 되는 외교 본문', + option: { action: 'noAggression' }, + }, + }; + const queryRaw = vi.fn(async () => [diplomacyRow]); + const { caller } = buildContext({ + $queryRaw: queryRaw, + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async () => ({ meta: {} })), + }, + }); + + const recent = await caller.messages.getRecent({ generalId: general.id }); + const old = await caller.messages.getOld({ + generalId: general.id, + type: 'diplomacy', + to: 20, + }); + + expect(recent.permission).toBe(2); + expect(recent.diplomacy[0]).toMatchObject({ + text: '(외교 메시지입니다)', + option: { action: 'noAggression', invalid: true }, + }); + expect(old.diplomacy[0]).toMatchObject({ + text: '(외교 메시지입니다)', + option: { action: 'noAggression', invalid: true }, + }); + }); + + it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => { + const queryRaw = vi.fn(async () => [{ id: 51 }]); + const findNation = vi.fn(async ({ where }: { where: { id: number } }) => ({ + id: where.id, + name: where.id === 1 ? '위' : '촉', + color: '#112233', + meta: {}, + })); + const { caller } = buildContext({ + $queryRaw: queryRaw, + nation: { + findMany: vi.fn(async () => []), + findUnique: findNation, + }, + }); + + const result = await caller.messages.send({ + generalId: general.id, + mailbox: 9002, + text: '국가 메시지', + }); + + expect(result.msgType).toBe('national'); + expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national'])); + }); + + it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => { + const ambassador = { + ...general, + officerLevel: 1, + meta: { permission: 'ambassador' }, + } as GeneralRow; + const queryRaw = vi.fn(async () => [{ id: 52 }]); + const { caller } = buildContext({ + $queryRaw: queryRaw, + general: { + findUnique: vi.fn(async () => ambassador), + findMany: vi.fn(async () => []), + }, + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => ({ + id: where.id, + name: where.id === 1 ? '위' : '촉', + color: '#112233', + meta: {}, + })), + }, + }); + + const result = await caller.messages.send({ + generalId: ambassador.id, + mailbox: 9002, + text: '외교 메시지', + }); + + expect(result.msgType).toBe('diplomacy'); + expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy'])); + }); + + it('blocks private messages between foreign ambassadors', async () => { + const ambassador = { + ...general, + officerLevel: 1, + meta: { permission: 'ambassador' }, + } as GeneralRow; + const foreignAmbassador = { + ...ambassador, + id: 8, + userId: 'user-8', + name: '상대 외교관', + nationId: 2, + } as GeneralRow; + const { caller } = buildContext({ + general: { + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => + where.id === ambassador.id ? ambassador : foreignAmbassador + ), + findMany: vi.fn(async () => []), + }, + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => ({ + id: where.id, + name: where.id === 1 ? '위' : '촉', + color: '#112233', + meta: {}, + })), + }, + }); + + await expect( + caller.messages.send({ + generalId: ambassador.id, + mailbox: foreignAmbassador.id, + text: '개인 메시지', + }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + message: '외교권자끼리는 메시지를 보낼 수 없습니다.', + }); + }); + + it.each([ + ['public', { noSendPublicMsg: 1 }, 9999, '공개 메세지를 보낼 수 없습니다.'], + ['private', { noSendPrivateMsg: 1 }, 8, '개인 메세지를 보낼 수 없습니다.'], + ])('enforces the general %s-message penalty', async (_type, penalty, mailbox, message) => { + const penalized = { ...general, penalty } as GeneralRow; + const { caller } = buildContext({ + general: { + findUnique: vi.fn(async () => penalized), + findMany: vi.fn(async () => []), + }, + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#fff', meta: {} })), + }, + }); + + await expect( + caller.messages.send({ + generalId: penalized.id, + mailbox, + text: '차단 메시지', + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN', message }); + }); + + it('enforces the legacy private-message interval through Redis without touching lifecycle', async () => { + const redis = { + set: vi.fn(async () => null), + publish: vi.fn(async () => 1), + }; + const { caller } = buildContext( + { + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#fff', meta: {} })), + }, + }, + { redis } + ); + + await expect( + caller.messages.send({ + generalId: general.id, + mailbox: 8, + text: '너무 빠른 메시지', + }) + ).rejects.toMatchObject({ + code: 'TOO_MANY_REQUESTS', + message: '개인메세지는 2초당 1건만 보낼 수 있습니다!', + }); + }); + + it('blocks sends for a muted authenticated user independently of general permission', async () => { + const mutedAuth = { + ...auth, + sanctions: { mutedUntil: '2099-01-01T00:00:00.000Z' }, + }; + const { caller } = buildContext({}, { auth: mutedAuth }); + + await expect( + caller.messages.send({ + generalId: general.id, + mailbox: 9999, + text: '사용자 mute', + }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + message: '메시지 전송이 제한된 계정입니다.', + }); + }); + + it('rejects every remaining general-scoped message mutation for another user general', async () => { + const foreignGeneral = { ...general, userId: 'user-8' } as GeneralRow; + const { caller } = buildContext({ + general: { + findUnique: vi.fn(async () => foreignGeneral), + findMany: vi.fn(async () => []), + }, + }); + + await expect(caller.messages.getContacts({ generalId: foreignGeneral.id })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect( + caller.messages.readLatest({ + generalId: foreignGeneral.id, + type: 'private', + messageId: 1, + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect(caller.messages.delete({ generalId: foreignGeneral.id, messageId: 1 })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect( + caller.messages.respond({ + generalId: foreignGeneral.id, + messageId: 1, + response: true, + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + it('persists latest-read updates through the monotonic upsert', async () => { const { caller, executeRaw } = buildContext(); @@ -154,6 +444,49 @@ describe('messages router missing-flow compatibility', () => { }); }); + it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => { + const queryRaw = vi.fn(async () => [ + { + id: 25, + mailbox: 9001, + type: 'diplomacy', + src: 9001, + dest: 9002, + time: new Date(), + valid_until: new Date('9999-12-31T00:00:00Z'), + message: { + src: { + generalId: general.id, + generalName: general.name, + nationId: 1, + nationName: '위', + color: '#fff', + icon: '', + }, + dest: { + generalId: 0, + generalName: '', + nationId: 2, + nationName: '촉', + color: '#000', + icon: '', + }, + text: '일반 외교 메시지', + option: { receiverMessageID: 26 }, + }, + }, + ]); + const { caller, updateMany } = buildContext({ $queryRaw: queryRaw }); + + const result = await caller.messages.delete({ generalId: general.id, messageId: 25 }); + + expect(result.deletedIds).toEqual([25]); + expect(updateMany).toHaveBeenCalledWith({ + where: { id: { in: [25] } }, + data: { validUntil: expect.any(Date) }, + }); + }); + it('rejects deleting another general message', async () => { const queryRaw = vi.fn(async () => [ { diff --git a/app/game-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-api/test/publicTraffic.test.ts b/app/game-api/test/publicTraffic.test.ts new file mode 100644 index 0000000..78008d8 --- /dev/null +++ b/app/game-api/test/publicTraffic.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; + +import type { RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; +import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js'; +import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { appRouter } from '../src/router.js'; + +const profile: GameProfile = { + id: 'che', + scenario: 'default', + name: 'che:default', +}; + +const buildContext = (): GameApiContext => { + const db = { + worldState: { + findFirst: async () => ({ + id: 1, + currentYear: 185, + currentMonth: 3, + tickSeconds: 600, + config: {}, + meta: { + lastTurnTime: '2026-07-26T03:00:00.000Z', + refresh: 12, + maxrefresh: 30, + maxonline: 5, + recentTraffic: [ + { + year: 185, + month: 2, + refresh: 30, + online: 5, + date: '2026-07-26 02:50:00', + }, + ], + }, + }), + }, + generalAccessLog: { + aggregate: async () => ({ + _sum: { + refresh: 12, + refreshScoreTotal: 21, + }, + }), + count: async (args: { where: { lastRefresh: { gte: Date } } }) => { + expect(args.where.lastRefresh.gte).toEqual(new Date('2026-07-26T03:00:00.000Z')); + return 2; + }, + findMany: async () => [ + { generalId: 7, refresh: 9, refreshScoreTotal: 15 }, + { generalId: 8, refresh: 3, refreshScoreTotal: 6 }, + ], + }, + general: { + findMany: async () => [ + { id: 7, name: '갑' }, + { id: 8, name: '을' }, + ], + }, + }; + const redis = { + get: async () => null, + set: async () => null, + } as unknown as RedisConnector['client']; + + return { + db: db as unknown as DatabaseClient, + turnDaemon: new InMemoryTurnDaemonTransport(), + battleSim: new InMemoryBattleSimTransport(), + profile, + auth: null, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + redis, + accessTokenStore: new RedisAccessTokenStore(redis, profile.name), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; +}; + +describe('public.getTraffic', () => { + it('is public and returns only aggregate traffic plus allowlisted general names', async () => { + const result = await appRouter.createCaller(buildContext()).public.getTraffic(); + + expect(result.history).toHaveLength(2); + expect(result.history[0]).toEqual({ + year: 185, + month: 2, + refresh: 30, + online: 5, + date: '2026-07-26 02:50:00', + }); + expect(result.history[1]).toMatchObject({ + year: 185, + month: 3, + refresh: 12, + online: 2, + }); + expect(result.maxRefresh).toBe(30); + expect(result.maxOnline).toBe(5); + expect(result.suspects).toEqual([ + { generalId: null, name: '접속자 총합', refresh: 12, refreshScoreTotal: 21 }, + { generalId: 7, name: '갑', refresh: 9, refreshScoreTotal: 15 }, + { generalId: 8, name: '을', refresh: 3, refreshScoreTotal: 6 }, + ]); + expect(JSON.stringify(result)).not.toContain('userId'); + }); +}); diff --git a/app/game-api/test/rankingRouter.test.ts b/app/game-api/test/rankingRouter.test.ts new file mode 100644 index 0000000..7f897a6 --- /dev/null +++ b/app/game-api/test/rankingRouter.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; +import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js'; +import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { appRouter } from '../src/router.js'; + +const profile: GameProfile = { + id: 'che', + scenario: 'default', + name: 'che:default', +}; + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che', + issuedAt: '2026-07-26T00:00:00.000Z', + expiresAt: '2026-07-27T00:00:00.000Z', + sessionId: 'ranking-session', + user: { + id: 'request-user-id', + username: 'ranking-user', + displayName: '조회자', + roles: [], + }, + sanctions: {}, +}; + +const generalRows = [ + { + id: 1, + name: '유비', + nationId: 1, + userId: 'private-user-id-1', + npcState: 0, + picture: '1.jpg', + imageServer: 0, + meta: { ownerName: '공개소유자' }, + experience: 1200, + dedication: 900, + horseCode: 'che_명마_15_적토마', + weaponCode: 'None', + bookCode: 'None', + itemCode: 'None', + }, + { + id: 2, + name: '빙의관우', + nationId: 1, + userId: 'private-user-id-2', + npcState: 1, + picture: null, + imageServer: 0, + meta: { owner_name: '빙의소유자' }, + experience: 1100, + dedication: 800, + horseCode: 'None', + weaponCode: 'None', + bookCode: 'None', + itemCode: 'None', + }, + { + id: 3, + name: 'NPC조조', + nationId: 2, + userId: null, + npcState: 2, + picture: null, + imageServer: 0, + meta: {}, + experience: 1300, + dedication: 1000, + horseCode: 'None', + weaponCode: 'None', + bookCode: 'None', + itemCode: 'None', + }, +] as const; + +const buildContext = (options?: { + authenticated?: boolean; + isUnited?: boolean; + includeOwnerDisplayName?: boolean; +}): GameApiContext => { + const db = { + worldState: { + findFirst: async () => ({ + meta: { isUnited: options?.isUnited ? 1 : 0 }, + config: { + const: { + allItems: { + horse: { che_명마_15_적토마: 2 }, + weapon: {}, + book: {}, + item: {}, + }, + }, + }, + }), + }, + nation: { + findMany: async () => [ + { id: 1, name: '촉', color: '#006400' }, + { id: 2, name: '위', color: '#8b0000' }, + ], + }, + general: { + findMany: async (args: { where: { npcState: { lt?: number; gte?: number } } }) => + generalRows.filter((general) => + args.where.npcState.gte !== undefined + ? general.npcState >= args.where.npcState.gte + : general.npcState < (args.where.npcState.lt ?? Number.POSITIVE_INFINITY) + ), + }, + rankData: { + findMany: async () => [ + { generalId: 1, type: 'firenum', value: 10 }, + { generalId: 2, type: 'firenum', value: 20 }, + { generalId: 3, type: 'firenum', value: 30 }, + ], + }, + auction: { + findMany: async () => [{ targetCode: 'che_명마_15_적토마' }], + }, + gameHistory: { + findMany: async () => [ + { season: 3, scenario: 22, scenarioName: '가상모드22' }, + { season: 3, scenario: 22, scenarioName: '가상모드22' }, + ], + }, + hallOfFame: { + findMany: async (args: { where: { type: string } }) => + args.where.type === 'experience' + ? [ + { + generalNo: 1, + value: 1200, + aux: { + name: '유비', + ownerName: 'private-hall-user-id', + ...(options?.includeOwnerDisplayName ? { ownerDisplayName: '공개소유자' } : {}), + nationName: '촉', + bgColor: '#006400', + fgColor: '#ffffff', + }, + }, + ] + : [], + }, + }; + const redis = { + get: async () => null, + set: async () => null, + } as unknown as RedisConnector['client']; + + return { + db: db as unknown as DatabaseClient, + turnDaemon: new InMemoryTurnDaemonTransport(), + battleSim: new InMemoryBattleSimTransport(), + profile, + auth: options?.authenticated === false ? null : auth, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + redis, + accessTokenStore: new RedisAccessTokenStore(redis, profile.name), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; +}; + +describe('ranking.getBestGeneral', () => { + it('requires a game login even though the ranking is the same for every authenticated user', async () => { + await expect( + appRouter.createCaller(buildContext({ authenticated: false })).ranking.getBestGeneral({ view: 'user' }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('keeps possessed generals in the user view and redacts account identifiers before unification', async () => { + const result = await appRouter.createCaller(buildContext({ isUnited: false })).ranking.getBestGeneral({ + view: 'user', + }); + + expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([1, 2]); + expect(result.sections[0]?.entries.map((entry) => entry.ownerName)).toEqual([null, null]); + expect(result.sections.find((section) => section.title === '계 략 성 공')?.entries).toEqual([ + expect.objectContaining({ id: 2, name: '???', nationName: '???', ownerName: null }), + expect.objectContaining({ id: 1, name: '???', nationName: '???', ownerName: null }), + ]); + expect(JSON.stringify(result)).not.toContain('private-user-id'); + }); + + it('uses display names only after unification and preserves configured item copies plus auctions', async () => { + const result = await appRouter.createCaller(buildContext({ isUnited: true })).ranking.getBestGeneral({ + view: 'user', + }); + + expect(result.sections[0]?.entries.map((entry) => entry.ownerName)).toEqual(['공개소유자', '빙의소유자']); + expect(result.uniqueItems.find((section) => section.slot === 'horse')?.entries).toEqual([ + expect.objectContaining({ + itemKey: 'che_명마_15_적토마', + owner: expect.objectContaining({ id: 1, name: '유비' }), + }), + expect.objectContaining({ + itemKey: 'che_명마_15_적토마', + owner: expect.objectContaining({ id: 0, name: '경매중' }), + }), + ]); + expect(JSON.stringify(result)).not.toContain('private-user-id'); + }); + + it('separates autonomous NPCs from users and possessed generals', async () => { + const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'npc' }); + expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([3]); + }); +}); + +describe('ranking hall of fame', () => { + it('remains public and groups scenario counts', async () => { + const options = await appRouter + .createCaller(buildContext({ authenticated: false })) + .ranking.getHallOfFameOptions(); + expect(options).toEqual([ + { + season: 3, + scenarios: [{ id: 22, name: '가상모드22', count: 2 }], + }, + ]); + }); + + it('returns an explicit display name but never exposes the stored account identifier', async () => { + const result = await appRouter + .createCaller(buildContext({ authenticated: false, includeOwnerDisplayName: true })) + .ranking.getHallOfFame({ season: 3 }); + expect(result.sections[0]?.entries[0]?.ownerName).toBe('공개소유자'); + expect(JSON.stringify(result)).not.toContain('private-hall-user-id'); + + const redacted = await appRouter + .createCaller(buildContext({ authenticated: false })) + .ranking.getHallOfFame({ season: 3 }); + expect(redacted.sections[0]?.entries[0]?.ownerName).toBeNull(); + }); +}); 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-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index fd32464..8bffaf2 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -809,9 +809,35 @@ async function handleVacation( if (!general) { return { type: 'vacation', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; } + const autorunUser = asRecord(world.getState().meta.autorun_user); + if (autorunUser.limit_minutes) { + return { + type: 'vacation', + ok: false, + generalId: command.generalId, + reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.', + }; + } + const killturn = readMetaNumber(asRecord(world.getState().meta), 'killturn', 0); + world.updateGeneral(general.id, { + meta: { + ...general.meta, + killturn: killturn * 3, + }, + }); return { type: 'vacation', ok: true, generalId: command.generalId }; } +const normalizeDefenceTrain = (value: number): number => { + if (value <= 40) { + return 40; + } + if (value <= 90) { + return Math.round(value / 10) * 10; + } + return 999; +}; + async function handleSetMySetting( ctx: CommandHandlerContext, command: Extract @@ -826,11 +852,48 @@ async function handleSetMySetting( reason: '장수 정보를 찾을 수 없습니다.', }; } + + const settings = command.settings; + const previousDefenceTrain = readMetaNumber(general.meta, 'defence_train', 80); + const nextDefenceTrain = + settings.defence_train === undefined ? previousDefenceTrain : normalizeDefenceTrain(settings.defence_train); + const nextMeta = { ...general.meta }; + + if (settings.tnmt !== undefined) { + nextMeta.tnmt = settings.tnmt < 0 || settings.tnmt > 1 ? 1 : settings.tnmt; + } + if (settings.use_treatment !== undefined) { + nextMeta.use_treatment = Math.max(10, Math.min(100, settings.use_treatment)); + } + if (settings.use_auto_nation_turn !== undefined) { + nextMeta.use_auto_nation_turn = settings.use_auto_nation_turn; + } + + let nextTrain = general.train; + let nextAtmos = general.atmos; + if (nextDefenceTrain !== previousDefenceTrain) { + nextMeta.myset = readMetaNumber(general.meta, 'myset', 0) - 1; + nextMeta.defence_train = nextDefenceTrain; + if (nextDefenceTrain === 999) { + const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect; + const ignoresPenalty = + scenarioEffect === 'event_UnlimitedDefenceThresholdChange' || + scenarioEffect === 'event_StrongAttacker' || + scenarioEffect === 'event_MoreEffect'; + const constValues = asRecord(world.getScenarioConfig().const); + const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 100); + const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 100); + const trainDelta = ignoresPenalty ? 0 : -3; + const atmosDelta = ignoresPenalty ? 0 : -6; + nextTrain = Math.max(20, Math.min(maxTrain, general.train + trainDelta)); + nextAtmos = Math.max(20, Math.min(maxAtmos, general.atmos + atmosDelta)); + } + } + world.updateGeneral(command.generalId, { - meta: { - ...general.meta, - ...command.settings, - }, + meta: nextMeta, + train: nextTrain, + atmos: nextAtmos, }); return { type: 'setMySetting', ok: true, generalId: command.generalId }; } @@ -844,10 +907,8 @@ async function handleDropItem( if (!general) { return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; } - const slot = (['horse', 'weapon', 'book', 'item'] as const).find( - (candidate) => general.role.items[candidate] === command.itemType - ); - if (!slot) { + const slot = (['horse', 'weapon', 'book', 'item'] as const).find((candidate) => candidate === command.itemType); + if (!slot || !general.role.items[slot]) { return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템을 가지고 있지 않습니다.' }; } const nextGeneral = { diff --git a/app/game-engine/test/myInformationCommands.test.ts b/app/game-engine/test/myInformationCommands.test.ts new file mode 100644 index 0000000..86c72b8 --- /dev/null +++ b/app/game-engine/test/myInformationCommands.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; + +import type { TurnSchedule } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; + +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; + +const buildGeneral = (overrides: Partial = {}): TurnGeneral => ({ + id: 7, + userId: 'user-7', + name: '테스트장수', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + turnTime: new Date('0185-01-01T00:00:00Z'), + recentWarTime: null, + role: { + items: { horse: 'che_명마', weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { + killturn: 12, + myset: 3, + defence_train: 80, + tnmt: 0, + use_treatment: 10, + use_auto_nation_turn: 1, + }, + penalty: {}, + officerLevel: 1, + experience: 0, + dedication: 0, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 100, + crewTypeId: 0, + train: 90, + atmos: 90, + age: 20, + npcState: 0, + ...overrides, +}); + +const buildWorld = ( + general = buildGeneral(), + options: { autorunLimit?: boolean; scenarioEffect?: string | null } = {} +) => { + const state: TurnWorldState = { + id: 1, + currentYear: 185, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0185-01-01T00:00:00Z'), + meta: { + killturn: 24, + autorun_user: options.autorunLimit ? { limit_minutes: 60 } : {}, + }, + }; + const snapshot: TurnWorldSnapshot = { + generals: [general], + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 }, + iconPath: '', + map: {}, + const: { maxTrainByWar: 100, maxAtmosByWar: 100 }, + environment: { + mapName: 'test', + unitSet: 'test', + ...(options.scenarioEffect !== undefined ? { scenarioEffect: options.scenarioEffect } : {}), + }, + }, + scenarioMeta: { + title: 'test', + startYear: 180, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + }; + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + return { world, handler: createTurnDaemonCommandHandler({ world }) }; +}; + +describe('my information world commands', () => { + it('normalizes legacy settings and charges myset only when defence mode changes', async () => { + const fixture = buildWorld(); + + await expect( + fixture.handler.handle({ + type: 'setMySetting', + generalId: 7, + settings: { + tnmt: 9, + defence_train: 94, + use_treatment: 200, + use_auto_nation_turn: 0, + }, + }) + ).resolves.toMatchObject({ ok: true }); + + expect(fixture.world.getGeneralById(7)).toMatchObject({ + train: 87, + atmos: 84, + meta: { + tnmt: 1, + defence_train: 999, + use_treatment: 100, + use_auto_nation_turn: 0, + myset: 2, + }, + }); + + await fixture.handler.handle({ + type: 'setMySetting', + generalId: 7, + settings: { tnmt: 0, defence_train: 999, use_treatment: 1 }, + }); + expect(fixture.world.getGeneralById(7)?.meta).toMatchObject({ + tnmt: 0, + use_treatment: 10, + myset: 2, + }); + }); + + it('preserves the event scenarios that waive the no-defence penalty', async () => { + const fixture = buildWorld(buildGeneral(), { scenarioEffect: 'event_StrongAttacker' }); + await fixture.handler.handle({ + type: 'setMySetting', + generalId: 7, + settings: { defence_train: 999 }, + }); + expect(fixture.world.getGeneralById(7)).toMatchObject({ train: 90, atmos: 90 }); + }); + + it('applies vacation killturn and rejects it in automatic-turn mode', async () => { + const allowed = buildWorld(); + await expect(allowed.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({ ok: true }); + expect(allowed.world.getGeneralById(7)?.meta.killturn).toBe(72); + + const blocked = buildWorld(buildGeneral(), { autorunLimit: true }); + await expect(blocked.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({ + ok: false, + reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.', + }); + expect(blocked.world.getGeneralById(7)?.meta.killturn).toBe(12); + }); + + it('drops only the authenticated command target slot and rejects an empty slot', async () => { + const fixture = buildWorld(); + await expect( + fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'weapon' }) + ).resolves.toMatchObject({ ok: false }); + await expect( + fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'horse' }) + ).resolves.toMatchObject({ ok: true }); + expect(fixture.world.getGeneralById(7)?.role.items.horse).toBeNull(); + }); +}); 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/battleSimulator.spec.ts b/app/game-frontend/e2e/battleSimulator.spec.ts new file mode 100644 index 0000000..bf0760f --- /dev/null +++ b/app/game-frontend/e2e/battleSimulator.spec.ts @@ -0,0 +1,320 @@ +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'), resolve(repositoryRoot, '../../image')]; +const artifactRoot = process.env.BATTLE_SIM_ARTIFACT_DIR; + +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 readImage = async (relative: string): Promise => { + for (const root of imageRoots) { + try { + return await readFile(resolve(root, relative)); + } catch { + // Main checkout and feature worktrees have different image-root parents. + } + } + throw new Error(`Reference image not found: ${relative}`); +}; + +const simulatorOptions = { + world: { startYear: 190, currentYear: 205, currentMonth: 8 }, + config: { + maxTrainByWar: 120, + maxAtmosByWar: 120, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + }, + unitSet: { + defaultCrewTypeId: 100, + crewTypes: [ + { id: 100, name: '보병', armType: 1 }, + { id: 200, name: '궁병', armType: 2 }, + ], + }, + nationTypes: [{ key: 'che_중립', name: '중립', info: '특별한 효과 없음' }], + warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }], + personalities: [{ key: 'che_대담', name: '대담', info: '공격적인 성격' }], + items: { horse: [], weapon: [], book: [], item: [] }, + nationLevels: [ + { level: 0, name: '방랑군' }, + { level: 1, name: '소국' }, + ], + cityLevels: [ + { level: 1, name: '소도시' }, + { level: 5, name: '대도시' }, + ], + dexLevels: [ + { level: 0, label: 'F', value: 0 }, + { level: 1, label: 'E', value: 1000 }, + ], +}; + +const generalMe = { + general: { + id: 7, + name: '유비', + npcState: 0, + nationId: 1, + cityId: 1, + troopId: 0, + picture: '22.jpg', + imageServer: 0, + officerLevel: 12, + stats: { leadership: 85, strength: 72, intelligence: 78 }, + gold: 1000, + rice: 8765, + crew: 4321, + train: 99, + atmos: 98, + injury: 0, + experience: 900, + dedication: 100, + items: { horse: null, weapon: null, book: null, item: null }, + }, + city: { id: 1, level: 1, defence: 2222, wall: 3333 }, + nation: { id: 1, level: 1, tech: 4500, typeCode: 'che_중립', capitalCityId: 1 }, + settings: {}, + penalties: {}, +}; + +const importedGeneral = { + general: { + no: 7, + name: '유비', + officer_level: 12, + explevel: 30, + leadership: 85, + strength: 72, + intel: 78, + horse: null, + weapon: null, + book: null, + item: null, + injury: 0, + rice: 8765, + personal: 'che_대담', + special2: 'che_필살', + crew: 4321, + crewtype: 100, + atmos: 98, + train: 99, + dex1: 1000, + dex2: 0, + dex3: 0, + dex4: 0, + dex5: 0, + defence_train: 90, + warnum: 12, + killnum: 7, + killcrew: 3456, + }, +}; + +const simulationResult = { + result: true, + reason: 'success', + datetime: '205-08', + avgWar: 5, + phase: 13, + killed: 1234, + maxKilled: 1400, + minKilled: 1100, + dead: 432, + maxDead: 500, + minDead: 400, + attackerRice: 321, + defenderRice: 654, + attackerSkills: { 필살: 2 }, + defendersSkills: [{ 회피: 1 }], + lastWarLog: { + generalHistoryLog: '', + generalActionLog: '', + generalBattleResultLog: '유비가 모의전에서 승리했습니다.', + generalBattleDetailLog: '필살 발동, 피해 1,234', + nationalHistoryLog: '', + globalHistoryLog: '', + globalActionLog: '', + }, +}; + +type Fixture = { + hasGeneral: boolean; + failNextSimulation?: boolean; + queueFirst?: boolean; + pollingCount: number; + requests: string[]; +}; + +const installImages = async (page: Page) => { + 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 readImage(`game/${filename}`), + }); + }); + } +}; + +const installApi = async (page: Page, fixture: Fixture) => { + await installImages(page); + await page.addInitScript(() => { + window.localStorage.setItem('sammo-game-token', 'ga_battle_sim_playwright'); + window.localStorage.setItem('sammo-game-profile', 'che:default'); + }); + await page.route('**/che/api/trpc/**', async (route) => { + const operations = operationNames(route); + const results = operations.map((operation) => { + fixture.requests.push(operation); + if (operation === 'lobby.info') { + return response({ + year: 205, + month: 8, + myGeneral: fixture.hasGeneral ? { name: '유비', picture: '22.jpg' } : null, + }); + } + if (operation === 'battle.getSimulatorContext') return response(simulatorOptions); + if (operation === 'general.me') return response(fixture.hasGeneral ? generalMe : null); + if (operation === 'battle.getGeneralList') { + return response({ + myNationId: 1, + myGeneralId: 7, + nations: [{ id: 1, name: '촉', color: '#8fbc8f' }], + generalsByNation: { 1: [{ id: 7, name: '유비', npcState: 0 }] }, + }); + } + if (operation === 'battle.getGeneralDetail') return response(importedGeneral); + if (operation === 'battle.simulate') { + if (fixture.failNextSimulation) { + fixture.failNextSimulation = false; + return errorResponse(operation, '시뮬레이터 입력 오류'); + } + if (fixture.queueFirst) { + return response({ status: 'queued', jobId: 'job-playwright' }); + } + return response({ status: 'completed', jobId: 'job-playwright', payload: simulationResult }); + } + if (operation === 'battle.getSimulation') { + fixture.pollingCount += 1; + if (fixture.pollingCount === 1) { + return response({ status: 'queued', jobId: 'job-playwright' }); + } + return response({ + status: 'completed', + jobId: 'job-playwright', + payload: simulationResult, + }); + } + return errorResponse(operation, `Unhandled battle simulator fixture operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); +}; + +const gotoSimulator = async (page: Page) => { + await page.goto('battle-simulator'); + await expect(page.getByRole('heading', { name: '전투 시뮬레이터' })).toBeVisible(); + await expect(page.getByLabel('시뮬레이터 데이터 안내')).toBeVisible(); + await expect(page.getByText('출병자 설정')).toBeVisible(); +}; + +test('operates independent/game presets, imports my general, and renders battle logs', async ({ page }) => { + const fixture: Fixture = { hasGeneral: true, queueFirst: true, pollingCount: 0, requests: [] }; + await installApi(page, fixture); + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoSimulator(page); + + const notice = page.getByLabel('시뮬레이터 데이터 안내'); + const noticeRect = await notice.boundingBox(); + expect(noticeRect?.width).toBeGreaterThan(900); + expect(await notice.evaluate((element) => getComputedStyle(element).display)).toBe('flex'); + + await page.getByRole('button', { name: '독립 기본값' }).click(); + await expect(page.getByLabel('연도', { exact: true })).toHaveValue('190'); + await expect(page.getByLabel('월')).toHaveValue('1'); + + await page.getByRole('button', { name: '현재 게임 환경 적용' }).click(); + await expect(page.getByLabel('연도', { exact: true })).toHaveValue('205'); + await expect(page.getByLabel('월')).toHaveValue('8'); + + await page.getByRole('button', { name: '내 장수를 출병자로' }).click(); + await expect(page.getByLabel('이름').first()).toHaveValue('유비'); + await expect(page.getByLabel('병사').first()).toHaveValue('4321'); + + const battleButton = page.getByRole('button', { name: '전투', exact: true }); + await battleButton.hover(); + expect(await battleButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer'); + await page.getByLabel('시드').fill('playwright-fixed-seed'); + await battleButton.click(); + + await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible(); + await expect(page.getByText('5', { exact: true })).toBeVisible(); + expect(fixture.pollingCount).toBe(2); + expect(fixture.requests).toContain('battle.getSimulation'); + + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'battle-simulator-core-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + } +}); + +test('keeps simulation available without a game general and preserves input after an API error', async ({ page }) => { + const fixture: Fixture = { + hasGeneral: false, + failNextSimulation: true, + pollingCount: 0, + requests: [], + }; + await installApi(page, fixture); + await page.setViewportSize({ width: 500, height: 900 }); + await gotoSimulator(page); + + await expect(page).toHaveURL(/battle-simulator/); + await expect(page.getByRole('button', { name: '내 장수를 출병자로' })).toBeDisabled(); + await expect(page.getByRole('button', { name: '서버에서 가져오기' }).first()).toBeDisabled(); + + await page.getByLabel('시드').fill('keep-this-seed'); + await page.getByRole('button', { name: '전투', exact: true }).click(); + await expect(page.getByText('시뮬레이터 입력 오류')).toBeVisible(); + await expect(page.getByLabel('시드')).toHaveValue('keep-this-seed'); + + await page.getByRole('button', { name: '전투', exact: true }).click(); + await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible(); + await expect(page.getByText('시뮬레이터 입력 오류')).toHaveCount(0); + + const notice = page.getByLabel('시뮬레이터 데이터 안내'); + expect(await notice.evaluate((element) => getComputedStyle(element).flexDirection)).toBe('column'); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'battle-simulator-core-mobile.png'), + fullPage: true, + animations: 'disabled', + }); + } +}); diff --git a/app/game-frontend/e2e/battleSimulatorRef.spec.ts b/app/game-frontend/e2e/battleSimulatorRef.spec.ts new file mode 100644 index 0000000..b11b48a --- /dev/null +++ b/app/game-frontend/e2e/battleSimulatorRef.spec.ts @@ -0,0 +1,73 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { expect, test } from '@playwright/test'; + +const refBaseUrl = process.env.REF_BATTLE_SIM_URL; +const refPasswordFile = process.env.REF_USER_PASSWORD_FILE; +const refUsername = process.env.REF_USER_ID ?? 'refuser1'; +const artifactRoot = process.env.BATTLE_SIM_ARTIFACT_DIR; +const refTest = refBaseUrl && refPasswordFile ? test : test.skip; + +refTest('runs the legacy simulator in the same Chromium and captures its rendered contract', async ({ page }) => { + test.setTimeout(120_000); + if (!refBaseUrl || !refPasswordFile) { + throw new Error('REF_BATTLE_SIM_URL and REF_USER_PASSWORD_FILE are required'); + } + const password = (await readFile(refPasswordFile, 'utf8')).trim(); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(refBaseUrl, { waitUntil: 'networkidle' }); + await page.locator('#username').fill(refUsername); + await page.locator('#password').fill(password); + const globalSalt = await page.locator('#global_salt').inputValue(); + const passwordHash = createHash('sha512') + .update(globalSalt + password + globalSalt) + .digest('hex'); + const loginResponse = await page + .context() + .request.post(new URL('api.php?path=Login/LoginByID', refBaseUrl).toString(), { + data: { username: refUsername, password: passwordHash }, + }); + expect(loginResponse.status()).toBe(200); + await expect(loginResponse.json()).resolves.toMatchObject({ result: true }); + + await page.goto(new URL('hwe/battle_simulator.php', refBaseUrl).toString(), { + waitUntil: 'networkidle', + }); + const battleButton = page.locator('.btn-begin_battle'); + await expect(battleButton).toBeVisible(); + const container = page.locator('#container'); + const rect = await container.boundingBox(); + expect(rect?.width).toBeGreaterThanOrEqual(995); + expect(rect?.width).toBeLessThanOrEqual(1005); + + // A login with no game general leaves the legacy nation selects without a + // selected option. Choose the first legal independent value before running. + await page.locator('.form_nation_type').evaluateAll((elements) => { + for (const element of elements) { + const select = element as HTMLSelectElement; + select.selectedIndex = 0; + select.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + await expect(page.locator('.form_nation_type').first()).not.toHaveValue(''); + + await battleButton.hover(); + expect(await battleButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer'); + const simulationResponse = page.waitForResponse( + (response) => response.url().includes('/j_simulate_battle.php') && response.status() === 200, + { timeout: 90_000 } + ); + await battleButton.click(); + await simulationResponse; + await expect(page.locator('#generalBattleResultLog')).not.toBeEmpty(); + + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'battle-simulator-ref-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + } +}); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts new file mode 100644 index 0000000..8d7a3f2 --- /dev/null +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -0,0 +1,375 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; +import { expect, test, type Page, type Route } from '@playwright/test'; + +const response = (data: unknown) => ({ result: { data } }); +const parityArtifactDir = process.env.MENU_PARITY_ARTIFACT_DIR; +const legacyImageRoot = process.env.LEGACY_IMAGE_ROOT; +const operationNames = (route: Route) => + decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); + +const persistParityArtifact = async (page: Page, name: string, geometry: unknown) => { + if (!parityArtifactDir) { + return; + } + await mkdir(parityArtifactDir, { recursive: true }); + await Promise.all([ + page.screenshot({ path: resolve(parityArtifactDir, `${name}.png`), fullPage: true }), + writeFile(resolve(parityArtifactDir, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`), + ]); +}; + +type FixtureState = { + permission: 'head' | 'member'; + myset: number; + settingMutations: Array>; +}; + +const myGeneral = (state: FixtureState) => ({ + general: { + id: 7, + name: '검증장수', + npcState: 0, + nationId: 1, + cityId: 1, + troopId: 0, + picture: null, + imageServer: 0, + officerLevel: state.permission === 'head' ? 5 : 1, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + gold: 1_000, + rice: 2_000, + crew: 300, + train: 80, + atmos: 90, + injury: 0, + experience: 100, + dedication: 200, + items: { horse: 'che_명마', weapon: null, book: null, item: null }, + }, + city: { id: 1, name: '업', level: 8, nationId: 1 }, + nation: { id: 1, name: '위', color: '#777777', level: 3 }, + settings: { + tnmt: 0, + defence_train: 80, + use_treatment: 21, + use_auto_nation_turn: 1, + myset: state.myset, + }, + penalties: {}, +}); + +const battleCenter = (state: FixtureState) => ({ + me: { + id: 7, + officerLevel: state.permission === 'head' ? 5 : 1, + permissionLevel: state.permission === 'head' ? 2 : 0, + }, + nation: { id: 1, name: '위', color: '#777777', level: 3 }, + currentYear: 185, + currentMonth: 1, + turnTermMinutes: 10, + generals: [ + { + id: 7, + name: '검증장수', + npcState: 0, + officerLevel: state.permission === 'head' ? 5 : 1, + cityId: 1, + turnTime: '2026-01-01 00:10:00', + recentWar: '2026-01-01 00:00:00', + warnum: 3, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + experience: 100, + dedication: 200, + injury: 0, + gold: 1_000, + rice: 2_000, + crew: 300, + train: 80, + atmos: 90, + }, + { + id: 8, + name: '다른장수', + npcState: 2, + officerLevel: 1, + cityId: 1, + turnTime: '2026-01-01 00:20:00', + recentWar: null, + warnum: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + injury: 0, + gold: 500, + rice: 500, + crew: 100, + train: 60, + atmos: 60, + }, + ], +}); + +const install = async (page: Page, state: FixtureState) => { + await page.addInitScript(() => { + localStorage.setItem('sammo-game-token', 'menu-token'); + localStorage.setItem('sammo-game-profile', 'che:default'); + }); + await page.route('**/image/game/**', async (route) => { + const filename = basename(new URL(route.request().url()).pathname); + if (legacyImageRoot && ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg'].includes(filename)) { + await route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await readFile(resolve(legacyImageRoot, filename)), + }); + return; + } + await route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') }); + }); + await page.route('**/che/api/trpc/**', async (route) => { + const operations = operationNames(route); + const results = operations.map((operation) => { + if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } }); + if (operation === 'join.getConfig') return response({}); + if (operation === 'general.me') return response(myGeneral(state)); + if (operation === 'world.getState') + return response({ + currentYear: 185, + currentMonth: 1, + tickSeconds: 600, + config: { npcMode: 0, const: { availableInstantAction: {} } }, + meta: { + turntime: '2026-01-01T00:00:00.000Z', + opentime: '2025-12-01T00:00:00.000Z', + autorun_user: {}, + }, + }); + if (operation === 'public.getTraffic') + return response({ + history: [ + { year: 185, month: 1, date: '2026-01-01T00:00:00.000Z', refresh: 120, online: 8 }, + { year: 185, month: 2, date: '2026-01-01T00:10:00.000Z', refresh: 240, online: 12 }, + ], + maxRefresh: 240, + maxOnline: 12, + suspects: [ + { generalId: null, name: '합계', refresh: 360, refreshScoreTotal: 36 }, + { generalId: 7, name: '검증장수', refresh: 240, refreshScoreTotal: 24 }, + ], + }); + if (operation === 'general.getMyLog') + return response({ type: 'generalAction', logs: [{ id: 1, text: '기록' }] }); + if (operation === 'general.setMySetting') { + const raw = route.request().postDataJSON() as { input?: { json?: Record } }; + state.settingMutations.push(raw.input?.json ?? {}); + state.myset = Math.max(0, state.myset - 1); + return response({ ok: true }); + } + if (operation === 'nation.getBattleCenter') { + if (state.permission === 'member') { + return { + error: { + message: '권한이 부족합니다.', + code: -32000, + data: { code: 'FORBIDDEN', httpStatus: 403, path: operation }, + }, + }; + } + return response(battleCenter(state)); + } + if (operation === 'nation.getGeneralLog') { + const type = new URL(route.request().url()).searchParams.get('input')?.includes('generalAction') + ? 'generalAction' + : operation; + return response({ type, generalId: 7, logs: [{ id: 1, text: '감찰 기록' }] }); + } + return response({ ok: true }); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(operations.length === 1 ? results[0] : results), + }); + }); +}; + +test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => { + const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [] }; + await install(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('traffic'); + await expect(page.locator('.chart-title').first()).toHaveText('접 속 량'); + + const geometry = await page.locator('#traffic-container').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const title = element.querySelector('.title-table')!.getBoundingClientRect(); + const charts = [...element.querySelectorAll('.chart-table')].map((chart) => + chart.getBoundingClientRect() + ); + const row = element.querySelector('.chart-row')!.getBoundingClientRect(); + const bar = element.querySelector('.big-bar')!.getBoundingClientRect(); + const suspect = element.querySelector('.suspect-table')!.getBoundingClientRect(); + return { + width: rect.width, + minWidth: getComputedStyle(element).minWidth, + fontSize: getComputedStyle(element).fontSize, + fontFamily: getComputedStyle(element).fontFamily, + titleWidth: title.width, + chartWidths: charts.map((chart) => chart.width), + chartGap: charts[1]!.x - charts[0]!.right, + rowHeight: row.height, + barHeight: bar.height, + suspectWidth: suspect.width, + }; + }); + expect(geometry.width).toBe(1016); + expect(geometry.minWidth).toBe('1016px'); + expect(geometry.fontSize).toBe('14px'); + expect(geometry.fontFamily).toContain('Pretendard'); + expect(geometry.titleWidth).toBe(1000); + expect(geometry.chartWidths).toEqual([483, 483]); + expect(geometry.chartGap).toBe(26); + expect(geometry.rowHeight).toBe(31); + expect(geometry.barHeight).toBe(30); + expect(geometry.suspectWidth).toBeGreaterThanOrEqual(994); + await persistParityArtifact(page, 'traffic-desktop', geometry); + + await page.setViewportSize({ width: 500, height: 900 }); + const mobileWidth = await page + .locator('#traffic-container') + .evaluate((element) => element.getBoundingClientRect().width); + expect(mobileWidth).toBe(1016); +}); + +test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => { + const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [] }; + await install(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('my-page'); + await expect(page.locator('.title-row')).toContainText('내 정 보'); + await expect(page.locator('#set_my_setting')).toBeVisible(); + + const desktop = await page.locator('#container').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const title = element.querySelector('.title-row')!.getBoundingClientRect(); + const settings = element.querySelector('.settings-column')!.getBoundingClientRect(); + const saveButton = element.querySelector('#set_my_setting')!; + const save = saveButton.getBoundingClientRect(); + const customCss = element.querySelector('#custom_css')!.getBoundingClientRect(); + const columns = getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns; + return { + width: rect.width, + minWidth: getComputedStyle(element).minWidth, + fontSize: getComputedStyle(element).fontSize, + columns, + titleHeight: title.height, + settingsOffset: settings.x - rect.x, + saveWidth: save.width, + saveHeight: save.height, + saveBackground: getComputedStyle(saveButton).backgroundColor, + customCssWidth: customCss.width, + customCssHeight: customCss.height, + backgroundImage: getComputedStyle(element).backgroundImage, + sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).backgroundImage, + }; + }); + expect(desktop.width).toBe(1000); + expect(desktop.minWidth).toBe('500px'); + expect(desktop.fontSize).toBe('14px'); + expect(desktop.columns.split(' ')).toHaveLength(2); + expect(desktop.titleHeight).toBeCloseTo(54, 0); + expect(desktop.settingsOffset).toBeCloseTo(500, 0); + expect(desktop.saveWidth).toBe(160); + expect(desktop.saveHeight).toBe(30); + expect(desktop.saveBackground).toBe('rgb(34, 85, 0)'); + expect(desktop.customCssWidth).toBe(420); + expect(desktop.customCssHeight).toBe(150); + expect(desktop.backgroundImage).toContain('back_walnut.jpg'); + expect(desktop.sectionBackgroundImage).toContain('back_green.jpg'); + await persistParityArtifact(page, 'core-my-page-desktop', desktop); + + await page + .locator('select') + .filter({ has: page.locator('option[value="999"]') }) + .selectOption('999'); + await page.locator('#set_my_setting').click(); + await expect.poll(() => state.settingMutations.length).toBe(1); + expect(state.settingMutations[0]).not.toHaveProperty('generalId'); + + await page.setViewportSize({ width: 500, height: 900 }); + await page.reload(); + const mobile = await page.locator('#container').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const settings = element.querySelector('.settings-column')!.getBoundingClientRect(); + return { + width: rect.width, + scrollWidth: document.documentElement.scrollWidth, + columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns, + settingsOffset: settings.x - rect.x, + settingsWidth: settings.width, + }; + }); + expect(mobile).toMatchObject({ + width: 500, + scrollWidth: 500, + columns: '500px', + settingsOffset: 0, + settingsWidth: 500, + }); + await persistParityArtifact(page, 'core-my-page-mobile', mobile); +}); + +test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => { + const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [] }; + await install(page, head); + await page.setViewportSize({ width: 1000, height: 900 }); + await page.goto('battle-center'); + await expect(page.getByRole('heading', { name: '감찰부' })).toBeVisible(); + await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8'); + await page.getByRole('button', { name: '다음 ▶' }).click(); + await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7'); + const geometry = await page.locator('.battle-page').evaluate((element) => { + const selector = element.querySelector('.selector-row')!; + const controls = [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect()); + const logBlock = element.querySelector('.log-block')!.getBoundingClientRect(); + return { + width: element.getBoundingClientRect().width, + fontSize: getComputedStyle(element).fontSize, + selectorColumns: getComputedStyle(selector).gridTemplateColumns, + selectorHeight: selector.getBoundingClientRect().height, + controlWidths: controls.map((control) => control.width), + logBlockWidth: logBlock.width, + backgroundImage: getComputedStyle(element).backgroundImage, + generalBackgroundImage: getComputedStyle(element.querySelector('.battle-general-card')!) + .backgroundImage, + }; + }); + expect(geometry.width).toBe(1000); + expect(geometry.fontSize).toBe('14px'); + expect(geometry.selectorColumns.split(' ')).toHaveLength(4); + expect(geometry.selectorHeight).toBeCloseTo(36, 0); + expect(geometry.controlWidths[0]).toBeCloseTo(83.33, 0); + expect(geometry.controlWidths[1]).toBeCloseTo(333.33, 0); + expect(geometry.logBlockWidth).toBeCloseTo(500, 0); + expect(geometry.backgroundImage).toContain('back_walnut.jpg'); + expect(geometry.generalBackgroundImage).toContain('back_blue.jpg'); + await persistParityArtifact(page, 'core-battle-center-desktop', geometry); + + await page.setViewportSize({ width: 500, height: 900 }); + const mobileGeometry = await page.locator('.selector-row').evaluate((element) => ({ + columns: getComputedStyle(element).gridTemplateColumns, + controlWidths: [...element.children].map((child) => (child as HTMLElement).getBoundingClientRect().width), + })); + expect(mobileGeometry.columns.split(' ')).toHaveLength(4); + expect(mobileGeometry.controlWidths[0]).toBeCloseTo(83.33, 0); + expect(mobileGeometry.controlWidths[1]).toBeCloseTo(125, 0); + await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry); + + await page.unrouteAll({ behavior: 'wait' }); + const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [] }; + await install(page, member); + await page.reload(); + await expect(page.locator('.error')).toContainText('권한이 부족합니다.'); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index b0a1eae..ea02b56 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -12,9 +12,13 @@ export default defineConfig({ 'troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', + 'inGameMenus.spec.ts', 'nationOffices.spec.ts', 'nationGeneralSecret.spec.ts', 'npcPolicy.spec.ts', + 'auction.spec.ts', + 'battleSimulator.spec.ts', + 'battleSimulatorRef.spec.ts', ], fullyParallel: false, workers: 1, diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index a60d1d3..cac0d86 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -11,6 +11,8 @@ "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", "test": "node -e \"console.log('test not configured')\"", diff --git a/app/game-frontend/src/components/battle/BattleGeneralCard.vue b/app/game-frontend/src/components/battle/BattleGeneralCard.vue index 1d8c95c..ed7eb05 100644 --- a/app/game-frontend/src/components/battle/BattleGeneralCard.vue +++ b/app/game-frontend/src/components/battle/BattleGeneralCard.vue @@ -6,6 +6,7 @@ interface Props { options: BattleSimOptions; mode: 'attacker' | 'defender'; title: string; + canImportServer: boolean; } const props = defineProps(); @@ -59,7 +60,19 @@ const officerLevelOptions = [
No {{ general.no }}
- + @@ -366,6 +379,11 @@ const officerLevelOptions = [ color: #f0b6b6; } +.action:disabled { + cursor: not-allowed; + opacity: 0.45; +} + .form-block { display: flex; flex-direction: column; 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/router/index.ts b/app/game-frontend/src/router/index.ts index 651dd13..c4dd81d 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -21,7 +21,6 @@ import NotFoundView from '../views/NotFoundView.vue'; import TournamentView from '../views/TournamentView.vue'; import BettingView from '../views/BettingView.vue'; import MyPageView from '../views/MyPageView.vue'; -import MySettingsView from '../views/MySettingsView.vue'; import BoardView from '../views/BoardView.vue'; import DiplomacyView from '../views/DiplomacyView.vue'; import BestGeneralView from '../views/BestGeneralView.vue'; @@ -33,6 +32,7 @@ import TroopView from '../views/TroopView.vue'; import YearbookView from '../views/YearbookView.vue'; import NationBettingView from '../views/NationBettingView.vue'; import NpcListView from '../views/NpcListView.vue'; +import TrafficView from '../views/TrafficView.vue'; import { useSessionStore } from '../stores/session'; const routes = [ @@ -197,7 +197,6 @@ const routes = [ component: BattleSimulatorView, meta: { requiresAuth: true, - requiresGeneral: true, }, }, { @@ -258,6 +257,11 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/traffic', + name: 'traffic', + component: TrafficView, + }, { path: '/npc-list', name: 'npc-list', @@ -283,8 +287,7 @@ const routes = [ }, { path: '/my-settings', - name: 'my-settings', - component: MySettingsView, + redirect: '/my-page', meta: { requiresAuth: true, requiresGeneral: true, 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/BattleCenterView.vue b/app/game-frontend/src/views/BattleCenterView.vue index 1d3f0e6..8b48183 100644 --- a/app/game-frontend/src/views/BattleCenterView.vue +++ b/app/game-frontend/src/views/BattleCenterView.vue @@ -3,7 +3,6 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'; import { useRoute } from 'vue-router'; import PanelCard from '../components/ui/PanelCard.vue'; import SkeletonLines from '../components/ui/SkeletonLines.vue'; -import GeneralBasicCard from '../components/main/GeneralBasicCard.vue'; import { trpc } from '../utils/trpc'; import { getNpcColor } from '../utils/npcColor'; import { formatLog } from '../utils/formatLog'; @@ -269,7 +268,26 @@ onMounted(() => { - + +
+
+ {{ selectedGeneral.name }} (관직 {{ selectedGeneral.officerLevel }}) +
+
+ 통솔{{ selectedGeneral.stats.leadership }} 무력{{ selectedGeneral.stats.strength }} 지력{{ selectedGeneral.stats.intelligence }} 자금{{ selectedGeneral.gold }} 군량{{ selectedGeneral.rice }} 병력{{ selectedGeneral.crew }} 훈련{{ selectedGeneral.train }} 사기{{ selectedGeneral.atmos }} 부상{{ selectedGeneral.injury }} 경험{{ selectedGeneral.experience }} 공헌{{ selectedGeneral.dedication }} 전투{{ selectedGeneral.warnum }}회 +
+
최근 턴: {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}
최근 전투: {{ selectedGeneral.recentWar || '-' }}
@@ -286,12 +304,7 @@ onMounted(() => {
@@ -303,126 +316,237 @@ onMounted(() => { diff --git a/app/game-frontend/src/views/BestGeneralView.vue b/app/game-frontend/src/views/BestGeneralView.vue index 63e2add..cf4345b 100644 --- a/app/game-frontend/src/views/BestGeneralView.vue +++ b/app/game-frontend/src/views/BestGeneralView.vue @@ -1,5 +1,7 @@ + + diff --git a/app/game-frontend/src/views/HallOfFameView.vue b/app/game-frontend/src/views/HallOfFameView.vue index 3ea0afa..f112db3 100644 --- a/app/game-frontend/src/views/HallOfFameView.vue +++ b/app/game-frontend/src/views/HallOfFameView.vue @@ -135,7 +135,7 @@ onMounted(async () => { -
{{ errorMessage }}
+
불러오는 중...
표시할 데이터가 없습니다.
@@ -171,6 +171,10 @@ onMounted(async () => {
+
+ 삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD / + Credit +
@@ -191,7 +195,7 @@ onMounted(async () => { .legacy-hall-title, .legacy-hall-bottom { - text-align: center; + text-align: left; } .legacy-hall-title { @@ -205,12 +209,33 @@ onMounted(async () => { } .scenario-search select { - min-width: 220px; + width: 189px; + height: 20px; border: 1px solid #555; background: #ddd; color: #303030; } +.legacy-button { + border: 0; + border-radius: 5.25px; + background: #375a7f; + padding: 5.25px 10.5px; + font-weight: 700; + line-height: 21px; +} + +.legacy-button:hover, +.legacy-button:focus, +.legacy-button:active { + background: #6b6b6b; +} + +.legacy-button:focus-visible { + outline: revert; + outline-offset: 0; +} + .legacy-message { border: 1px solid gray; padding: 12px; @@ -221,6 +246,15 @@ onMounted(async () => { color: #ff6b6b; } +.legacy-banner { + font-size: 13px; +} + +.legacy-banner a { + color: #fff; + text-decoration: underline; +} + .hall-sections { display: block; } @@ -235,7 +269,9 @@ onMounted(async () => { margin: 0; border-bottom: 1px solid gray; padding: 2px; - font-size: 1.17em; + font-size: calc(19px + 0.784615vw); + font-weight: 500; + line-height: 1.2; text-align: center; } @@ -275,7 +311,7 @@ onMounted(async () => { display: inline-block; width: 64px; height: 64px; - object-fit: cover; + object-fit: fill; } .hall-server, @@ -309,5 +345,9 @@ onMounted(async () => { .legacy-hall-page { width: 1000px; } + + .rankType { + font-size: 28px; + } } diff --git a/app/game-frontend/src/views/JoinView.vue b/app/game-frontend/src/views/JoinView.vue index 9073508..6f7feb1 100644 --- a/app/game-frontend/src/views/JoinView.vue +++ b/app/game-frontend/src/views/JoinView.vue @@ -1,6 +1,6 @@