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/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/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/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/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/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/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index c83872fa..6751dba8 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'; @@ -31,6 +38,7 @@ type NavigationFixture = { commandBlockedCount?: number; forceSnapshotCalls?: number; refreshDelayMs?: number; + accessLimitAfterCalls?: number; largeCommandTable?: boolean; refCommandCategories?: boolean; currentYear?: number; @@ -368,6 +376,14 @@ const installFixture = async (page: Page, state: NavigationFixture) => { } 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 +515,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 }; @@ -2041,7 +2057,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 +2215,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 +2291,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 +2303,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/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index b392d3d5..d2e3166b 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -82,6 +82,16 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const realtimeEnabled = ref(true); const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle'); const realtimeActive = ref(false); + const accessLimited = ref(false); + + const handleDashboardError = (value: unknown) => { + const message = resolveErrorMessage(value); + error.value = message; + if (message.startsWith('접속 제한중입니다.')) { + accessLimited.value = true; + realtimeStatus.value = 'paused'; + } + }; const general = ref(null); const city = ref(null); @@ -508,6 +518,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { { context: true, commandTable: true, boardAccess: true }, true ); + accessLimited.value = false; applyDashboardPatch(contextPatch); const context = contextSnapshot; @@ -573,7 +584,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } initialized = true; } catch (err) { - error.value = resolveErrorMessage(err); + handleDashboardError(err); } finally { if (isInitialLoad) { loading.value = false; @@ -626,14 +637,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { if (plan.records) recordsError.value = null; if (plan.frontStatus) frontStatusError.value = null; try { - const contextBundlePromise = - plan.context || plan.commands || plan.boardAccess - ? fetchContextBundlePatch({ - context: plan.context, - commandTable: plan.commands, - boardAccess: plan.boardAccess, - }) - : Promise.resolve(undefined); + const contextPatch = await fetchContextBundlePatch({ + // Every automatic refresh crosses this access-limit gate. The + // context delta is usually unchanged and therefore stays small. + context: true, + commandTable: plan.commands, + boardAccess: plan.boardAccess, + }); + accessLimited.value = false; const lobbyPromise = plan.lobby ? trpc.lobby.info.query() : Promise.resolve(undefined); const mapPromise = plan.map ? trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }) @@ -659,8 +670,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { }) : Promise.resolve(undefined); - const [contextPatch, lobby, map, contacts, generalTurns, records, nextFrontStatus] = await Promise.all([ - contextBundlePromise, + const [lobby, map, contacts, generalTurns, records, nextFrontStatus] = await Promise.all([ lobbyPromise, mapPromise, contactsPromise, @@ -669,7 +679,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { frontPromise, ]); - const patch: DashboardReadModelPatch = contextPatch ? { ...contextPatch } : {}; + const patch: DashboardReadModelPatch = { ...contextPatch }; if (lobby !== undefined) patch.lobbyInfo = lobby; if (map !== undefined) patch.worldMap = map; if (contacts !== undefined) patch.messageContacts = contacts; @@ -690,7 +700,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { applyDashboardPatch(patch); publishDashboardPatch(patch); } catch (err) { - error.value = resolveErrorMessage(err); + handleDashboardError(err); } finally { refreshing.value = false; } @@ -709,7 +719,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { applyDashboardPatch(patch); publishDashboardPatch(patch); } catch (err) { - error.value = resolveErrorMessage(err); + handleDashboardError(err); } }; @@ -970,6 +980,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { realtimeActive.value && document.visibilityState !== 'hidden' && realtimeEnabled.value && + !accessLimited.value && session.isReady && session.hasGeneral && generalId.value !== null; @@ -1158,11 +1169,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { session.profile, session.user?.id, generalId.value, + accessLimited.value, ], - ([active, enabled, ready, hasGeneral]) => { - realtimeStatus.value = !enabled ? 'paused' : realtimeStatus.value; + ([active, enabled, ready, hasGeneral, , , , , limited]) => { + realtimeStatus.value = !enabled || limited ? 'paused' : realtimeStatus.value; if (!active || !ready || !hasGeneral) { - realtimeStatus.value = enabled ? 'idle' : 'paused'; + realtimeStatus.value = enabled && !limited ? 'idle' : 'paused'; } reconcileRealtimeCoordinator(); } diff --git a/app/gateway-api/src/lobby/profileStatusInternalRoute.ts b/app/gateway-api/src/lobby/profileStatusInternalRoute.ts new file mode 100644 index 00000000..6dc46aab --- /dev/null +++ b/app/gateway-api/src/lobby/profileStatusInternalRoute.ts @@ -0,0 +1,44 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +import type { FastifyInstance } from 'fastify'; + +import type { GatewayProfileRepository } from '../orchestrator/profileRepository.js'; + +const INTERNAL_TOKEN_HEADER = 'x-sammo-internal-token'; +const INTERNAL_TOKEN_CONTEXT = 'sammo:profile-status-source:v1'; + +const deriveInternalToken = (secret: string): string => + createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex'); + +const matchesSecret = (provided: string | string[] | undefined, expected: string): boolean => { + const candidate = Array.isArray(provided) ? provided[0] : provided; + if (!candidate) { + return false; + } + const candidateBuffer = Buffer.from(candidate); + const expectedBuffer = Buffer.from(expected); + return candidateBuffer.length === expectedBuffer.length && timingSafeEqual(candidateBuffer, expectedBuffer); +}; + +export const registerProfileStatusInternalRoute = ( + app: FastifyInstance, + options: { + profiles: GatewayProfileRepository; + secret: string; + } +): void => { + app.get<{ Params: { profileName: string } }>('/internal/profile-status/:profileName', async (request, reply) => { + void reply.header('Cache-Control', 'no-store'); + if (!matchesSecret(request.headers[INTERNAL_TOKEN_HEADER], deriveInternalToken(options.secret))) { + await reply.status(401).send({ ok: false, error: 'unauthorized' }); + return; + } + const profileName = request.params.profileName; + const profile = await options.profiles.getProfile(profileName); + if (!profile) { + await reply.status(404).send({ ok: false, error: 'not_found' }); + return; + } + await reply.send({ profileName: profile.profileName, status: profile.status }); + }); +}; diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index cda40579..ed109e83 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -26,6 +26,7 @@ import { createGatewayReleaseRepository } from './orchestrator/gatewayReleaseRep import { appRouter } from './router.js'; import { RepositoryProfileStatusService } from './lobby/profileStatusService.js'; import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js'; +import { registerProfileStatusInternalRoute } from './lobby/profileStatusInternalRoute.js'; import { installGatewayShutdownController } from './lifecycle/shutdownController.js'; import { RemoteUserIconStore } from './account/remoteUserIconStore.js'; import { gatewayFastifyRouterOptions } from './fastifyOptions.js'; @@ -98,6 +99,10 @@ export const createGatewayApiServer = async () => { users, secret: config.gameTokenSecret, }); + registerProfileStatusInternalRoute(app, { + profiles, + secret: config.gameTokenSecret, + }); await app.register(fastifyTRPCPlugin, { prefix: config.trpcPath, diff --git a/app/gateway-api/test/profileStatusInternalRoute.test.ts b/app/gateway-api/test/profileStatusInternalRoute.test.ts new file mode 100644 index 00000000..d4a25f7f --- /dev/null +++ b/app/gateway-api/test/profileStatusInternalRoute.test.ts @@ -0,0 +1,50 @@ +import { createHmac } from 'node:crypto'; + +import fastify from 'fastify'; +import { describe, expect, it, vi } from 'vitest'; + +import type { GatewayProfileRepository } from '../src/orchestrator/profileRepository.js'; +import { registerProfileStatusInternalRoute } from '../src/lobby/profileStatusInternalRoute.js'; + +const secret = 'gateway-profile-status-test-secret'; +const token = createHmac('sha256', secret).update('sammo:profile-status-source:v1').digest('hex'); + +describe('profile status internal route', () => { + it('requires a purpose-derived token and returns only the durable status', async () => { + const app = fastify(); + const profiles = { + getProfile: vi.fn(async (profileName: string) => ({ profileName, status: 'PAUSED' })), + } as unknown as GatewayProfileRepository; + registerProfileStatusInternalRoute(app, { profiles, secret }); + + const unauthorized = await app.inject({ + method: 'GET', + url: '/internal/profile-status/che%3Adefault', + headers: { 'x-sammo-internal-token': secret }, + }); + expect(unauthorized.statusCode).toBe(401); + + const response = await app.inject({ + method: 'GET', + url: '/internal/profile-status/che%3Adefault', + headers: { 'x-sammo-internal-token': token }, + }); + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toBe('no-store'); + expect(response.json()).toEqual({ profileName: 'che:default', status: 'PAUSED' }); + expect(Object.keys(response.json()).sort()).toEqual(['profileName', 'status']); + }); + + it('returns 404 for an unknown profile', async () => { + const app = fastify(); + const profiles = { getProfile: vi.fn(async () => null) } as unknown as GatewayProfileRepository; + registerProfileStatusInternalRoute(app, { profiles, secret }); + const response = await app.inject({ + method: 'GET', + url: '/internal/profile-status/missing', + headers: { 'x-sammo-internal-token': token }, + }); + expect(response.statusCode).toBe(404); + expect(response.json()).toEqual({ ok: false, error: 'not_found' }); + }); +}); diff --git a/packages/common/src/game/accessPenalty.ts b/packages/common/src/game/accessPenalty.ts new file mode 100644 index 00000000..ec4e5d35 --- /dev/null +++ b/packages/common/src/game/accessPenalty.ts @@ -0,0 +1,28 @@ +export const ACCESS_REFRESH_LIMIT_COEFFICIENT = 10; + +export type AccessLimitLevel = 0 | 1 | 2; + +export const calculateAccessRefreshLimit = (tickSeconds: number): number => { + if (!Number.isFinite(tickSeconds) || tickSeconds <= 0) { + throw new RangeError('tickSeconds must be a positive finite number.'); + } + const turnMinutes = tickSeconds / 60; + return Math.round(Math.pow(turnMinutes, 0.6) * 3) * ACCESS_REFRESH_LIMIT_COEFFICIENT; +}; + +export const resolveAccessRefreshLimit = (tickSeconds: number, storedLimit: unknown): number => { + if (typeof storedLimit === 'number' && Number.isSafeInteger(storedLimit) && storedLimit > 0) { + return storedLimit; + } + return calculateAccessRefreshLimit(tickSeconds); +}; + +export const resolveAccessLimitLevel = (refreshScore: number, refreshLimit: number): AccessLimitLevel => { + if (refreshScore > refreshLimit) { + return 2; + } + if (refreshScore > refreshLimit * 0.9) { + return 1; + } + return 0; +}; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 8cd41628..4bc508a7 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -23,3 +23,4 @@ export * from './ranking/legacyColor.js'; export * from './auth/accountIconProjection.js'; export * from './logging/formatLegacyLogHtml.js'; export * from './gateway/profileStatus.js'; +export * from './game/accessPenalty.js'; diff --git a/packages/common/test/access-penalty.test.ts b/packages/common/test/access-penalty.test.ts new file mode 100644 index 00000000..cbb510f5 --- /dev/null +++ b/packages/common/test/access-penalty.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { + calculateAccessRefreshLimit, + resolveAccessLimitLevel, + resolveAccessRefreshLimit, +} from '../src/game/accessPenalty.js'; + +describe('legacy access penalty', () => { + it.each([ + [60, 30], + [300, 80], + [600, 120], + [1_200, 180], + ])('calculates the Ref refresh limit for %i-second turns', (tickSeconds, expected) => { + expect(calculateAccessRefreshLimit(tickSeconds)).toBe(expected); + }); + + it('keeps a persisted positive limit and derives a missing one', () => { + expect(resolveAccessRefreshLimit(600, 777)).toBe(777); + expect(resolveAccessRefreshLimit(600, undefined)).toBe(120); + expect(resolveAccessRefreshLimit(600, 0)).toBe(120); + }); + + it('uses the strict Ref threshold and warns only above ninety percent', () => { + expect(resolveAccessLimitLevel(108, 120)).toBe(0); + expect(resolveAccessLimitLevel(109, 120)).toBe(1); + expect(resolveAccessLimitLevel(120, 120)).toBe(1); + expect(resolveAccessLimitLevel(121, 120)).toBe(2); + }); +});