import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common'; import { GamePrisma } from '@sammo-ts/infra'; import type { GameApiContext } from '../context.js'; export const accessPages = [ 'nation-info', 'nation-cities', 'nation-list', 'current-city', 'dynasty', 'traffic', 'npc-control', ] as const; export type AccessPage = (typeof accessPages)[number]; export const accessPageWeights: Record = { 'nation-info': 1, 'nation-cities': 1, 'nation-list': 2, 'current-city': 1, dynasty: 1, traffic: 1, 'npc-control': 1, }; export const generalAccessEndpointWeights = { 'world.getGeneralDirectory': 2, 'public.getNpcList': 2, 'ranking.getBestGeneral': 1, 'ranking.getHallOfFame': 1, 'tournament.getSnapshot': 1, 'nation.getSecretGeneralList': 1, 'nation.getPersonnelInfo': 1, 'nation.getGeneralList': 1, 'general.ensureDieOnPrestartStatus': 1, 'nation.getStratFinan': 1, 'board.getArticles': 1, 'diplomacy.getLetters': 2, 'battle.getGeneralDetail': 1, 'betting.getList': 1, 'general.getFrontStatus': 1, 'yearbook.getHistory': 1, 'world.getGlobalInfo': 1, 'nation.getBattleCenter': 1, 'nation.getChiefCenter': 1, 'troop.getList': 1, 'board.writeArticle': 1, 'board.writeComment': 1, 'diplomacy.sendLetter': 1, 'diplomacy.respondLetter': 1, 'diplomacy.rollbackLetter': 1, 'diplomacy.destroyLetter': 1, 'general.buildNationCandidate': 1, 'general.dieOnPrestart': 1, 'general.instantRetreat': 1, 'messages.send': 1, 'turns.getCommandTable': 1, 'general.setMySetting': 0, 'npc.setNationPolicy': 0, 'npc.setNationPriority': 0, 'npc.setGeneralPriority': 0, 'battle.simulate': 0, } as const satisfies Record; 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, currentProfileName: string ): 0 | 1 | 2 | null | undefined => { const weight = generalAccessEndpointWeights[path as GeneralAccessEndpoint]; if (weight === undefined) { return undefined; } if (path === 'yearbook.getHistory') { const requestedServer = typeof input === 'object' && input !== null && 'serverID' in input ? (input as { serverID?: unknown }).serverID : undefined; if (requestedServer !== undefined && requestedServer !== currentProfileName) { return null; } } return weight; }; const adminRoles = new Set(['superuser', 'admin', 'admin.superuser']); const readFiniteNumber = (value: unknown): number | null => typeof value === 'number' && Number.isFinite(value) ? value : null; const readDate = (value: unknown): Date | null => { if (typeof value !== 'string' && !(value instanceof Date)) { return null; } const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? null : parsed; }; export const resolveAccessWindows = ( now: Date, tickSeconds: number, worldMeta: unknown ): { periodStartedAt: Date; scoreStartedAt: Date } => { const meta = asRecord(worldMeta); const tickStartedAt = readDate(meta.lastTurnTime) ?? readDate(meta.turntime); const fallbackTickMs = Math.max(1, Math.floor(tickSeconds)) * 1_000; const scoreStartedAt = tickStartedAt && tickStartedAt.getTime() <= now.getTime() ? tickStartedAt : new Date(now.getTime() - fallbackTickMs); 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: { worldStateId: number; year: number; month: number; tickSeconds: number; generalId: number; userId: string; weight: number; now: Date; periodStartedAt: Date; scoreStartedAt: Date; } ): Promise => { if (!db.$transaction) { throw new Error('Traffic access persistence requires transaction support.'); } await db.$transaction(async (transaction) => { const periodKey = input.year * 12 + input.month - 1; const periodRows = await transaction.$queryRaw>( GamePrisma.sql` WITH latest_period AS ( SELECT MAX(year * 12 + month - 1)::INTEGER AS period_key FROM traffic_period WHERE world_state_id = ${input.worldStateId} ), missing_periods AS ( INSERT INTO traffic_period ( world_state_id, year, month, started_at, last_refresh, refresh, online ) SELECT ${input.worldStateId}, (missing_key / 12)::INTEGER, (missing_key % 12 + 1)::INTEGER, CAST(${input.periodStartedAt} AS TIMESTAMP) - ( (${periodKey} - missing_key) * ${input.tickSeconds} ) * INTERVAL '1 second', CAST(${input.periodStartedAt} AS TIMESTAMP) - ( (${periodKey} - missing_key - 1) * ${input.tickSeconds} ) * INTERVAL '1 second', 0, 0 FROM latest_period CROSS JOIN LATERAL generate_series( latest_period.period_key + 1, ${periodKey} - 1 ) AS missing_key WHERE latest_period.period_key IS NOT NULL ON CONFLICT (world_state_id, year, month) DO NOTHING RETURNING id ) INSERT INTO traffic_period ( world_state_id, year, month, started_at, last_refresh, refresh ) VALUES ( ${input.worldStateId}, ${input.year}, ${input.month}, ${input.periodStartedAt}, ${input.now}, ${input.weight} ) ON CONFLICT (world_state_id, year, month) DO UPDATE SET started_at = LEAST(traffic_period.started_at, EXCLUDED.started_at), last_refresh = GREATEST(traffic_period.last_refresh, EXCLUDED.last_refresh), refresh = traffic_period.refresh + EXCLUDED.refresh RETURNING id ` ); const periodId = periodRows[0]?.id; if (periodId === undefined) { throw new Error('Failed to resolve the traffic period.'); } await transaction.$executeRaw( GamePrisma.sql` WITH inserted_general AS ( INSERT INTO traffic_period_general ( period_id, general_id, user_id, refresh, last_refresh ) VALUES ( ${periodId}, ${input.generalId}, ${input.userId}, ${input.weight}, ${input.now} ) ON CONFLICT (period_id, general_id) DO NOTHING RETURNING period_id ), updated_general AS ( UPDATE traffic_period_general SET user_id = ${input.userId}, refresh = traffic_period_general.refresh + ${input.weight}, last_refresh = GREATEST( traffic_period_general.last_refresh, ${input.now} ) WHERE period_id = ${periodId} AND general_id = ${input.generalId} AND NOT EXISTS (SELECT 1 FROM inserted_general) RETURNING period_id ) UPDATE traffic_period SET online = traffic_period.online + ( SELECT COUNT(*)::INTEGER FROM inserted_general ) WHERE id = ${periodId} ` ); await transaction.$executeRaw( GamePrisma.sql` INSERT INTO general_access_log ( general_id, user_id, last_refresh, refresh, refresh_total, refresh_score, refresh_score_total ) VALUES ( ${input.generalId}, ${input.userId}, ${input.now}, ${input.weight}, ${input.weight}, ${input.weight}, ${input.weight} ) ON CONFLICT (general_id) DO UPDATE SET user_id = EXCLUDED.user_id, last_refresh = EXCLUDED.last_refresh, refresh = CASE WHEN general_access_log.last_refresh IS NULL OR general_access_log.last_refresh < ${input.periodStartedAt} THEN EXCLUDED.refresh ELSE general_access_log.refresh + EXCLUDED.refresh END, refresh_total = general_access_log.refresh_total + EXCLUDED.refresh_total, refresh_score = CASE WHEN general_access_log.last_refresh IS NULL OR general_access_log.last_refresh < ${input.scoreStartedAt} THEN EXCLUDED.refresh_score ELSE general_access_log.refresh_score + EXCLUDED.refresh_score END, refresh_score_total = general_access_log.refresh_score_total + EXCLUDED.refresh_score_total ` ); }); }; export const recordGeneralAccess = async ( ctx: Pick, page: AccessPage, now = new Date() ): Promise => recordGeneralAccessWeight(ctx, accessPageWeights[page], now); export const recordGeneralAccessWeight = async ( ctx: Pick, weight: number, now = new Date() ): Promise => { if (!Number.isInteger(weight) || weight < 0) { throw new RangeError('General access weight must be a non-negative integer.'); } const user = ctx.auth?.user; if (!user || user.roles.some((role) => adminRoles.has(role))) { 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, turnTime: true }, }), ctx.db.worldState.findFirst({ orderBy: { id: 'asc' }, select: { id: true, currentYear: true, currentMonth: true, tickSeconds: true, meta: true, }, }), ]); if (!general || !worldState) { return false; } const meta = asRecord(worldState.meta); const isUnited = readFiniteNumber(meta.isUnited) ?? readFiniteNumber(meta.isunited) ?? 0; const openTime = readDate(meta.opentime); if (isUnited === 2 || (openTime && openTime.getTime() > now.getTime())) { return false; } const { periodStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta); const scoreStartedAt = resolveGeneralScoreStartedAt(worldState.tickSeconds, general.turnTime); await upsertGeneralAccess(ctx.db, { worldStateId: worldState.id, year: worldState.currentYear, month: worldState.currentMonth, tickSeconds: worldState.tickSeconds, generalId: general.id, userId: user.id, weight, now, periodStartedAt, scoreStartedAt, }); return true; };