diff --git a/app/game-api/src/auth/profileStatusSource.ts b/app/game-api/src/auth/profileStatusSource.ts new file mode 100644 index 00000000..b3a12d55 --- /dev/null +++ b/app/game-api/src/auth/profileStatusSource.ts @@ -0,0 +1,56 @@ +import { createHmac } from 'node:crypto'; +import { GATEWAY_PROFILE_STATUSES, type GatewayProfileStatus } from '@sammo-ts/common'; + +const INTERNAL_TOKEN_CONTEXT = 'sammo:profile-status-source:v1'; +const profileStatuses = new Set(GATEWAY_PROFILE_STATUSES); + +export interface ProfileStatusSource { + get(profileName: string): Promise; +} + +const deriveInternalToken = (secret: string): string => + createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex'); + +const parseProfileStatus = (value: unknown, profileName: string): GatewayProfileStatus => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('invalid gateway profile status projection'); + } + const record = value as Record; + if ( + Object.keys(record).sort().join(',') !== 'profileName,status' || + record.profileName !== profileName || + typeof record.status !== 'string' || + !profileStatuses.has(record.status) + ) { + throw new Error('invalid gateway profile status projection'); + } + return record.status as GatewayProfileStatus; +}; + +export class GatewayHttpProfileStatusSource implements ProfileStatusSource { + private readonly baseUrl: string; + + constructor( + baseUrl: string, + private readonly secret: string, + private readonly timeoutMs = 2_000 + ) { + this.baseUrl = baseUrl.replace(/\/$/u, ''); + } + + async get(profileName: string): Promise { + const response = await fetch(`${this.baseUrl}/internal/profile-status/${encodeURIComponent(profileName)}`, { + headers: { + 'x-sammo-internal-token': deriveInternalToken(this.secret), + }, + signal: AbortSignal.timeout(this.timeoutMs), + }); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(`Gateway profile status request failed with HTTP ${response.status}.`); + } + return parseProfileStatus(await response.json(), profileName); + } +} diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 8824f96a..213bda2a 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -8,6 +8,7 @@ import type { BattleSimTransport } from './battleSim/transport.js'; import type { FlushStore } from './auth/flushStore.js'; import type { RedisAccessTokenStore } from './auth/accessTokenStore.js'; import type { AccountIconSource } from './auth/accountIconSource.js'; +import type { ProfileStatusSource } from './auth/profileStatusSource.js'; import type { ContentImageUploadStore } from './services/remoteContentImageStore.js'; export interface GameProfile { @@ -99,6 +100,9 @@ export interface GameApiContext { flushStore: FlushStore; gameTokenSecret: string; accountIconSource?: AccountIconSource; + // Runtime context always supplies this. Partial router fixtures may omit it; + // access scoring then fails open and never penalizes a test-only request. + profileStatusSource?: ProfileStatusSource; } export const createGameApiContext = (options: { @@ -118,6 +122,7 @@ export const createGameApiContext = (options: { flushStore: FlushStore; gameTokenSecret: string; accountIconSource?: AccountIconSource; + profileStatusSource: ProfileStatusSource; }): GameApiContext => { return { requestId: options.requestId, @@ -137,5 +142,6 @@ export const createGameApiContext = (options: { flushStore: options.flushStore, gameTokenSecret: options.gameTokenSecret, ...(options.accountIconSource ? { accountIconSource: options.accountIconSource } : {}), + profileStatusSource: options.profileStatusSource, }; }; diff --git a/app/game-api/src/router/dashboard/index.ts b/app/game-api/src/router/dashboard/index.ts index b36dec7c..18804a62 100644 --- a/app/game-api/src/router/dashboard/index.ts +++ b/app/game-api/src/router/dashboard/index.ts @@ -1,7 +1,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { authedProcedure, router } from '../../trpc.js'; +import { accessLimitAuthedProcedure, router } from '../../trpc.js'; import { createReadModelDelta } from '../../services/readModelDeltaCache.js'; import { getBoardAccess } from '../board/index.js'; import { getGeneralContext } from '../general/index.js'; @@ -31,7 +31,7 @@ const zContextBundleInput = z }); export const dashboardRouter = router({ - getContextBundleDelta: authedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => { + getContextBundleDelta: accessLimitAuthedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => { const viewerId = ctx.auth?.user.id; if (!viewerId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 308e3d01..346b2394 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -10,6 +10,7 @@ import { accessAuthedInputProcedure, accessEngineAuthedProcedure, accessEngineAuthedInputProcedure, + accessLimitAuthedProcedure, authedProcedure, engineAuthedProcedure, router, @@ -735,7 +736,7 @@ export const generalRouter = router({ })), }; }), - getRecentRecords: authedProcedure + getRecentRecords: accessLimitAuthedProcedure .input( z.object({ lastGeneralRecordId: z.number().int().nonnegative().default(0), diff --git a/app/game-api/src/router/lobby/index.ts b/app/game-api/src/router/lobby/index.ts index a0817186..97e800c2 100644 --- a/app/game-api/src/router/lobby/index.ts +++ b/app/game-api/src/router/lobby/index.ts @@ -4,6 +4,7 @@ import { asRecord } from '@sammo-ts/common'; import { zWorldStateConfig, zWorldStateMeta } from '../../context.js'; import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js'; +import { loadCurrentGameTime } from '../../services/gameClock.js'; import { procedure, router } from '../../trpc.js'; export const lobbyRouter = router({ @@ -26,6 +27,7 @@ export const lobbyRouter = router({ const npcCnt = await ctx.db.general.count({ where: { npcState: { gte: 2 } } }); const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } }); const scenarioTitle = asRecord(asRecord(rawWorldState.meta).scenarioMeta).title; + const gameTime = await loadCurrentGameTime(ctx.db); let myGeneral = null; if (ctx.auth?.user.id) { @@ -54,6 +56,8 @@ export const lobbyRouter = router({ starttime: worldState.meta.starttime ?? '', opentime: worldState.meta.opentime ?? '', turntime: worldState.meta.turntime ?? '', + serverTime: gameTime.now.toISOString(), + clockMode: gameTime.mode ?? 'realtime', otherTextInfo: worldState.meta.otherTextInfo ?? '', isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0, selectionPoolEnabled: isSelectionPoolWorld(rawWorldState), diff --git a/app/game-api/src/router/messages/index.ts b/app/game-api/src/router/messages/index.ts index fe27e81b..60d56f7c 100644 --- a/app/game-api/src/router/messages/index.ts +++ b/app/game-api/src/router/messages/index.ts @@ -4,7 +4,7 @@ import { asRecord } from '@sammo-ts/common'; import type { UserSanctions } from '@sammo-ts/common/auth/gameToken'; import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions'; -import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; +import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, @@ -73,116 +73,114 @@ const hasPenalty = (penalty: unknown, key: string): boolean => { }; export const messagesRouter = router({ - getRecent: authedProcedure - .input( - z.object({ - generalId: z.number().int().positive(), - sequence: z.number().int().optional(), - }) - ) - .query(async ({ ctx, input }) => { - const general = await getOwnedGeneral(ctx, input.generalId); + getRecent: accessLimitAuthedInputProcedure( + z.object({ + generalId: z.number().int().positive(), + sequence: z.number().int().optional(), + }) + ).query(async ({ ctx, input }) => { + const general = await getOwnedGeneral(ctx, input.generalId); - const sequence = input.sequence ?? -1; - const nationId = general.nationId; - const mailboxes = { - private: general.id, - public: MESSAGE_MAILBOX_PUBLIC, - national: MESSAGE_MAILBOX_NATIONAL_BASE + nationId, - diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId, - } satisfies Record; + const sequence = input.sequence ?? -1; + const nationId = general.nationId; + const mailboxes = { + private: general.id, + public: MESSAGE_MAILBOX_PUBLIC, + national: MESSAGE_MAILBOX_NATIONAL_BASE + nationId, + diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId, + } satisfies Record; - const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState, nation] = - await Promise.all([ - fetchMessagesFromMailbox({ - db: ctx.db, - mailbox: mailboxes.private, - msgType: 'private', - limit: 15, - fromSeq: sequence, - }), - fetchMessagesFromMailbox({ - db: ctx.db, - mailbox: mailboxes.public, - msgType: 'public', - limit: 15, - fromSeq: sequence, - }), - fetchMessagesFromMailbox({ - db: ctx.db, - mailbox: mailboxes.national, - msgType: 'national', - limit: 15, - fromSeq: sequence, - }), - fetchMessagesFromMailbox({ - db: ctx.db, - mailbox: mailboxes.diplomacy, - msgType: 'diplomacy', - limit: 15, - fromSeq: sequence, - }), - ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }), - nationId > 0 - ? ctx.db.nation.findUnique({ - where: { id: nationId }, - select: { meta: true }, - }) - : null, - ]); + const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState, nation] = + await Promise.all([ + fetchMessagesFromMailbox({ + db: ctx.db, + mailbox: mailboxes.private, + msgType: 'private', + limit: 15, + fromSeq: sequence, + }), + fetchMessagesFromMailbox({ + db: ctx.db, + mailbox: mailboxes.public, + msgType: 'public', + limit: 15, + fromSeq: sequence, + }), + fetchMessagesFromMailbox({ + db: ctx.db, + mailbox: mailboxes.national, + msgType: 'national', + limit: 15, + fromSeq: sequence, + }), + fetchMessagesFromMailbox({ + db: ctx.db, + mailbox: mailboxes.diplomacy, + msgType: 'diplomacy', + limit: 15, + fromSeq: sequence, + }), + ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }), + nationId > 0 + ? ctx.db.nation.findUnique({ + where: { id: nationId }, + select: { meta: true }, + }) + : null, + ]); - const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1; - const messageBuckets: Record = { - private: privateMessages, - public: publicMessages, - national: nationalMessages, - diplomacy: redactDiplomacyMessages(diplomacyMessages, permission), - }; + const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1; + const messageBuckets: Record = { + private: privateMessages, + public: publicMessages, + national: nationalMessages, + diplomacy: redactDiplomacyMessages(diplomacyMessages, permission), + }; - let nextSequence = sequence; - let minSequence = sequence; - let lastType: MessageType | null = null; - const updateSequence = (type: MessageType, messages: Array<{ id: number }>) => { - for (const message of messages) { - if (message.id > nextSequence) { - nextSequence = message.id; - } - if (message.id <= minSequence) { - minSequence = message.id; - lastType = type; - } + let nextSequence = sequence; + let minSequence = sequence; + let lastType: MessageType | null = null; + const updateSequence = (type: MessageType, messages: Array<{ id: number }>) => { + for (const message of messages) { + if (message.id > nextSequence) { + nextSequence = message.id; + } + if (message.id <= minSequence) { + minSequence = message.id; + lastType = type; } - }; - - updateSequence('private', privateMessages); - updateSequence('public', publicMessages); - updateSequence('national', nationalMessages); - updateSequence('diplomacy', diplomacyMessages); - - if (lastType === 'private' && messageBuckets.private.length > 0) { - messageBuckets.private.pop(); - } else if (lastType === 'public' && messageBuckets.public.length > 0) { - messageBuckets.public.pop(); - } else if (lastType === 'national' && messageBuckets.national.length > 0) { - messageBuckets.national.pop(); - } else if (lastType === 'diplomacy' && messageBuckets.diplomacy.length > 0) { - messageBuckets.diplomacy.pop(); } + }; - return { - result: true, - ...messageBuckets, - sequence: nextSequence, - nationId: nationId, - generalName: general.name, - permission, - canRespondDiplomacy: permission >= 4 && general.officerLevel > 4, - latestRead: { - diplomacy: readState?.latestDiplomacyMessage ?? 0, - private: readState?.latestPrivateMessage ?? 0, - }, - }; - }), + updateSequence('private', privateMessages); + updateSequence('public', publicMessages); + updateSequence('national', nationalMessages); + updateSequence('diplomacy', diplomacyMessages); + + if (lastType === 'private' && messageBuckets.private.length > 0) { + messageBuckets.private.pop(); + } else if (lastType === 'public' && messageBuckets.public.length > 0) { + messageBuckets.public.pop(); + } else if (lastType === 'national' && messageBuckets.national.length > 0) { + messageBuckets.national.pop(); + } else if (lastType === 'diplomacy' && messageBuckets.diplomacy.length > 0) { + messageBuckets.diplomacy.pop(); + } + + return { + result: true, + ...messageBuckets, + sequence: nextSequence, + nationId: nationId, + generalName: general.name, + permission, + canRespondDiplomacy: permission >= 4 && general.officerLevel > 4, + latestRead: { + diplomacy: readState?.latestDiplomacyMessage ?? 0, + private: readState?.latestPrivateMessage ?? 0, + }, + }; + }), getContacts: authedProcedure .input(z.object({ generalId: z.number().int().positive() })) .query(async ({ ctx, input }) => { diff --git a/app/game-api/src/router/nation/endpoints/getGeneralLog.ts b/app/game-api/src/router/nation/endpoints/getGeneralLog.ts index 99b5836b..bc5cf9b9 100644 --- a/app/game-api/src/router/nation/endpoints/getGeneralLog.ts +++ b/app/game-api/src/router/nation/endpoints/getGeneralLog.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { LogCategory, LogScope } from '@sammo-ts/logic'; -import { authedProcedure } from '../../../trpc.js'; +import { accessLimitAuthedInputProcedure } from '../../../trpc.js'; import { getMyGeneral } from '../../shared/general.js'; import { assertNationAccess, @@ -13,77 +13,75 @@ import { type GeneralLogType, } from '../shared.js'; -export const getGeneralLog = authedProcedure - .input( - z.object({ - generalId: z.number().int().positive(), - type: zGeneralLogType, - beforeId: z.number().int().positive().optional(), - }) - ) - .query(async ({ ctx, input }) => { - const me = await getMyGeneral(ctx); - assertNationAccess(me); +export const getGeneralLog = accessLimitAuthedInputProcedure( + z.object({ + generalId: z.number().int().positive(), + type: zGeneralLogType, + beforeId: z.number().int().positive().optional(), + }) +).query(async ({ ctx, input }) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); - const [nation, target] = await Promise.all([ - ctx.db.nation.findUnique({ - where: { id: me.nationId }, - select: { meta: true }, - }), - ctx.db.general.findUnique({ - where: { id: input.generalId }, - select: { id: true, nationId: true, npcState: true }, - }), - ]); + const [nation, target] = await Promise.all([ + ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { meta: true }, + }), + ctx.db.general.findUnique({ + where: { id: input.generalId }, + select: { id: true, nationId: true, npcState: true }, + }), + ]); - if (!nation) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); - } - if (!target) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found' }); - } + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } + if (!target) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found' }); + } - const permissionLevel = resolveNationPermission(me, nation.meta, true); - if (permissionLevel < 1) { - throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); - } - if (target.nationId !== me.nationId) { - throw new TRPCError({ code: 'FORBIDDEN', message: '같은 나라의 장수가 아닙니다.' }); - } - if (input.type === 'generalAction' && target.npcState < 2 && target.id !== me.id && permissionLevel < 2) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: '권한이 부족합니다. 유저 장수의 개인 기록은 수뇌만 열람 가능합니다.', - }); - } - - const categoryMap: Record = { - generalHistory: LogCategory.HISTORY, - generalAction: LogCategory.ACTION, - battleResult: LogCategory.BATTLE_BRIEF, - battleDetail: LogCategory.BATTLE_DETAIL, - }; - - const logs = await ctx.db.logEntry.findMany({ - where: { - generalId: target.id, - scope: LogScope.GENERAL, - category: categoryMap[input.type], - ...(input.type !== 'generalHistory' && input.beforeId ? { id: { lt: input.beforeId } } : {}), - }, - orderBy: { id: 'desc' }, - ...(input.type === 'generalHistory' ? {} : { take: 30 }), + const permissionLevel = resolveNationPermission(me, nation.meta, true); + if (permissionLevel < 1) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + } + if (target.nationId !== me.nationId) { + throw new TRPCError({ code: 'FORBIDDEN', message: '같은 나라의 장수가 아닙니다.' }); + } + if (input.type === 'generalAction' && target.npcState < 2 && target.id !== me.id && permissionLevel < 2) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: '권한이 부족합니다. 유저 장수의 개인 기록은 수뇌만 열람 가능합니다.', }); + } - return { - type: input.type, + const categoryMap: Record = { + generalHistory: LogCategory.HISTORY, + generalAction: LogCategory.ACTION, + battleResult: LogCategory.BATTLE_BRIEF, + battleDetail: LogCategory.BATTLE_DETAIL, + }; + + const logs = await ctx.db.logEntry.findMany({ + where: { generalId: target.id, - logs: logs.map((entry) => ({ - id: entry.id, - text: entry.text, - year: entry.year, - month: entry.month, - createdAt: formatDateTime(entry.createdAt), - })), - }; + scope: LogScope.GENERAL, + category: categoryMap[input.type], + ...(input.type !== 'generalHistory' && input.beforeId ? { id: { lt: input.beforeId } } : {}), + }, + orderBy: { id: 'desc' }, + ...(input.type === 'generalHistory' ? {} : { take: 30 }), }); + + return { + type: input.type, + generalId: target.id, + logs: logs.map((entry) => ({ + id: entry.id, + text: entry.text, + year: entry.year, + month: entry.month, + createdAt: formatDateTime(entry.createdAt), + })), + }; +}); diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index 67c8bef5..2516c41a 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -7,7 +7,13 @@ import type { GameApiContext } from '../../context.js'; import { zWorldStateConfig, zWorldStateMeta } from '../../context.js'; import { loadMapLayout } from '../../maps/mapLayout.js'; import { loadPublicMap } from '../../maps/worldMap.js'; -import { accessPages, recordGeneralAccess } from '../../services/generalAccess.js'; +import { + accessPages, + formatGeneralAccessLimitMessage, + generalAccessLimitPages, + getGeneralAccessState, + recordGeneralAccess, +} from '../../services/generalAccess.js'; import { sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js'; import { accessInputProcedure, procedure, router, sessionActivityProcedure } from '../../trpc.js'; import { loadTraitNames } from '../nation/shared.js'; @@ -248,9 +254,19 @@ const sortNpcList = < export const publicRouter = router({ recordAccess: sessionActivityProcedure .input(z.object({ page: z.enum(accessPages) })) - .mutation(async ({ ctx, input }) => ({ - recorded: await recordGeneralAccess(ctx, input.page), - })), + .mutation(async ({ ctx, input }) => { + const recorded = await recordGeneralAccess(ctx, input.page); + if (ctx.generalAccessTracking === true && generalAccessLimitPages.has(input.page)) { + const state = await getGeneralAccessState(ctx); + if (state?.level === 2) { + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + message: formatGeneralAccessLimitMessage(state), + }); + } + } + return { recorded }; + }), getMapLayout: procedure.query(async ({ ctx }) => { return loadMapLayout(ctx.profile.scenario); }), diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index be304ace..11b78cf6 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { loadActionModuleBundle } from '@sammo-ts/logic'; import { asRecord } from '@sammo-ts/common'; -import { authedProcedure, router } from '../../trpc.js'; +import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; import { buildBattleSimEnvironment } from '../../battleSim/environment.js'; import { loadBattleSimTraitOptions } from '../../battleSim/simulatorOptions.js'; import { @@ -283,13 +283,11 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number }; export const turnsRouter = router({ - getCommandTable: authedProcedure - .input( - z.object({ - generalId: z.number().int().positive(), - }) - ) - .query(({ ctx, input }) => getTurnCommandTable(ctx, input.generalId)), + getCommandTable: accessAuthedInputProcedure( + z.object({ + generalId: z.number().int().positive(), + }) + ).query(({ ctx, input }) => getTurnCommandTable(ctx, input.generalId)), reserved: router({ getGeneral: authedProcedure .input( diff --git a/app/game-api/src/router/yearbook/index.ts b/app/game-api/src/router/yearbook/index.ts index 80eab2b9..1f672934 100644 --- a/app/game-api/src/router/yearbook/index.ts +++ b/app/game-api/src/router/yearbook/index.ts @@ -7,7 +7,12 @@ import { LogCategory, LogScope } from '@sammo-ts/logic'; import type { GameApiContext } from '../../context.js'; import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js'; -import { generalAccessEndpointWeights, recordGeneralAccessWeight } from '../../services/generalAccess.js'; +import { + formatGeneralAccessLimitMessage, + generalAccessEndpointWeights, + getGeneralAccessState, + recordGeneralAccessWeight, +} from '../../services/generalAccess.js'; import { authedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; @@ -31,6 +36,10 @@ const recordHistoryAccess = async (ctx: GameApiContext): Promise => { return; } await recordGeneralAccessWeight(ctx, generalAccessEndpointWeights['yearbook.getHistory']); + const state = ctx.generalAccessTracking === true ? await getGeneralAccessState(ctx) : null; + if (state?.level === 2) { + throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: formatGeneralAccessLimitMessage(state) }); + } }; const parseTextArray = (value: unknown): string[] => diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 02d257d6..0fa0ab29 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -25,6 +25,7 @@ import { RedisRealtimeEventHub } from './realtime/eventHub.js'; import { formatSseFrame } from './realtime/sse.js'; import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from './realtime/publicEvent.js'; import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js'; +import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js'; import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js'; import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js'; import { createBestEffortResourceCloser } from './services/bestEffortResourceCloser.js'; @@ -92,6 +93,10 @@ export const createGameApiServer = async () => { throw error; } const accountIconSource = new GatewayHttpAccountIconSource(config.gatewayInternalApiUrl, config.gameTokenSecret); + const profileStatusSource = new GatewayHttpProfileStatusSource( + config.gatewayInternalApiUrl, + config.gameTokenSecret + ); const turnDaemon = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs); const accountIconResetReconciler = new AccountIconResetReconciler( @@ -214,6 +219,7 @@ export const createGameApiServer = async () => { flushStore, gameTokenSecret: config.gameTokenSecret, accountIconSource, + profileStatusSource, }); }, }, diff --git a/app/game-api/src/services/generalAccess.ts b/app/game-api/src/services/generalAccess.ts index 08129b40..f65bfa64 100644 --- a/app/game-api/src/services/generalAccess.ts +++ b/app/game-api/src/services/generalAccess.ts @@ -1,4 +1,4 @@ -import { asRecord } from '@sammo-ts/common'; +import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common'; import { GamePrisma } from '@sammo-ts/infra'; import type { GameApiContext } from '../context.js'; @@ -56,6 +56,7 @@ export const generalAccessEndpointWeights = { 'general.dieOnPrestart': 1, 'general.instantRetreat': 1, 'messages.send': 1, + 'turns.getCommandTable': 1, 'general.setMySetting': 0, 'npc.setNationPolicy': 0, 'npc.setNationPriority': 0, @@ -65,6 +66,40 @@ export const generalAccessEndpointWeights = { export type GeneralAccessEndpoint = keyof typeof generalAccessEndpointWeights; +export const generalAccessLimitPages = new Set(['nation-list', 'npc-control']); + +export const generalAccessLimitEndpoints = new Set([ + 'world.getGeneralDirectory', + 'tournament.getSnapshot', + 'nation.getSecretGeneralList', + 'nation.getGeneralList', + 'nation.getStratFinan', + 'nation.getBattleCenter', + 'nation.getChiefCenter', + 'board.getArticles', + 'board.writeArticle', + 'board.writeComment', + 'diplomacy.getLetters', + 'diplomacy.sendLetter', + 'diplomacy.respondLetter', + 'diplomacy.rollbackLetter', + 'diplomacy.destroyLetter', + 'betting.getList', + 'general.getFrontStatus', + 'yearbook.getHistory', + 'messages.send', + 'turns.getCommandTable', +]); + +export const generalAccessLimitBeforeRecordEndpoints = new Set(['general.getFrontStatus']); + +export type GeneralAccessState = { + refreshScore: number; + refreshLimit: number; + level: AccessLimitLevel; + nextAccessAt: Date; +}; + export const resolveGeneralAccessEndpointWeight = ( path: string, input: unknown, @@ -114,6 +149,58 @@ export const resolveAccessWindows = ( return { periodStartedAt: scoreStartedAt, scoreStartedAt }; }; +export const resolveGeneralScoreStartedAt = (tickSeconds: number, nextTurnAt: Date): Date => + new Date(nextTurnAt.getTime() - Math.max(1, Math.floor(tickSeconds)) * 1_000); + +const formatAccessTime = (value: Date): string => { + const kst = new Date(value.getTime() + 9 * 60 * 60 * 1_000); + return kst.toISOString().slice(0, 19).replace('T', ' '); +}; + +export const formatGeneralAccessLimitMessage = (state: Pick): string => + `접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. ` + + `(다음 접속 가능 시각: ${formatAccessTime(state.nextAccessAt)}) ` + + '자신의 턴이 되면 다시 접속 가능합니다. 잠시 쉬어보세요.'; + +export const getGeneralAccessState = async ( + ctx: Pick +): Promise => { + const user = ctx.auth?.user; + if (!user || user.roles.some((role) => adminRoles.has(role))) { + return null; + } + const [general, worldState] = await Promise.all([ + ctx.db.general.findFirst({ + where: { userId: user.id }, + orderBy: { id: 'asc' }, + select: { id: true, turnTime: true }, + }), + ctx.db.worldState.findFirst({ + orderBy: { id: 'asc' }, + select: { tickSeconds: true, meta: true }, + }), + ]); + if (!general || !worldState) { + return null; + } + const access = await ctx.db.generalAccessLog.findUnique({ + where: { generalId: general.id }, + select: { lastRefresh: true, refreshScore: true }, + }); + const scoreStartedAt = resolveGeneralScoreStartedAt(worldState.tickSeconds, general.turnTime); + const refreshScore = + access?.lastRefresh && access.lastRefresh.getTime() < scoreStartedAt.getTime() + ? 0 + : (access?.refreshScore ?? 0); + const refreshLimit = resolveAccessRefreshLimit(worldState.tickSeconds, asRecord(worldState.meta).refreshLimit); + return { + refreshScore, + refreshLimit, + level: resolveAccessLimitLevel(refreshScore, refreshLimit), + nextAccessAt: general.turnTime, + }; +}; + export const upsertGeneralAccess = async ( db: Pick, input: { @@ -286,13 +373,13 @@ export const upsertGeneralAccess = async ( }; export const recordGeneralAccess = async ( - ctx: Pick, + ctx: Pick, page: AccessPage, now = new Date() ): Promise => recordGeneralAccessWeight(ctx, accessPageWeights[page], now); export const recordGeneralAccessWeight = async ( - ctx: Pick, + ctx: Pick, weight: number, now = new Date() ): Promise => { @@ -304,11 +391,25 @@ export const recordGeneralAccessWeight = async ( return false; } + const profileStatusSource = ctx.profileStatusSource; + if (!profileStatusSource) { + return false; + } + try { + if ((await profileStatusSource.get(ctx.profile.name)) !== 'RUNNING') { + return false; + } + } catch { + // 상태를 확인하지 못한 요청으로 사용자를 벌주지 않는다. 업무 요청은 + // 계속 진행하고 다음 요청에서 gateway 상태를 다시 확인한다. + return false; + } + const [general, worldState] = await Promise.all([ ctx.db.general.findFirst({ where: { userId: user.id }, orderBy: { id: 'asc' }, - select: { id: true, userId: true }, + select: { id: true, userId: true, turnTime: true }, }), ctx.db.worldState.findFirst({ orderBy: { id: 'asc' }, @@ -332,7 +433,8 @@ export const recordGeneralAccessWeight = async ( return false; } - const { periodStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta); + const { periodStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta); + const scoreStartedAt = resolveGeneralScoreStartedAt(worldState.tickSeconds, general.turnTime); await upsertGeneralAccess(ctx.db, { worldStateId: worldState.id, diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 4471a22c..03220de5 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -5,7 +5,15 @@ import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions'; import type { GameApiContext } from './context.js'; import { IdempotentTurnDaemonTransport } from './daemon/idempotentTransport.js'; import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundary.js'; -import { recordGeneralAccessWeight, resolveGeneralAccessEndpointWeight } from './services/generalAccess.js'; +import { + formatGeneralAccessLimitMessage, + generalAccessLimitBeforeRecordEndpoints, + generalAccessLimitEndpoints, + getGeneralAccessState, + recordGeneralAccessWeight, + resolveGeneralAccessEndpointWeight, + type GeneralAccessEndpoint, +} from './services/generalAccess.js'; const t = initTRPC.context().create(); @@ -84,7 +92,40 @@ const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, if (weight === null) { return next(); } + const endpoint = path as GeneralAccessEndpoint; + if (generalAccessLimitBeforeRecordEndpoints.has(endpoint)) { + const state = await getGeneralAccessState(ctx); + if (state?.level === 2) { + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + message: formatGeneralAccessLimitMessage(state), + }); + } + } await recordGeneralAccessWeight(ctx, weight); + if (generalAccessLimitEndpoints.has(endpoint) && !generalAccessLimitBeforeRecordEndpoints.has(endpoint)) { + const state = await getGeneralAccessState(ctx); + if (state?.level === 2) { + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + message: formatGeneralAccessLimitMessage(state), + }); + } + } + return next(); +}); + +const generalAccessLimitMiddleware = t.middleware(async ({ ctx, next }) => { + if (ctx.generalAccessTracking !== true) { + return next(); + } + const state = await getGeneralAccessState(ctx); + if (state?.level === 2) { + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + message: formatGeneralAccessLimitMessage(state), + }); + } return next(); }); @@ -116,6 +157,9 @@ export const sessionActivityProcedure = t.procedure; // 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과 // 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다. export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware); +export const accessLimitAuthedProcedure: typeof procedure = t.procedure + .use(requireAuthMiddleware) + .use(generalAccessLimitMiddleware); // 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를 // 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다. export const accessInputProcedure: typeof procedure.input = (input) => @@ -126,3 +170,5 @@ export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware); export const accessReadOnlyAuthedInputProcedure: typeof procedure.input = (input) => t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware); +export const accessLimitAuthedInputProcedure: typeof procedure.input = (input) => + t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessLimitMiddleware); diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index 9741acc8..96699fee 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -119,6 +119,7 @@ describe('buildTurnCommandTable', () => { 'che_단련', 'che_숙련전환', 'che_견문', + 'che_은퇴', 'che_장비매매', 'che_군량매매', 'che_내정특기초기화', @@ -135,9 +136,58 @@ describe('buildTurnCommandTable', () => { 'che_주민선정', ], 군사: ['che_징병', 'che_모병', 'che_훈련', 'che_사기진작', 'che_출병', 'che_집합', 'che_소집해제'], - 인사: ['che_이동', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'], - 계략: ['che_화계'], - 국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_거병', 'che_건국', 'che_선양', 'che_해산'], + 인사: ['che_이동', 'che_강행', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'], + 계략: ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'], + 국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_하야', 'che_거병', 'che_건국', 'che_선양', 'che_해산'], + }); + }); + + it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => { + const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) => + buildTurnCommandTable({ + worldState: buildWorldState(), + general, + city: buildCity(), + nation, + nationGenerals: null, + }); + const findCommand = (table: Awaited>, key: string) => + table.general.flatMap((group) => group.values).find((command) => command.key === key); + + const ordinary = await buildTable(buildGeneral()); + expect(findCommand(ordinary, 'che_강행')).toMatchObject({ + name: '강행', + reqArg: true, + possible: true, + inputFields: [{ key: 'destCityId', optionSource: 'cities' }], + }); + expect(findCommand(ordinary, 'che_은퇴')).toMatchObject({ + name: '은퇴', + possible: false, + status: 'blocked', + reason: '나이가 60세 이상이어야 합니다.', + }); + expect(findCommand(ordinary, 'che_하야')).toMatchObject({ + name: '하야', + possible: true, + status: 'available', + }); + + const oldEnough = await buildTable({ ...buildGeneral(), age: 60 } as GeneralRow); + expect(findCommand(oldEnough, 'che_은퇴')).toMatchObject({ possible: true, status: 'available' }); + + const ruler = await buildTable({ ...buildGeneral(), officerLevel: 12 } as GeneralRow); + expect(findCommand(ruler, 'che_하야')).toMatchObject({ + possible: false, + status: 'blocked', + reason: expect.stringContaining('군주'), + }); + + const neutral = await buildTable({ ...buildGeneral(), nationId: 0, officerLevel: 0 } as GeneralRow, null); + expect(findCommand(neutral, 'che_하야')).toMatchObject({ + possible: false, + status: 'blocked', + reason: '재야입니다.', }); }); diff --git a/app/game-api/test/dashboardRouter.test.ts b/app/game-api/test/dashboardRouter.test.ts index 859be381..6d9ab119 100644 --- a/app/game-api/test/dashboardRouter.test.ts +++ b/app/game-api/test/dashboardRouter.test.ts @@ -73,6 +73,7 @@ const buildContext = (authenticated: boolean) => { }, city: { findUnique: async () => null }, nation: { findUnique: async () => null }, + generalAccessLog: { findUnique: async () => null }, worldState: { findFirst: async () => ({ config: { const: {} } }) }, }, } as unknown as GameApiContext; diff --git a/app/game-api/test/generalAccessTracking.integration.test.ts b/app/game-api/test/generalAccessTracking.integration.test.ts index fa4d3c9a..0c138751 100644 --- a/app/game-api/test/generalAccessTracking.integration.test.ts +++ b/app/game-api/test/generalAccessTracking.integration.test.ts @@ -379,6 +379,7 @@ integration('general access tracking persistence', () => { name: yearbookProfile, scenario: 'default', }, + profileStatusSource: { get: async () => 'RUNNING' as const }, } as unknown as GameApiContext; const boundaryCaller = endpointBoundaryRouter.createCaller(context); diff --git a/app/game-api/test/generalAccessTracking.test.ts b/app/game-api/test/generalAccessTracking.test.ts index 87450fcb..5697fef3 100644 --- a/app/game-api/test/generalAccessTracking.test.ts +++ b/app/game-api/test/generalAccessTracking.test.ts @@ -5,16 +5,28 @@ import { z } from 'zod'; import type { GameApiContext } from '../src/context.js'; import type { DatabaseClient } from '../src/context.js'; -import { accessAuthedInputProcedure, router } from '../src/trpc.js'; +import { accessAuthedInputProcedure, accessLimitAuthedProcedure, router } from '../src/trpc.js'; import { accessPageWeights, generalAccessEndpointWeights, + getGeneralAccessState, recordGeneralAccess, recordGeneralAccessWeight, resolveGeneralAccessEndpointWeight, resolveAccessWindows, + resolveGeneralScoreStartedAt, } from '../src/services/generalAccess.js'; +const profile = { id: 'che', name: 'che:default', scenario: 'default' }; +const profileStatusSource = { get: vi.fn(async () => 'RUNNING' as const) }; +const accessContext = (db: DatabaseClient, token: GameSessionTokenPayload | null = auth()) => ({ + auth: token, + db, + profile, + profileStatusSource, + generalAccessTracking: true as const, +}); + const auth = (roles = ['user']): GameSessionTokenPayload => ({ version: 1, profile: 'che:default', @@ -30,7 +42,10 @@ const auth = (roles = ['user']): GameSessionTokenPayload => ({ sanctions: {}, }); -const buildDb = (meta: Record = {}) => { +const buildDb = ( + meta: Record = {}, + access: { lastRefresh: Date | null; refreshScore: number } | null = null +) => { const executeRaw = vi.fn(async (_query: unknown) => 1); const queryRaw = vi.fn(async (_query: unknown) => [{ id: 41 }]); const transaction = vi.fn( @@ -38,7 +53,11 @@ const buildDb = (meta: Record = {}) => { callback: (client: { $executeRaw: typeof executeRaw; $queryRaw: typeof queryRaw }) => Promise ) => callback({ $executeRaw: executeRaw, $queryRaw: queryRaw }) ); - const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' })); + const findGeneral = vi.fn(async () => ({ + id: 7, + userId: 'user-7', + turnTime: new Date('2026-07-26T03:10:00.000Z'), + })); const findWorld = vi.fn(async () => ({ id: 3, currentYear: 185, @@ -53,6 +72,7 @@ const buildDb = (meta: Record = {}) => { const db = { $transaction: transaction, general: { findFirst: findGeneral }, + generalAccessLog: { findUnique: vi.fn(async () => access) }, worldState: { findFirst: findWorld }, } as unknown as DatabaseClient; return { db, executeRaw, queryRaw, transaction, findGeneral, findWorld }; @@ -91,6 +111,7 @@ describe('general access tracking', () => { 'general.dieOnPrestart': 1, 'general.instantRetreat': 1, 'messages.send': 1, + 'turns.getCommandTable': 1, 'general.setMySetting': 0, 'npc.setNationPolicy': 0, 'npc.setNationPriority': 0, @@ -120,17 +141,20 @@ describe('general access tracking', () => { periodStartedAt: new Date('2026-07-26T03:10:00.000Z'), scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'), }); + expect(resolveGeneralScoreStartedAt(600, new Date('2026-07-26T03:20:00.000Z'))).toEqual( + new Date('2026-07-26T03:10:00.000Z') + ); }); it('uses the session user actor and the legacy page weight in one atomic upsert', async () => { const { db, executeRaw, queryRaw, transaction, findGeneral } = buildDb(); const now = new Date('2026-07-26T03:05:00.000Z'); - await expect(recordGeneralAccess({ auth: auth(), db }, 'nation-list', now)).resolves.toBe(true); + await expect(recordGeneralAccess(accessContext(db), 'nation-list', now)).resolves.toBe(true); expect(findGeneral).toHaveBeenCalledWith({ where: { userId: 'user-7' }, orderBy: { id: 'asc' }, - select: { id: true, userId: true }, + select: { id: true, userId: true, turnTime: true }, }); expect(transaction).toHaveBeenCalledTimes(1); expect(queryRaw).toHaveBeenCalledTimes(1); @@ -167,7 +191,7 @@ describe('general access tracking', () => { const { db, executeRaw, queryRaw, transaction } = buildDb(); const now = new Date('2026-07-26T03:06:00.000Z'); - await expect(recordGeneralAccessWeight({ auth: auth(), db }, 0, now)).resolves.toBe(true); + await expect(recordGeneralAccessWeight(accessContext(db), 0, now)).resolves.toBe(true); expect(transaction).toHaveBeenCalledTimes(1); expect((queryRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0); expect((executeRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0); @@ -175,14 +199,42 @@ describe('general access tracking', () => { expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(now); }); + it('blocks above the strict limit and lazily clears a score from before the own turn', async () => { + const blocked = buildDb( + { refreshLimit: 120 }, + { lastRefresh: new Date('2026-07-26T03:05:00.000Z'), refreshScore: 121 } + ); + await expect(getGeneralAccessState(accessContext(blocked.db))).resolves.toMatchObject({ + refreshScore: 121, + refreshLimit: 120, + level: 2, + nextAccessAt: new Date('2026-07-26T03:10:00.000Z'), + }); + + const stale = buildDb( + { refreshLimit: 120 }, + { lastRefresh: new Date('2026-07-26T02:59:59.999Z'), refreshScore: 999 } + ); + await expect(getGeneralAccessState(accessContext(stale.db))).resolves.toMatchObject({ + refreshScore: 0, + level: 0, + }); + + const resolver = vi.fn(() => ({ ok: true })); + const limitedRouter = router({ read: accessLimitAuthedProcedure.query(resolver) }); + await expect( + limitedRouter.createCaller(accessContext(blocked.db) as unknown as GameApiContext).read() + ).rejects.toMatchObject({ + code: 'TOO_MANY_REQUESTS', + message: expect.stringContaining('자신의 턴이 되면 다시 접속 가능합니다.'), + }); + expect(resolver).not.toHaveBeenCalled(); + }); + it('rejects weights that cannot come from a server-owned Ref call boundary', async () => { const fixture = buildDb(); - await expect(recordGeneralAccessWeight({ auth: auth(), db: fixture.db }, -1)).rejects.toBeInstanceOf( - RangeError - ); - await expect(recordGeneralAccessWeight({ auth: auth(), db: fixture.db }, 0.5)).rejects.toBeInstanceOf( - RangeError - ); + await expect(recordGeneralAccessWeight(accessContext(fixture.db), -1)).rejects.toBeInstanceOf(RangeError); + await expect(recordGeneralAccessWeight(accessContext(fixture.db), 0.5)).rejects.toBeInstanceOf(RangeError); expect(fixture.findGeneral).not.toHaveBeenCalled(); }); @@ -201,6 +253,13 @@ describe('general access tracking', () => { findFirst: vi.fn(async () => ({ id: 7, userId: 'user-7', + turnTime: new Date('2026-07-26T03:10:00.000Z'), + })), + }, + generalAccessLog: { + findUnique: vi.fn(async () => ({ + lastRefresh: new Date('2026-07-26T03:05:00.000Z'), + refreshScore: 1, })), }, worldState: { @@ -253,6 +312,7 @@ describe('general access tracking', () => { generalAccessTracking: true, requestId: 'access-boundary-test', profile: { id: 'che:default', name: 'che' }, + profileStatusSource, } as unknown as GameApiContext; await expect(trackedRouter.createCaller(context).board.writeArticle({ value: 'ok' })).rejects.toMatchObject({ @@ -279,21 +339,37 @@ describe('general access tracking', () => { it('does not write for anonymous/admin users, a future opening, or a finished world', async () => { const anonymous = buildDb(); - await expect(recordGeneralAccess({ auth: null, db: anonymous.db }, 'traffic')).resolves.toBe(false); + await expect(recordGeneralAccess(accessContext(anonymous.db, null), 'traffic')).resolves.toBe(false); expect(anonymous.findGeneral).not.toHaveBeenCalled(); const admin = buildDb(); - await expect(recordGeneralAccess({ auth: auth(['admin']), db: admin.db }, 'traffic')).resolves.toBe(false); + await expect(recordGeneralAccess(accessContext(admin.db, auth(['admin'])), 'traffic')).resolves.toBe(false); expect(admin.findGeneral).not.toHaveBeenCalled(); const future = buildDb({ opentime: '2026-07-27T00:00:00.000Z' }); await expect( - recordGeneralAccess({ auth: auth(), db: future.db }, 'traffic', new Date('2026-07-26T03:05:00.000Z')) + recordGeneralAccess(accessContext(future.db), 'traffic', new Date('2026-07-26T03:05:00.000Z')) ).resolves.toBe(false); expect(future.transaction).not.toHaveBeenCalled(); const united = buildDb({ isUnited: 2 }); - await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false); + await expect(recordGeneralAccess(accessContext(united.db), 'traffic')).resolves.toBe(false); expect(united.transaction).not.toHaveBeenCalled(); + + const paused = buildDb(); + const pausedContext = { + ...accessContext(paused.db), + profileStatusSource: { get: vi.fn(async () => 'PAUSED' as const) }, + }; + await expect(recordGeneralAccess(pausedContext, 'traffic')).resolves.toBe(false); + expect(paused.findGeneral).not.toHaveBeenCalled(); + + const unavailable = buildDb(); + const unavailableContext = { + ...accessContext(unavailable.db), + profileStatusSource: { get: vi.fn(async () => Promise.reject(new Error('gateway unavailable'))) }, + }; + await expect(recordGeneralAccess(unavailableContext, 'traffic')).resolves.toBe(false); + expect(unavailable.findGeneral).not.toHaveBeenCalled(); }); }); diff --git a/app/game-api/test/lobbyRouter.test.ts b/app/game-api/test/lobbyRouter.test.ts index 3ba1b6f9..ecd9de5a 100644 --- a/app/game-api/test/lobbyRouter.test.ts +++ b/app/game-api/test/lobbyRouter.test.ts @@ -3,7 +3,15 @@ import { describe, expect, it, vi } from 'vitest'; import type { DatabaseClient, GameApiContext } from '../src/context.js'; import { appRouter } from '../src/router.js'; -const buildContext = (meta: Record): GameApiContext => +const buildContext = ( + meta: Record, + clock: { + baseTime?: Date; + tick?: bigint; + mode?: string; + wallAnchor?: Date; + } = {} +): GameApiContext => ({ auth: null, db: { @@ -16,6 +24,10 @@ const buildContext = (meta: Record): GameApiContext => tickSeconds: 3_600, config: {}, meta, + clockBaseTime: clock.baseTime ?? null, + clockTick: clock.tick ?? null, + clockMode: clock.mode ?? 'realtime', + clockWallAnchor: clock.wallAnchor ?? null, updatedAt: new Date('2026-07-31T00:00:00.000Z'), })), }, @@ -36,4 +48,23 @@ describe('lobby season state', () => { expect(result.isUnited).toBe(isunited); }); + + it('returns the projected server game time and whether the clock is running', async () => { + const result = await appRouter + .createCaller( + buildContext( + {}, + { + baseTime: new Date('2026-08-15T00:00:00.000Z'), + tick: 72_000_000n, + mode: 'manual', + wallAnchor: new Date('2026-08-15T17:00:00.000Z'), + } + ) + ) + .lobby.info(); + + expect(result.serverTime).toBe('2026-08-15T02:00:00.000Z'); + expect(result.clockMode).toBe('manual'); + }); }); diff --git a/app/game-api/test/profileStatusSource.test.ts b/app/game-api/test/profileStatusSource.test.ts new file mode 100644 index 00000000..c92cd2e4 --- /dev/null +++ b/app/game-api/test/profileStatusSource.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { GatewayHttpProfileStatusSource } from '../src/auth/profileStatusSource.js'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('GatewayHttpProfileStatusSource', () => { + it('uses an encoded path and a purpose-derived credential', async () => { + const fetchMock = vi.fn<(input: string | URL, init?: RequestInit) => Promise>( + async () => + new Response(JSON.stringify({ profileName: 'che:default/한글', status: 'RUNNING' }), { status: 200 }) + ); + vi.stubGlobal('fetch', fetchMock); + + const source = new GatewayHttpProfileStatusSource('http://gateway.internal/', 'root-secret'); + await expect(source.get('che:default/한글')).resolves.toBe('RUNNING'); + + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('http://gateway.internal/internal/profile-status/che%3Adefault%2F%ED%95%9C%EA%B8%80'); + expect(init?.headers).toMatchObject({ + 'x-sammo-internal-token': expect.not.stringContaining('root-secret'), + }); + }); + + it('returns null only for a missing profile and rejects malformed status values', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 404 })) + ); + await expect(new GatewayHttpProfileStatusSource('http://gateway', 'secret').get('missing')).resolves.toBeNull(); + + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ profileName: 'che', status: 'UNKNOWN' }), { status: 200 })) + ); + await expect(new GatewayHttpProfileStatusSource('http://gateway', 'secret').get('che')).rejects.toThrow( + 'invalid' + ); + }); +}); diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 2a2c2505..64948f22 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -914,6 +914,7 @@ export const createDatabaseTurnHooks = async ( let persistedVisibleLogs: PersistedVisibleLogRow[] = []; let visibleLogFloor = directLogFloor; const { + accessScoreResetGeneralIds, generals, cities, nations, @@ -1012,6 +1013,13 @@ export const createDatabaseTurnHooks = async ( world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0) ); + if (accessScoreResetGeneralIds.length > 0) { + await prisma.generalAccessLog.updateMany({ + where: { generalId: { in: accessScoreResetGeneralIds } }, + data: { refreshScore: 0 }, + }); + } + if (inheritancePointAdjustments.length > 0) { const grouped = new Map(); for (const entry of inheritancePointAdjustments) { diff --git a/app/game-engine/src/turn/inMemoryTurnProcessor.ts b/app/game-engine/src/turn/inMemoryTurnProcessor.ts index 2539d707..2e438782 100644 --- a/app/game-engine/src/turn/inMemoryTurnProcessor.ts +++ b/app/game-engine/src/turn/inMemoryTurnProcessor.ts @@ -2,7 +2,7 @@ import type { TurnCheckpoint, TurnProcessor, TurnRunBudget, TurnRunResult } from import { getNextTickTime } from '../lifecycle/getNextTickTime.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { TurnGeneral } from './types.js'; -import { asNumber, asRecord } from '@sammo-ts/common'; +import { asNumber, asRecord, calculateAccessRefreshLimit } from '@sammo-ts/common'; export interface InMemoryTurnProcessorOptions { tickMinutes?: number; @@ -50,6 +50,9 @@ export class InMemoryTurnProcessor implements TurnProcessor { const isBudgetExpired = () => Date.now() >= deadlineMs; this.world.setCheckpoint(checkpoint); + this.world.updateWorldMeta({ + refreshLimit: calculateAccessRefreshLimit(this.world.getState().tickSeconds), + }); let processedGenerals = 0; let processedTurns = 0; @@ -97,6 +100,9 @@ export class InMemoryTurnProcessor implements TurnProcessor { if (executionError !== undefined) { throw executionError; } + // Ref의 updateTurnTime()은 장수 명령이 성공한 뒤 그 장수의 + // 순간 벌점을 같은 턴 flush에서 초기화한다. + this.world.markGeneralAccessScoreReset(general.id); processedGenerals += 1; nextCheckpoint = { turnTime: executedAt.toISOString(), diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 72a90e01..3831011b 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -115,6 +115,7 @@ export interface InMemoryGameClockState { } export interface TurnWorldChanges { + accessScoreResetGeneralIds: number[]; generals: TurnGeneral[]; cities: City[]; nations: Nation[]; @@ -156,6 +157,7 @@ export interface InMemoryTurnWorldStateSnapshot { dirtyNationIds: number[]; dirtyTroopIds: number[]; dirtyDiplomacyKeys: string[]; + accessScoreResetGeneralIds: number[]; createdGeneralIds: number[]; createdNationIds: number[]; createdTroopIds: number[]; @@ -419,6 +421,7 @@ export class InMemoryTurnWorld { private readonly dirtyNationIds = new Set(); private readonly dirtyTroopIds = new Set(); private readonly dirtyDiplomacyKeys = new Set(); + private readonly accessScoreResetGeneralIds = new Set(); private readonly createdGeneralIds = new Set(); private nextLegacyGeneralScanOrder = 0; private readonly createdNationIds = new Set(); @@ -606,6 +609,7 @@ export class InMemoryTurnWorld { dirtyNationIds: Array.from(this.dirtyNationIds), dirtyTroopIds: Array.from(this.dirtyTroopIds), dirtyDiplomacyKeys: Array.from(this.dirtyDiplomacyKeys), + accessScoreResetGeneralIds: Array.from(this.accessScoreResetGeneralIds), createdGeneralIds: Array.from(this.createdGeneralIds), createdNationIds: Array.from(this.createdNationIds), createdTroopIds: Array.from(this.createdTroopIds), @@ -644,6 +648,7 @@ export class InMemoryTurnWorld { this.replaceSet(this.dirtyNationIds, restored.dirtyNationIds); this.replaceSet(this.dirtyTroopIds, restored.dirtyTroopIds); this.replaceSet(this.dirtyDiplomacyKeys, restored.dirtyDiplomacyKeys); + this.replaceSet(this.accessScoreResetGeneralIds, restored.accessScoreResetGeneralIds ?? []); this.replaceSet(this.createdGeneralIds, restored.createdGeneralIds); this.replaceSet(this.createdNationIds, restored.createdNationIds); this.replaceSet(this.createdTroopIds, restored.createdTroopIds); @@ -693,6 +698,12 @@ export class InMemoryTurnWorld { }; } + markGeneralAccessScoreReset(generalId: number): void { + if (Number.isSafeInteger(generalId) && generalId > 0) { + this.accessScoreResetGeneralIds.add(generalId); + } + } + changeTurnTerm(tickMinutes: number): void { if (!Number.isInteger(tickMinutes) || tickMinutes <= 0) { throw new Error('Turn term must be a positive integer.'); @@ -1512,8 +1523,12 @@ export class InMemoryTurnWorld { })); const pendingYearbookSnapshots = structuredClone(this.pendingYearbookSnapshots); const pendingUnificationFinalizations = structuredClone(this.pendingUnificationFinalizations); + const accessScoreResetGeneralIds = Array.from(this.accessScoreResetGeneralIds).sort( + (left, right) => left - right + ); return { + accessScoreResetGeneralIds, generals, cities, nations, @@ -1542,6 +1557,7 @@ export class InMemoryTurnWorld { } acknowledgeDirtyState(changes: TurnWorldChanges): void { + for (const id of changes.accessScoreResetGeneralIds) this.accessScoreResetGeneralIds.delete(id); for (const general of changes.generals) this.dirtyGeneralIds.delete(general.id); for (const city of changes.cities) this.dirtyCityIds.delete(city.id); for (const nation of changes.nations) this.dirtyNationIds.delete(nation.id); diff --git a/app/game-engine/src/turn/joinCreateGeneralService.ts b/app/game-engine/src/turn/joinCreateGeneralService.ts index 5ce18abb..c5b52545 100644 --- a/app/game-engine/src/turn/joinCreateGeneralService.ts +++ b/app/game-engine/src/turn/joinCreateGeneralService.ts @@ -330,8 +330,8 @@ export const cutJoinTurnTime = (value: Date, tickSeconds: number): Date => { return new Date(baseTime + alignedSeconds * 1000); }; -const resolveTurnTime = ( - rng: RandUtil, +export const resolveJoinTurnTime = ( + rng: Pick, worldState: WorldStateRow, acceptedAt: Date, runtimeTurnTime: Date, @@ -348,7 +348,12 @@ const resolveTurnTime = ( offsetSeconds = inheritTurntimeZone * legacyTurnTermMinutes + rng.nextRangeInt(0, legacyTurnTermMinutes - 1); offsetMicros = rng.nextRangeInt(0, 999_999); } else { - turnTimeBase = base; + // Ref normally uses game_env.turntime as a near-current cursor. Core's + // durable daemon can legitimately be catching up from an older cursor, + // so scheduling from runtimeTurnTime may put a newly created general + // hours behind the game clock. The accepted game time is the equivalent + // current-time boundary for a new general. + turnTimeBase = acceptedAt; offsetSeconds = rng.nextRangeInt(0, tickSeconds - 1); offsetMicros = rng.nextRangeInt(0, 999_999); } @@ -662,7 +667,7 @@ export const createGeneralFromJoin = async (options: { } const experience = await resolveCatchupExperience(db, relativeYear); - const turnTime = resolveTurnTime( + const turnTime = resolveJoinTurnTime( rng, worldState, acceptedAt, diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index c9d4cf1a..395b8e99 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -87,6 +87,9 @@ const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([ 'che_견문', 'che_무작위건국', 'che_화계', + 'che_선동', + 'che_파괴', + 'che_탈취', 'che_집합', 'cr_건국', 'che_이동', diff --git a/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts b/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts index c8c8cc06..f71197ee 100644 --- a/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts +++ b/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts @@ -18,15 +18,23 @@ const GENERAL_AI_ACTIONS = [ const NATION_AI_ACTIONS = ['che_몰수', 'che_발령', 'che_선전포고', 'che_천도', 'che_포상'] as const; const GENERAL_REF_EDITOR_ACTIONS = [ + 'che_은퇴', 'che_임관', 'che_랜덤임관', + 'che_강행', 'che_징병', 'che_출병', 'che_농지개간', + 'che_선동', + 'che_탈취', + 'che_파괴', 'che_화계', 'che_증여', + 'che_하야', 'che_장비매매', ] as const; +const GENERAL_REF_STRATEGY_ACTIONS = ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'] as const; +const GENERAL_REF_STRATEGY_ACTION_SET = new Set(GENERAL_REF_STRATEGY_ACTIONS); const NATION_REF_EDITOR_ACTIONS = ['che_포상', 'che_발령', 'che_증축', 'che_필사즉생'] as const; describe('default turn command profile AI coverage', () => { @@ -41,6 +49,9 @@ describe('default turn command profile AI coverage', () => { const profile = await loadTurnCommandProfile(); expect(profile.general).toEqual(expect.arrayContaining([...GENERAL_REF_EDITOR_ACTIONS])); + expect(profile.general.filter((action) => GENERAL_REF_STRATEGY_ACTION_SET.has(action))).toEqual( + GENERAL_REF_STRATEGY_ACTIONS + ); expect(profile.nation).toEqual(expect.arrayContaining([...NATION_REF_EDITOR_ACTIONS])); }); }); diff --git a/app/game-engine/test/generalAccessScoreResetPersistence.integration.test.ts b/app/game-engine/test/generalAccessScoreResetPersistence.integration.test.ts new file mode 100644 index 00000000..43eed0c0 --- /dev/null +++ b/app/game-engine/test/generalAccessScoreResetPersistence.integration.test.ts @@ -0,0 +1,120 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const generalId = 991_815; +const scenarioCode = 'general-access-score-reset-persistence'; + +integration('general access score reset persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + const cleanup = async () => { + await db.generalAccessLog.deleteMany({ where: { generalId } }); + await db.general.deleteMany({ where: { id: generalId } }); + await db.worldState.deleteMany({ where: { scenarioCode } }); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await cleanup(); + }); + + afterAll(async () => { + await cleanup(); + await closeDb?.(); + }); + + it('commits the own-turn reset marker in the same world flush', async () => { + const turnTime = new Date('2026-08-15T00:10:00.000Z'); + await db.general.create({ + data: { + id: generalId, + userId: 'access-reset-persistence-user', + name: '접속점수초기화장수', + turnTime, + }, + }); + await db.generalAccessLog.create({ + data: { + generalId, + userId: 'access-reset-persistence-user', + lastRefresh: new Date('2026-08-15T00:09:59.000Z'), + refresh: 120, + refreshTotal: 500, + refreshScore: 351, + refreshScoreTotal: 999, + }, + }); + const row = await db.worldState.create({ + data: { + scenarioCode, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + }, + }); + const state: TurnWorldState = { + id: row.id, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('2026-08-15T00:00:00.000Z'), + meta: {}, + }; + const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }; + const world = new InMemoryTurnWorld( + state, + { + scenarioConfig, + map: { id: 'test', name: 'test', cities: [] }, + generals: [], + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + }, + { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } } + ); + world.markGeneralAccessScoreReset(generalId); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + + try { + await hooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 1, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + + expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({ + refresh: 120, + refreshTotal: 500, + refreshScore: 0, + refreshScoreTotal: 999, + }); + expect(world.peekDirtyState().accessScoreResetGeneralIds).toEqual([]); + } finally { + await hooks.close(); + } + }); +}); diff --git a/app/game-engine/test/joinCreateGeneralService.test.ts b/app/game-engine/test/joinCreateGeneralService.test.ts index 10c08920..18d4ff3e 100644 --- a/app/game-engine/test/joinCreateGeneralService.test.ts +++ b/app/game-engine/test/joinCreateGeneralService.test.ts @@ -4,6 +4,7 @@ import { buildJoinCreateGeneralSeed, cutJoinTurnTime, JOIN_WELCOME_MESSAGE, + resolveJoinTurnTime, } from '../src/turn/joinCreateGeneralService.js'; describe('generic join legacy time contracts', () => { @@ -19,6 +20,35 @@ describe('generic join legacy time contracts', () => { ); }); + it('schedules a new general within one turn of the accepted game time even when the daemon cursor is stale', () => { + const calls: Array<[number, number]> = []; + const values = [59, 250_000]; + const rng = { + nextRangeInt(min: number, max: number) { + calls.push([min, max]); + return values.shift() ?? min; + }, + }; + const acceptedAt = new Date('2026-08-15T17:57:05.837Z'); + const staleRuntimeTurnTime = new Date('2026-08-15T07:10:00.000Z'); + + const turnTime = resolveJoinTurnTime( + rng, + { tickSeconds: 120 } as Parameters[1], + acceptedAt, + staleRuntimeTurnTime, + undefined + ); + + expect(turnTime.toISOString()).toBe('2026-08-15T17:58:05.087Z'); + expect(turnTime.getTime()).toBeGreaterThan(acceptedAt.getTime()); + expect(turnTime.getTime()).toBeLessThanOrEqual(acceptedAt.getTime() + 120_000); + expect(calls).toEqual([ + [0, 119], + [0, 999_999], + ]); + }); + it('uses the HiDCHe product name without the legacy PHP runtime label', () => { expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^'); expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP'); diff --git a/app/game-engine/test/turnOrder.test.ts b/app/game-engine/test/turnOrder.test.ts index 47fa8f74..46b649bc 100644 --- a/app/game-engine/test/turnOrder.test.ts +++ b/app/game-engine/test/turnOrder.test.ts @@ -174,6 +174,8 @@ describe('InMemoryTurnProcessor ordering', () => { const tiedGeneralResult = await processor.run(new Date(addMinutes(baseTime, 10).getTime() + 1), budget); expect(tiedGeneralResult.processedTurns).toBe(0); expect(executed).toEqual([3, 2]); + expect(world.peekDirtyState().accessScoreResetGeneralIds).toEqual([2, 3]); + expect(world.getState().meta).toMatchObject({ refreshLimit: 350 }); expect(world.getGeneralById(2)?.recentWarTime?.getTime()).toBe(baseTime.getTime()); expect(world.getGeneralById(2)?.recentWarTick).not.toBeNull(); expect(Number(world.getGeneralById(2)?.turnTick) % 10).toBe(4); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 2991c6ac..3b1c4a0f 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -114,7 +114,7 @@ const inputOptions = { const commandTable = { general: [ { - category: '군사', + category: '계략', values: [ { key: 'che_화계', @@ -133,6 +133,26 @@ const commandTable = { }, ], }, + ...[ + { key: 'che_선동', name: '선동' }, + { key: 'che_탈취', name: '탈취' }, + { key: 'che_파괴', name: '파괴' }, + ].map(({ key, name }) => ({ + key, + name, + reqArg: true, + possible: true, + status: 'needsInput', + inputFields: [ + { + key: 'destCityId', + label: '대상 도시', + kind: 'select', + required: true, + optionSource: 'cities', + }, + ], + })), ], }, { @@ -294,7 +314,7 @@ const chiefCenter = { })), }; -const install = async (page: Page, rejectGeneral = false) => { +const install = async (page: Page, rejectGeneral = false, commandTableResponse: unknown = commandTable) => { const requests: unknown[] = []; const generalTurns = turns(30); const nationTurns = turns(12); @@ -344,7 +364,7 @@ const install = async (page: Page, rejectGeneral = false) => { ? { kind: 'snapshot', revision: 'BBBBBBBBBBBBBBBBBBBBBB', - data: commandTable, + data: commandTableResponse, } : { kind: 'unchanged', revision: 'BBBBBBBBBBBBBBBBBBBBBB' }, boardAccess: initial @@ -400,7 +420,7 @@ const install = async (page: Page, rejectGeneral = false) => { myCity: 1, myNation: 1, }); - if (name === 'turns.getCommandTable') return response(commandTable); + if (name === 'turns.getCommandTable') return response(commandTableResponse); if (name === 'nation.getChiefCenter') return response(chiefCenter); if (name === 'turns.reserved.getGeneral') return response({ turns: generalTurns, revision: generalRevision }); @@ -461,6 +481,143 @@ const install = async (page: Page, rejectGeneral = false) => { return requests; }; +test('renders and accepts every Ref strategy command at mobile width', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 500, height: 900 }); + await page.goto('/'); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + + const picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '계략', exact: true }).click(); + const strategies = [ + { name: '선동', guidance: '선택한 도시에 선동을 실행합니다.' }, + { name: '탈취', guidance: '선택한 도시에 탈취를 실행합니다.' }, + { name: '파괴', guidance: '선택한 도시에 파괴를 실행합니다.' }, + { name: '화계', guidance: '선택한 도시에 화계를 실행합니다.' }, + ]; + for (const strategy of strategies) { + const button = picker.getByRole('button', { name: strategy.name, exact: true }); + await expect(button).toBeVisible(); + await button.click(); + const form = picker.getByTestId('command-argument-form'); + await expect(form.getByTestId('command-argument-guidance')).toContainText(strategy.guidance); + await expect(form.locator('select option')).toHaveCount(2); + await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click(); + } + await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') }); +}); + +test('reserves force move, retirement, and resignation from the user command picker', async ({ page }) => { + const specialCommandTable = { + general: [ + { + category: '개인', + values: [ + { + key: 'che_은퇴', + name: '은퇴', + reqArg: false, + possible: false, + status: 'blocked', + reason: '나이가 60세 이상이어야 합니다.', + inputFields: [], + }, + ], + }, + { + category: '인사', + values: [ + { + key: 'che_강행', + name: '강행', + reqArg: true, + possible: true, + status: 'available', + inputFields: [ + { + key: 'destCityId', + label: '대상 도시', + kind: 'select', + required: true, + optionSource: 'cities', + }, + ], + }, + ], + }, + { + category: '국가', + values: [ + { + key: 'che_하야', + name: '하야', + reqArg: false, + possible: true, + status: 'available', + inputFields: [], + }, + ], + }, + ], + nation: [], + inputOptions, + }; + const requests = await install(page, false, specialCommandTable); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('/'); + + const editor = page.locator('[data-command-scope="general"]'); + + await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + let picker = page.getByTestId('command-picker'); + const retirement = picker.getByRole('button', { name: '은퇴', exact: true }); + await expect(retirement).toHaveClass(/blocked/); + await expect(retirement).toHaveAttribute('title', '나이가 60세 이상이어야 합니다.'); + await retirement.hover(); + await retirement.focus(); + await expect(retirement).toBeFocused(); + await picker.screenshot({ path: test.info().outputPath('special-user-commands-desktop-1200.png') }); + await retirement.click(); + await expect(editor.locator('.action-column > div').nth(0)).toHaveText('은퇴'); + + await editor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click(); + picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '국가', exact: true }).click(); + await picker.getByRole('button', { name: '하야', exact: true }).click(); + await expect(editor.locator('.action-column > div').nth(1)).toHaveText('하야'); + + await editor.getByRole('button', { name: '3턴 명령 입력', exact: true }).click(); + picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '인사', exact: true }).click(); + await picker.getByRole('button', { name: '강행', exact: true }).click(); + const forceMoveForm = picker.getByTestId('command-argument-form'); + await expect(forceMoveForm.getByTestId('command-argument-guidance')).toContainText('선택한 도시로 강행합니다.'); + await forceMoveForm.locator('select').selectOption('2'); + await picker.getByRole('button', { name: '입력', exact: true }).click(); + await expect(editor.locator('.action-column > div').nth(2)).toHaveText('강행'); + + const serialized = JSON.stringify(requests); + expect(serialized).toContain('"action":"che_은퇴","args":{}'); + expect(serialized).toContain('"action":"che_하야","args":{}'); + expect(serialized).toContain('"action":"che_강행","args":{"destCityId":2}'); + + await page.setViewportSize({ width: 500, height: 900 }); + await editor.getByRole('button', { name: '4턴 명령 입력', exact: true }).click(); + picker = page.getByTestId('command-picker'); + await expect(picker.locator('.category-btn')).toHaveText(['개인', '인사', '국가']); + const mobileGeometry = await picker.evaluate((element) => ({ + width: element.getBoundingClientRect().width, + horizontalOverflow: element.scrollWidth - element.clientWidth, + categoryColumns: getComputedStyle(element.querySelector('.category-list')!).gridTemplateColumns, + })); + expect(mobileGeometry.width).toBeLessThanOrEqual(500); + expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0); + expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3); + await picker.getByRole('button', { name: '개인', exact: true }).click(); + await expect(picker.getByRole('button', { name: '은퇴', exact: true })).toBeVisible(); + await picker.screenshot({ path: test.info().outputPath('special-user-commands-mobile-500.png') }); +}); + test('enters general and nation command arguments and sends exact values', async ({ page }) => { const requests = await install(page); await page.setViewportSize({ width: 1200, height: 900 }); diff --git a/app/game-frontend/e2e/commandArgumentsLive.spec.ts b/app/game-frontend/e2e/commandArgumentsLive.spec.ts index 27425cfb..811a54bd 100644 --- a/app/game-frontend/e2e/commandArgumentsLive.spec.ts +++ b/app/game-frontend/e2e/commandArgumentsLive.spec.ts @@ -69,29 +69,36 @@ test('reserves an argument command in the real game API and reads it back from P try { await page.goto('/'); await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); - await page.getByRole('button', { name: '계략', exact: true }).click(); - await page.getByRole('button', { name: /화계/ }).click(); - const form = page.getByTestId('command-argument-form'); - await expect(form).toBeVisible(); - const citySelect = form.locator('select'); - const optionValues = await citySelect - .locator('option') - .evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value)); - const targetCityId = Number(optionValues.find((value) => Number(value) !== context.general.cityId)); - expect(targetCityId).toBeGreaterThan(0); - await citySelect.selectOption(String(targetCityId)); - const generalSection = page.locator('.reserved-section').filter({ hasText: '일반 예턴' }); const lastTurn = generalSection.locator('.reserved-item').nth(29); - await lastTurn.getByRole('button', { name: '배치' }).click(); - await expect(lastTurn.locator('.turn-action')).toHaveText('che_화계'); + const form = page.getByTestId('command-argument-form'); + for (const strategy of [ + { key: 'che_선동', name: '선동' }, + { key: 'che_탈취', name: '탈취' }, + { key: 'che_파괴', name: '파괴' }, + { key: 'che_화계', name: '화계' }, + ]) { + await page.getByRole('button', { name: '계략', exact: true }).click(); + await page.getByRole('button', { name: new RegExp(strategy.name) }).click(); + await expect(form).toBeVisible(); + const citySelect = form.locator('select'); + const optionValues = await citySelect + .locator('option') + .evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value)); + const targetCityId = Number(optionValues.find((value) => Number(value) !== context.general.cityId)); + expect(targetCityId).toBeGreaterThan(0); + await citySelect.selectOption(String(targetCityId)); - const persisted = (await game.turns.reserved.getGeneral.query({ generalId })).turns[29]; - expect(persisted).toEqual({ - index: 29, - action: 'che_화계', - args: { destCityId: targetCityId }, - }); + await lastTurn.getByRole('button', { name: '배치' }).click(); + await expect(lastTurn.locator('.turn-action')).toHaveText(strategy.key); + + const persisted = (await game.turns.reserved.getGeneral.query({ generalId })).turns[29]; + expect(persisted).toEqual({ + index: 29, + action: strategy.key, + args: { destCityId: targetCityId }, + }); + } await page.getByRole('button', { name: '국가:인사', exact: true }).click(); await page.getByRole('button', { name: /포상/ }).click(); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index c83872fa..ff5750a3 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -3,6 +3,13 @@ import { resolve } from 'node:path'; import { expect, test, type Page, type Route } from '@playwright/test'; const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string) => ({ + error: { + message, + code: -32029, + data: { code: 'TOO_MANY_REQUESTS', httpStatus: 429, path }, + }, +}); const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR; const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR; const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production'; @@ -21,6 +28,8 @@ type NavigationFixture = { operations: string[]; generalName?: string; generalTurnTime?: string; + serverTime?: string; + clockMode?: 'realtime' | 'manual'; cityDefence?: number; cityState?: number; nationRate?: number; @@ -31,6 +40,7 @@ type NavigationFixture = { commandBlockedCount?: number; forceSnapshotCalls?: number; refreshDelayMs?: number; + accessLimitAfterCalls?: number; largeCommandTable?: boolean; refCommandCategories?: boolean; currentYear?: number; @@ -363,11 +373,21 @@ const installFixture = async (page: Page, state: NavigationFixture) => { year: state.currentYear ?? 185, month: state.currentMonth ?? 1, turnTerm: 10, + serverTime: state.serverTime ?? '2026-08-13T00:00:00.000Z', + clockMode: state.clockMode ?? 'realtime', scenarioTitle: state.scenarioTitle ?? '', }); } if (operation === 'dashboard.getContextBundleDelta') { state.generalMeCalls += 1; + if (state.accessLimitAfterCalls !== undefined && state.generalMeCalls > state.accessLimitAfterCalls) { + return errorResponse( + operation, + '접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. ' + + '(다음 접속 가능 시각: 2026-08-15 12:34:56) ' + + '자신의 턴이 되면 다시 접속 가능합니다. 잠시 쉬어보세요.' + ); + } const input = operationInput(route, index); const include = input.include ?? {}; const forceSnapshot = input.forceSnapshot === true; @@ -499,7 +519,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => { operations.forEach((operation, index) => { if (operation !== 'dashboard.getContextBundleDelta') return; const item = results[index]; - if (!item) return; + if (!item || !('result' in item)) return; const data = item.result.data as { context?: { kind: string }; commandTable?: { kind: string }; @@ -787,7 +807,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); -test('main general card and command clock render the next turn with second precision', async ({ page }) => { +test('main general card uses local turn time and command clock tracks corrected server time', async ({ page }) => { const state: NavigationFixture = { officerLevel: 0, permission: 0, @@ -797,22 +817,29 @@ test('main general card and command clock render the next turn with second preci generalMeCalls: 0, operations: [], generalName: 'Administrator', - generalTurnTime: '2026-08-13T00:07:06.713Z', + generalTurnTime: '2026-08-13T00:09:10.713Z', + serverTime: '2026-08-13T00:07:06.250Z', + clockMode: 'realtime', currentYear: 179, currentMonth: 8, }; await installFixture(page, state); + await page.clock.install({ time: new Date('2026-08-13T00:00:00.000Z') }); + const cdp = await page.context().newCDPSession(page); + await cdp.send('Emulation.setTimezoneOverride', { timezoneId: 'Asia/Seoul' }); await page.setViewportSize({ width: 1200, height: 900 }); await waitForMain(page); const title = page.locator('[data-main-target="general"] .general-title').first(); await expect(title).toContainText('Administrator'); await expect(title).toContainText('용장'); - await expect(title).toContainText('09:07:06'); - await expect(title).not.toContainText('00:07'); + await expect(title).toContainText('09:09:10'); + await expect(title).not.toContainText('00:09'); const commandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first(); await expect(commandClock).toHaveText('09:07:06'); await expect(commandClock).not.toHaveText('00:07'); + await page.clock.runFor(1_000); + await expect(commandClock).toHaveText('09:07:07'); const generalCard = page.locator('[data-main-target="general"] [data-general-basic-card]').first(); await expect(generalCard).toContainText('수비 함(훈사80)'); await expect(generalCard).toContainText('5 턴'); @@ -857,9 +884,9 @@ test('main general card and command clock render the next turn with second preci const target = resolve(artifactRoot); await mkdir(target, { recursive: true }); await Promise.all([ - page.screenshot({ path: resolve(target, 'main-turn-time-seoul-desktop-1200.png'), fullPage: true }), + page.screenshot({ path: resolve(target, 'main-turn-time-local-desktop-1200.png'), fullPage: true }), writeFile( - resolve(target, 'main-turn-time-seoul-desktop-1200.json'), + resolve(target, 'main-turn-time-local-desktop-1200.json'), `${JSON.stringify({ title: desktopGeometry, commandClock: desktopClockGeometry }, null, 2)}\n` ), ]); @@ -867,9 +894,9 @@ test('main general card and command clock render the next turn with second preci await page.setViewportSize({ width: 500, height: 900 }); const mobileTitle = page.locator('[data-main-target="general"] .general-title').first(); - await expect(mobileTitle).toContainText('09:07:06'); + await expect(mobileTitle).toContainText('09:09:10'); const mobileCommandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first(); - await expect(mobileCommandClock).toHaveText('09:07:06'); + await expect(mobileCommandClock).toHaveText('09:07:07'); const mobileGeometry = { title: await mobileTitle.evaluate((element) => ({ width: element.getBoundingClientRect().width, @@ -895,15 +922,23 @@ test('main general card and command clock render the next turn with second preci if (artifactRoot) { await Promise.all([ page.screenshot({ - path: resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.png'), + path: resolve(artifactRoot, 'main-turn-time-local-mobile-500.png'), fullPage: true, }), writeFile( - resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.json'), + resolve(artifactRoot, 'main-turn-time-local-mobile-500.json'), `${JSON.stringify(mobileGeometry, null, 2)}\n` ), ]); } + + state.clockMode = 'manual'; + state.serverTime = '2026-08-13T00:08:30.000Z'; + await page.reload(); + const frozenClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first(); + await expect(frozenClock).toHaveText('09:08:30'); + await page.clock.runFor(2_000); + await expect(frozenClock).toHaveText('09:08:30'); }); test('pure NPC message senders are not rendered as reply targets', async ({ page }) => { @@ -2041,7 +2076,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl }); await expect .poll(() => state.operations.slice(operationsBeforeSurvey), { timeout: 3_000 }) - .toEqual(['general.getFrontStatus']); + .toEqual(['dashboard.getContextBundleDelta', 'general.getFrontStatus']); const profile = await page.evaluate(() => { const probe = ( @@ -2199,6 +2234,55 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl expect(state.generalMeCalls).toBe(callsAfterLeavingMain); }); +test('access limit stops automatic main refresh and closes realtime until a manual retry can pass', async ({ + page, +}) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + accessLimitAfterCalls: 1, + }; + await installRealtimeHarness(page); + await installFixture(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await waitForMain(page); + await expect + .poll(() => + page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()) + ) + .toBe(true); + + const operationsBeforeLimit = state.operations.length; + await emitReadModelInvalidation(page, readModelInvalidation({ records: true, map: true })); + + await expect(page.getByRole('alert')).toContainText('접속 제한중입니다.'); + await expect + .poll(() => + page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()) + ) + .toBe(false); + expect(state.operations.slice(operationsBeforeLimit)).toEqual(['dashboard.getContextBundleDelta']); + + const operationsAfterLimit = state.operations.length; + await emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true })); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(state.operations).toHaveLength(operationsAfterLimit); + + state.accessLimitAfterCalls = undefined; + await page.getByRole('button', { name: '갱 신' }).click(); + await expect(page.getByRole('alert')).toHaveCount(0); + await expect + .poll(() => + page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()) + ) + .toBe(true); +}); + test('global activity, world history, and a month boundary refresh their visible main slices', async ({ page }) => { const state: NavigationFixture = { officerLevel: 5, @@ -2226,7 +2310,10 @@ test('global activity, world history, and a month boundary refresh their visible const operationsBeforeGlobal = state.operations.length; await emitReadModelInvalidation(page, readModelInvalidation({ records: true })); await expect(page.locator('[data-main-target="global-records"]')).toContainText('자동 갱신된 장수 동향'); - expect(state.operations.slice(operationsBeforeGlobal)).toEqual(['general.getRecentRecords']); + expect(state.operations.slice(operationsBeforeGlobal)).toEqual([ + 'dashboard.getContextBundleDelta', + 'general.getRecentRecords', + ]); state.worldHistory = [ { id: 5, text: '자동 갱신된 중원 정세' }, @@ -2235,7 +2322,10 @@ test('global activity, world history, and a month boundary refresh their visible const operationsBeforeHistory = state.operations.length; await emitReadModelInvalidation(page, readModelInvalidation({ records: true })); await expect(page.locator('[data-main-target="world-history"]')).toContainText('자동 갱신된 중원 정세'); - expect(state.operations.slice(operationsBeforeHistory)).toEqual(['general.getRecentRecords']); + expect(state.operations.slice(operationsBeforeHistory)).toEqual([ + 'dashboard.getContextBundleDelta', + 'general.getRecentRecords', + ]); state.currentMonth = 2; const operationsBeforeMonth = state.operations.length; diff --git a/app/game-frontend/src/components/main/CommandListPanel.vue b/app/game-frontend/src/components/main/CommandListPanel.vue index 86ae7745..0a2d2124 100644 --- a/app/game-frontend/src/components/main/CommandListPanel.vue +++ b/app/game-frontend/src/components/main/CommandListPanel.vue @@ -1,8 +1,8 @@