diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 06a4fa5..ba561d7 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -71,6 +71,7 @@ export type DatabaseClient = InfraDatabaseClient; export interface GameApiContext { requestId?: string; + generalAccessTracking?: boolean; db: DatabaseClient; redis: RedisConnector['client']; turnDaemon: TurnDaemonTransport; @@ -102,6 +103,7 @@ export const createGameApiContext = (options: { }): GameApiContext => { return { requestId: options.requestId, + generalAccessTracking: true, db: options.db, redis: options.redis, turnDaemon: options.turnDaemon, diff --git a/app/game-api/src/router/battle/index.ts b/app/game-api/src/router/battle/index.ts index 5768462..b18c9ea 100644 --- a/app/game-api/src/router/battle/index.ts +++ b/app/game-api/src/router/battle/index.ts @@ -4,7 +4,13 @@ import { z } from 'zod'; import { asRecord } from '@sammo-ts/common'; import { getDexLevel } from '@sammo-ts/logic'; -import { authedProcedure, readOnlyAuthedProcedure, router } from '../../trpc.js'; +import { + accessAuthedInputProcedure, + accessReadOnlyAuthedInputProcedure, + authedProcedure, + readOnlyAuthedProcedure, + router, +} from '../../trpc.js'; import { buildBattleSimEnvironment, buildBattleSimJobPayload } from '../../battleSim/environment.js'; import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js'; import { @@ -56,7 +62,7 @@ const resolveDexValue = (meta: Record, key: string): number => }; export const battleRouter = router({ - simulate: readOnlyAuthedProcedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => { + simulate: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(async ({ ctx, input }) => { const worldState = await ctx.db.worldState.findFirst(); if (!worldState) { throw new TRPCError({ @@ -169,8 +175,7 @@ export const battleRouter = router({ generalsByNation, }; }), - getGeneralDetail: authedProcedure - .input( + getGeneralDetail: accessAuthedInputProcedure( z.object({ generalId: z.number().int().positive(), }) diff --git a/app/game-api/src/router/betting/index.ts b/app/game-api/src/router/betting/index.ts index 9b42d33..76b2d5e 100644 --- a/app/game-api/src/router/betting/index.ts +++ b/app/game-api/src/router/betting/index.ts @@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server'; import { GamePrisma } from '@sammo-ts/infra'; import { z } from 'zod'; -import { authedProcedure, router } from '../../trpc.js'; +import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; import { appendInheritanceLog, readInheritancePoint, setInheritancePoint } from '../../services/inheritance.js'; import { getMyGeneral } from '../shared/general.js'; @@ -30,8 +30,7 @@ const loadWorldDate = async (db: Parameters[0]['db']) => { }; export const bettingRouter = router({ - getList: authedProcedure - .input(z.object({ req: z.literal('bettingNation').optional() }).optional()) + getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional()) .query(async ({ ctx, input }) => { requireUserId(ctx.auth); await getMyGeneral(ctx); diff --git a/app/game-api/src/router/board/index.ts b/app/game-api/src/router/board/index.ts index 7f8b3f3..d11c16c 100644 --- a/app/game-api/src/router/board/index.ts +++ b/app/game-api/src/router/board/index.ts @@ -5,7 +5,7 @@ import { promises as fs } from 'fs'; import { randomUUID } from 'crypto'; import sharp, { type WebpOptions } from 'sharp'; -import { authedProcedure, router } from '../../trpc.js'; +import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; import { resolveSecretPermission } from '../shared/secretPermission.js'; @@ -107,7 +107,7 @@ export const boardRouter = router({ canSecret: permission >= 2, }; }), - getArticles: authedProcedure.input(z.object({ isSecret: z.boolean() })).query(async ({ ctx, input }) => { + getArticles: accessAuthedInputProcedure(z.object({ isSecret: z.boolean() })).query(async ({ ctx, input }) => { const { general, permission } = await getBoardActor(ctx); assertBoardAccess(permission, input.isSecret); @@ -159,8 +159,7 @@ export const boardRouter = router({ })), })); }), - writeArticle: authedProcedure - .input( + writeArticle: accessAuthedInputProcedure( z.object({ isSecret: z.boolean(), title: z.string().trim().max(250), @@ -189,8 +188,7 @@ export const boardRouter = router({ return { id: post.id }; }), - writeComment: authedProcedure - .input( + writeComment: accessAuthedInputProcedure( z.object({ postId: z.number().int().positive(), content: z.string().trim().max(2000), diff --git a/app/game-api/src/router/diplomacy/index.ts b/app/game-api/src/router/diplomacy/index.ts index 1c9c907..c32ea05 100644 --- a/app/game-api/src/router/diplomacy/index.ts +++ b/app/game-api/src/router/diplomacy/index.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { asRecord } from '@sammo-ts/common'; import type { GamePrisma } from '@sammo-ts/infra'; -import { authedProcedure, router } from '../../trpc.js'; +import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; import { assertNationAccess, resolveNationPermission } from '../nation/shared.js'; @@ -28,7 +28,7 @@ const mapLetterState = (state: string): 'PROPOSED' | 'ACTIVATED' | 'CANCELLED' | }; export const diplomacyRouter = router({ - getLetters: authedProcedure.query(async ({ ctx }) => { + getLetters: accessAuthedProcedure.query(async ({ ctx }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); @@ -98,8 +98,7 @@ export const diplomacyRouter = router({ permission, }; }), - sendLetter: authedProcedure - .input( + sendLetter: accessAuthedInputProcedure( z.object({ destNationId: z.number().int().positive(), prevId: z.number().int().positive().nullable().optional(), @@ -217,8 +216,7 @@ export const diplomacyRouter = router({ return { id: created.id }; }), - respondLetter: authedProcedure - .input( + respondLetter: accessAuthedInputProcedure( z.object({ letterId: z.number().int().positive(), agree: z.boolean(), @@ -288,8 +286,7 @@ export const diplomacyRouter = router({ return { ok: true }; }), - rollbackLetter: authedProcedure - .input(z.object({ letterId: z.number().int().positive() })) + rollbackLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() })) .mutation(async ({ ctx, input }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); @@ -324,8 +321,7 @@ export const diplomacyRouter = router({ return { ok: true }; }), - destroyLetter: authedProcedure - .input(z.object({ letterId: z.number().int().positive() })) + destroyLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() })) .mutation(async ({ ctx, input }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 9571c52..9a7600c 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -5,7 +5,14 @@ import { LogCategory, LogScope } from '@sammo-ts/infra'; import { asRecord } from '@sammo-ts/common'; import type { GameApiContext } from '../../context.js'; -import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js'; +import { + accessAuthedProcedure, + accessAuthedInputProcedure, + accessEngineAuthedProcedure, + accessEngineAuthedInputProcedure, + authedProcedure, + router, +} from '../../trpc.js'; import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js'; import { resolveAccessWindows } from '../../services/generalAccess.js'; import { getMyGeneral } from '../shared/general.js'; @@ -283,7 +290,7 @@ export const generalRouter = router({ penalties, }; }), - ensureDieOnPrestartStatus: engineAuthedProcedure.mutation(async ({ ctx }) => { + ensureDieOnPrestartStatus: accessEngineAuthedProcedure.mutation(async ({ ctx }) => { const userId = ctx.auth?.user.id; if (!userId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); @@ -319,14 +326,11 @@ export const generalRouter = router({ availableAt: result.availableAt ?? null, }; }), - dieOnPrestart: engineAuthedProcedure - .input(zImmediateActionInput) + dieOnPrestart: accessEngineAuthedInputProcedure(zImmediateActionInput) .mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'dieOnPrestart')), - buildNationCandidate: engineAuthedProcedure - .input(zImmediateActionInput) + buildNationCandidate: accessEngineAuthedInputProcedure(zImmediateActionInput) .mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'buildNationCandidate')), - instantRetreat: engineAuthedProcedure - .input(zImmediateActionInput) + instantRetreat: accessEngineAuthedInputProcedure(zImmediateActionInput) .mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'instantRetreat')), vacation: authedProcedure.mutation(async ({ ctx }) => { const general = await getMyGeneral(ctx); @@ -342,7 +346,7 @@ export const generalRouter = router({ } return { ok: true }; }), - setMySetting: authedProcedure.input(zGeneralSettings).mutation(async ({ ctx, input }) => { + setMySetting: accessAuthedInputProcedure(zGeneralSettings).mutation(async ({ ctx, input }) => { const general = await getMyGeneral(ctx); const result = await ctx.turnDaemon.requestCommand({ type: 'setMySetting', @@ -459,7 +463,7 @@ export const generalRouter = router({ history: trimRecentRecords(history, input.lastWorldHistoryId), }; }), - getFrontStatus: authedProcedure.query(async ({ ctx }) => { + getFrontStatus: accessAuthedProcedure.query(async ({ ctx }) => { const me = await getMyGeneral(ctx); const worldState = await ctx.db.worldState.findFirst({ orderBy: { id: 'asc' }, diff --git a/app/game-api/src/router/messages/index.ts b/app/game-api/src/router/messages/index.ts index 5308bf2..c20d789 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 { authedProcedure, router } from '../../trpc.js'; +import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, @@ -388,8 +388,7 @@ export const messagesRouter = router({ ...messageBuckets, }; }), - send: authedProcedure - .input( + send: accessAuthedInputProcedure( z.object({ generalId: z.number().int().positive(), mailbox: z.number().int(), diff --git a/app/game-api/src/router/nation/endpoints/getBattleCenter.ts b/app/game-api/src/router/nation/endpoints/getBattleCenter.ts index dd2e63c..2628fa1 100644 --- a/app/game-api/src/router/nation/endpoints/getBattleCenter.ts +++ b/app/game-api/src/router/nation/endpoints/getBattleCenter.ts @@ -2,11 +2,11 @@ import { TRPCError } from '@trpc/server'; import { LogCategory } from '@sammo-ts/infra'; -import { authedProcedure } from '../../../trpc.js'; +import { accessAuthedProcedure } from '../../../trpc.js'; import { getMyGeneral } from '../../shared/general.js'; import { assertNationAccess, formatDateTime, resolveNationPermission } from '../shared.js'; -export const getBattleCenter = authedProcedure.query(async ({ ctx }) => { +export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); diff --git a/app/game-api/src/router/nation/endpoints/getChiefCenter.ts b/app/game-api/src/router/nation/endpoints/getChiefCenter.ts index c0731f3..15f3e40 100644 --- a/app/game-api/src/router/nation/endpoints/getChiefCenter.ts +++ b/app/game-api/src/router/nation/endpoints/getChiefCenter.ts @@ -1,12 +1,12 @@ import { TRPCError } from '@trpc/server'; -import { authedProcedure } from '../../../trpc.js'; +import { accessAuthedProcedure } from '../../../trpc.js'; import { getMyGeneral } from '../../shared/general.js'; import { resolveSecretPermission } from '../../shared/secretPermission.js'; import { MAX_NATION_TURNS, getNationTurnSnapshot } from '../../../turns/reservedTurns.js'; import { assertNationAccess } from '../shared.js'; -export const getChiefCenter = authedProcedure.query(async ({ ctx }) => { +export const getChiefCenter = accessAuthedProcedure.query(async ({ ctx }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); diff --git a/app/game-api/src/router/nation/endpoints/getGeneralList.ts b/app/game-api/src/router/nation/endpoints/getGeneralList.ts index 21ed1f6..715765e 100644 --- a/app/game-api/src/router/nation/endpoints/getGeneralList.ts +++ b/app/game-api/src/router/nation/endpoints/getGeneralList.ts @@ -1,6 +1,6 @@ import { TRPCError } from '@trpc/server'; -import { authedProcedure } from '../../../trpc.js'; +import { accessAuthedProcedure } from '../../../trpc.js'; import { getMyGeneral } from '../../shared/general.js'; import { assertNationAccess, @@ -18,7 +18,7 @@ const experienceLevel = (experience: number): number => const dedicationLevel = (dedication: number): number => Math.max(0, Math.min(10, Math.ceil(Math.sqrt(dedication) / 10))); -export const getGeneralList = authedProcedure.query(async ({ ctx }) => { +export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => { const general = await getMyGeneral(ctx); assertNationAccess(general); diff --git a/app/game-api/src/router/nation/endpoints/getPersonnelInfo.ts b/app/game-api/src/router/nation/endpoints/getPersonnelInfo.ts index 90ea0e4..7d4f46b 100644 --- a/app/game-api/src/router/nation/endpoints/getPersonnelInfo.ts +++ b/app/game-api/src/router/nation/endpoints/getPersonnelInfo.ts @@ -2,11 +2,11 @@ import { TRPCError } from '@trpc/server'; import { asRecord } from '@sammo-ts/common'; -import { authedProcedure } from '../../../trpc.js'; +import { accessAuthedProcedure } from '../../../trpc.js'; import { getMyGeneral } from '../../shared/general.js'; import { assertNationAccess, checkSecretMaxPermission, mapGeneralList, resolveChiefStatMin } from '../shared.js'; -export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => { +export const getPersonnelInfo = accessAuthedProcedure.query(async ({ ctx }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); diff --git a/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts b/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts index b72977f..09e9cc2 100644 --- a/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts +++ b/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts @@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server'; import { asRecord } from '@sammo-ts/common'; -import { authedProcedure } from '../../../trpc.js'; +import { accessAuthedProcedure } from '../../../trpc.js'; import { getMyGeneral } from '../../shared/general.js'; import { assertNationAccess, resolveNationPermission } from '../shared.js'; @@ -25,7 +25,7 @@ const leadershipBonus = (officerLevel: number, nationLevel: number): number => const defenceTrainText = (value: number): string => value === 999 ? '×' : value >= 90 ? '☆' : value >= 80 ? '◎' : value >= 60 ? '○' : '△'; -export const getSecretGeneralList = authedProcedure.query(async ({ ctx }) => { +export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); const nation = await ctx.db.nation.findUnique({ diff --git a/app/game-api/src/router/nation/endpoints/getStratFinan.ts b/app/game-api/src/router/nation/endpoints/getStratFinan.ts index 829c693..8f79b55 100644 --- a/app/game-api/src/router/nation/endpoints/getStratFinan.ts +++ b/app/game-api/src/router/nation/endpoints/getStratFinan.ts @@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server'; import { asRecord } from '@sammo-ts/common'; import { getGoldIncome, getOutcome, getRiceIncome, getWallIncome, getWarGoldIncome, type NationIncomeContext } from '@sammo-ts/logic'; -import { authedProcedure } from '../../../trpc.js'; +import { accessAuthedProcedure } from '../../../trpc.js'; import { getMyGeneral } from '../../shared/general.js'; import { assertNationAccess, @@ -29,7 +29,7 @@ import { type NationStratRow, } from '../shared.js'; -export const getStratFinan = authedProcedure.query(async ({ ctx }) => { +export const getStratFinan = accessAuthedProcedure.query(async ({ ctx }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); diff --git a/app/game-api/src/router/npc/index.ts b/app/game-api/src/router/npc/index.ts index 87cfe3b..0a0af99 100644 --- a/app/game-api/src/router/npc/index.ts +++ b/app/game-api/src/router/npc/index.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { asRecord, isRecord } from '@sammo-ts/common'; import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js'; -import { authedProcedure, router } from '../../trpc.js'; +import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; import { loadUnitSetDefinitionByName } from '../../battleSim/unitSetLoader.js'; import type { GameApiContext } from '../../context.js'; import { getMyGeneral } from '../shared/general.js'; @@ -537,7 +537,7 @@ export const npcRouter = router({ permissionLevel, }; }), - setNationPolicy: authedProcedure.input(z.record(z.string(), z.unknown())).mutation(async ({ ctx, input }) => { + setNationPolicy: accessAuthedInputProcedure(z.record(z.string(), z.unknown())).mutation(async ({ ctx, input }) => { const general = await getMyGeneral(ctx); if (general.nationId <= 0) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); @@ -700,7 +700,7 @@ export const npcRouter = router({ return { ok: true }; }), - setNationPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => { + setNationPriority: accessAuthedInputProcedure(z.array(z.string())).mutation(async ({ ctx, input }) => { const general = await getMyGeneral(ctx); if (general.nationId <= 0) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); @@ -753,7 +753,7 @@ export const npcRouter = router({ return { ok: true }; }), - setGeneralPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => { + setGeneralPriority: accessAuthedInputProcedure(z.array(z.string())).mutation(async ({ ctx, input }) => { const general = await getMyGeneral(ctx); if (general.nationId <= 0) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index 9589590..b60c2a7 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -8,7 +8,7 @@ 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 { procedure, router, sessionActivityProcedure } from '../../trpc.js'; +import { accessInputProcedure, procedure, router, sessionActivityProcedure } from '../../trpc.js'; import { loadTraitNames } from '../nation/shared.js'; type WorldTrendSnapshot = { @@ -477,8 +477,7 @@ export const publicRouter = router({ intelligence: general.intel, })); }), - getNpcList: procedure - .input( + getNpcList: accessInputProcedure( z .object({ sort: z.number().int().min(1).max(8).catch(1).optional(), diff --git a/app/game-api/src/router/ranking/index.ts b/app/game-api/src/router/ranking/index.ts index b23dccf..8c8d752 100644 --- a/app/game-api/src/router/ranking/index.ts +++ b/app/game-api/src/router/ranking/index.ts @@ -6,7 +6,7 @@ import type { ItemModule } from '@sammo-ts/logic/items/types.js'; import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js'; -import { authedProcedure, procedure, router } from '../../trpc.js'; +import { accessAuthedInputProcedure, accessInputProcedure, procedure, router } from '../../trpc.js'; const DEFAULT_BG_COLOR = '#330000'; const DEFAULT_FG_COLOR = '#ffffff'; @@ -81,8 +81,7 @@ const loadUniqueItems = () => { }; export const rankingRouter = router({ - getBestGeneral: authedProcedure - .input( + getBestGeneral: accessAuthedInputProcedure( z .object({ view: z.enum(['user', 'npc']).optional(), @@ -369,8 +368,7 @@ export const rankingRouter = router({ } return Array.from(seasonMap.values()); }), - getHallOfFame: procedure - .input( + getHallOfFame: accessInputProcedure( z.object({ season: z.number().int(), scenario: z.number().int().optional(), diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 650b026..c147532 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -7,7 +7,7 @@ import type { TournamentState } from '../../tournament/types.js'; import { TournamentStore } from '../../tournament/store.js'; import { buildTournamentKeys } from '../../tournament/keys.js'; -import { authedProcedure, router } from '../../trpc.js'; +import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; const hasAdminRole = (roles: string[], profileName: string): boolean => { @@ -127,7 +127,7 @@ export const tournamentRouter = router({ return store.getState(); }), getAdminStatus: adminProcedure.query(async () => ({ ok: true })), - getSnapshot: authedProcedure.query(async ({ ctx }) => { + getSnapshot: accessAuthedProcedure.query(async ({ ctx }) => { await getMyGeneral(ctx); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const [state, participants, matches, bets] = await Promise.all([ diff --git a/app/game-api/src/router/troop/index.ts b/app/game-api/src/router/troop/index.ts index 3942c23..75c43cb 100644 --- a/app/game-api/src/router/troop/index.ts +++ b/app/game-api/src/router/troop/index.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import type { TurnDaemonCommandResult } from '@sammo-ts/common'; import { isValidTroopNameWidth, normalizeTroopName, resolveTroopSecretPermission } from '@sammo-ts/logic'; -import { authedProcedure, router } from '../../trpc.js'; +import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; const troopNameSchema = z @@ -33,7 +33,7 @@ const assertCommandResult = { + getList: accessAuthedProcedure.query(async ({ ctx }) => { const me = await getMyGeneral(ctx); if (me.nationId <= 0) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' }); diff --git a/app/game-api/src/router/world/directory.ts b/app/game-api/src/router/world/directory.ts index 2abfe04..38d94cc 100644 --- a/app/game-api/src/router/world/directory.ts +++ b/app/game-api/src/router/world/directory.ts @@ -1,7 +1,7 @@ import { asRecord } from '@sammo-ts/common'; import { z } from 'zod'; -import { authedProcedure } from '../../trpc.js'; +import { accessAuthedInputProcedure, authedProcedure } from '../../trpc.js'; import { loadTraitNames } from '../nation/shared.js'; import { getMyGeneral } from '../shared/general.js'; import { resolveSecretPermission } from '../shared/secretPermission.js'; @@ -204,8 +204,7 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => { }); }); -export const getGeneralDirectory = authedProcedure - .input(z.object({ sort: zDirectorySort }).optional()) +export const getGeneralDirectory = accessAuthedInputProcedure(z.object({ sort: zDirectorySort }).optional()) .query(async ({ ctx, input }) => { await getMyGeneral(ctx); const sort = input?.sort ?? 9; diff --git a/app/game-api/src/router/world/index.ts b/app/game-api/src/router/world/index.ts index dac5e71..63a9496 100644 --- a/app/game-api/src/router/world/index.ts +++ b/app/game-api/src/router/world/index.ts @@ -2,8 +2,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { type WorldStateRow, zWorldStateConfig, zWorldStateMeta } from '../../context.js'; -import { procedure, router } from '../../trpc.js'; -import { authedProcedure } from '../../trpc.js'; +import { accessAuthedProcedure, authedProcedure, procedure, router } from '../../trpc.js'; import { asRecord, isRecord } from '@sammo-ts/common'; import { loadWorldMap } from '../../maps/worldMap.js'; import { loadMapLayout } from '../../maps/mapLayout.js'; @@ -71,7 +70,7 @@ const toWorldStateSnapshot = (row: WorldStateRow) => ({ export const worldRouter = router({ getNationDirectory, getGeneralDirectory, - getGlobalInfo: authedProcedure.query(async ({ ctx }) => { + getGlobalInfo: accessAuthedProcedure.query(async ({ ctx }) => { const me = await getMyGeneral(ctx); const [nations, cities, diplomacy, map] = await Promise.all([ ctx.db.nation.findMany({ where: { level: { gt: 0 } } }), diff --git a/app/game-api/src/router/yearbook/index.ts b/app/game-api/src/router/yearbook/index.ts index a98f2a0..51ac6c1 100644 --- a/app/game-api/src/router/yearbook/index.ts +++ b/app/game-api/src/router/yearbook/index.ts @@ -7,6 +7,10 @@ import { LogCategory, LogScope } from '@sammo-ts/infra'; import type { GameApiContext } from '../../context.js'; import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js'; +import { + generalAccessEndpointWeights, + recordGeneralAccessWeight, +} from '../../services/generalAccess.js'; import { authedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; @@ -24,6 +28,13 @@ const zServerId = z.string().trim().min(1).max(64); const computeHash = (payload: unknown): string => createHash('sha256').update(JSON.stringify(payload)).digest('hex'); +const recordHistoryAccess = async (ctx: GameApiContext): Promise => { + if (ctx.generalAccessTracking !== true) { + return; + } + await recordGeneralAccessWeight(ctx, generalAccessEndpointWeights['yearbook.getHistory']); +}; + const parseTextArray = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; @@ -257,6 +268,10 @@ export const yearbookRouter = router({ } const targetProfileName = input.serverID ?? ctx.profile.name; const isCurrentProfile = targetProfileName === ctx.profile.name; + const shouldRecordAfterHashCheck = isCurrentProfile && Boolean(input.hash); + if (isCurrentProfile && !shouldRecordAfterHashCheck) { + await recordHistoryAccess(ctx); + } const isCurrent = isCurrentProfile && worldState.currentYear === input.year && worldState.currentMonth === input.month; @@ -280,6 +295,9 @@ export const yearbookRouter = router({ if (input.hash && input.hash === hash) { return { notModified: true, hash }; } + if (shouldRecordAfterHashCheck) { + await recordHistoryAccess(ctx); + } return { notModified: false, hash, data }; } @@ -317,6 +335,9 @@ export const yearbookRouter = router({ if (input.hash && input.hash === hash) { return { notModified: true, hash }; } + if (shouldRecordAfterHashCheck) { + await recordHistoryAccess(ctx); + } return { notModified: false, hash, data }; }), }); diff --git a/app/game-api/src/services/generalAccess.ts b/app/game-api/src/services/generalAccess.ts index 2dfdc97..08129b4 100644 --- a/app/game-api/src/services/generalAccess.ts +++ b/app/game-api/src/services/generalAccess.ts @@ -4,59 +4,86 @@ import { GamePrisma } from '@sammo-ts/infra'; import type { GameApiContext } from '../context.js'; export const accessPages = [ - 'front-info', 'nation-info', 'nation-cities', - 'global-info', 'nation-list', - 'general-list', 'current-city', - 'diplomacy', - 'nation-generals', - 'nation-personnel', - 'nation-finance', - 'battle-center', - 'board', - 'best-general', - 'hall-of-fame', 'dynasty', - 'yearbook', - 'nation-betting', 'traffic', - 'npc-list', - 'my-page', 'npc-control', - 'tournament', - 'betting', ] as const; export type AccessPage = (typeof accessPages)[number]; export const accessPageWeights: Record = { - 'front-info': 1, 'nation-info': 1, 'nation-cities': 1, - 'global-info': 1, 'nation-list': 2, - 'general-list': 2, 'current-city': 1, - diplomacy: 1, - 'nation-generals': 1, - 'nation-personnel': 1, - 'nation-finance': 1, - 'battle-center': 1, - board: 1, - 'best-general': 1, - 'hall-of-fame': 1, dynasty: 1, - yearbook: 1, - 'nation-betting': 1, traffic: 1, - 'npc-list': 2, - 'my-page': 1, 'npc-control': 1, - tournament: 1, - betting: 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, + '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 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']); @@ -262,7 +289,16 @@ 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; @@ -296,7 +332,6 @@ export const recordGeneralAccess = async ( return false; } - const weight = accessPageWeights[page]; const { periodStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta); await upsertGeneralAccess(ctx.db, { diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 3b969cd..75f5218 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -5,6 +5,7 @@ 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'; const t = initTRPC.context().create(); @@ -67,14 +68,48 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => { } }); +const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => { + // 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다. + // Router 단위 테스트의 부분 DB mock은 명시적으로 opt-in할 때만 계측한다. + if (ctx.generalAccessTracking !== true) { + return next(); + } + const weight = resolveGeneralAccessEndpointWeight(path, input, ctx.profile.name); + if (weight === undefined) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `General access endpoint is not registered: ${path}`, + }); + } + if (weight === null) { + return next(); + } + await recordGeneralAccessWeight(ctx, weight); + return next(); +}); + export const router = t.router; export const procedure = t.procedure.use(inputEventMiddleware); export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware); +// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation +// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다 +// 앞에 두어 실패하거나 재시도되는 업무 transaction과 접속 기록을 분리한다. +export const accessProcedure: typeof procedure = t.procedure + .use(generalAccessEndpointMiddleware) + .use(inputEventMiddleware); +export const accessAuthedProcedure: typeof procedure = t.procedure + .use(requireAuthMiddleware) + .use(generalAccessEndpointMiddleware) + .use(inputEventMiddleware); + // 턴 데몬이 ENGINE input_event와 world/DB 변경을 자체 transaction으로 // 커밋하는 mutation에 사용한다. API input-event transaction으로 한 번 더 // 감싸면 daemon이 아직 commit되지 않은 command를 볼 수 없어 교착된다. export const engineAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware); +export const accessEngineAuthedProcedure: typeof procedure = t.procedure + .use(requireAuthMiddleware) + .use(generalAccessEndpointMiddleware); // 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조 // mutation에 사용한다. gameplay state 변경에는 사용하지 않는다. @@ -83,3 +118,17 @@ export const sessionActivityProcedure = t.procedure; // 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과 // 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다. export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware); +export const accessReadOnlyAuthedProcedure: typeof procedure = t.procedure + .use(requireAuthMiddleware) + .use(generalAccessEndpointMiddleware); + +// 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를 +// 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다. +export const accessInputProcedure: typeof procedure.input = (input) => + t.procedure.input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware); +export const accessAuthedInputProcedure: typeof procedure.input = (input) => + t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware); +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); diff --git a/app/game-api/test/generalAccessTracking.integration.test.ts b/app/game-api/test/generalAccessTracking.integration.test.ts index f3e2d5e..fa4d3c9 100644 --- a/app/game-api/test/generalAccessTracking.integration.test.ts +++ b/app/game-api/test/generalAccessTracking.integration.test.ts @@ -1,14 +1,58 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { TRPCError } from '@trpc/server'; +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import { z } from 'zod'; +import type { GameApiContext } from '../src/context.js'; +import { appRouter } from '../src/router.js'; import { upsertGeneralAccess } from '../src/services/generalAccess.js'; +import { accessAuthedInputProcedure, router } from '../src/trpc.js'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); const generalId = 9_980_071; const secondGeneralId = generalId + 1; const rollbackGeneralId = generalId + 2; +const zeroWeightGeneralId = generalId + 3; +const endpointGeneralId = generalId + 4; +const endpointUserId = `access-endpoint-user-${endpointGeneralId}`; const scenarioCode = `traffic-period-${generalId}`; +const endpointRequestPrefix = `access-endpoint-${endpointGeneralId}`; +const yearbookProfile = `access-profile-${endpointGeneralId}`; + +const endpointAuth = (roles = ['user']): GameSessionTokenPayload => ({ + version: 1, + profile: `${yearbookProfile}:default`, + issuedAt: '2026-07-26T00:00:00.000Z', + expiresAt: '2026-07-27T00:00:00.000Z', + sessionId: `access-session-${endpointGeneralId}`, + user: { + id: endpointUserId, + username: endpointUserId, + displayName: endpointUserId, + roles, + }, + sanctions: {}, +}); + +const endpointBoundaryRouter = router({ + world: router({ + getGeneralDirectory: accessAuthedInputProcedure(z.object({ accepted: z.literal(true) })).query(() => ({ + ok: true, + })), + }), + general: router({ + setMySetting: accessAuthedInputProcedure(z.object({ accepted: z.literal(true) })).mutation(() => ({ + ok: true, + })), + }), + board: router({ + writeArticle: accessAuthedInputProcedure(z.object({ accepted: z.literal(true) })).mutation(() => { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'business rejected' }); + }), + }), +}); integration('general access tracking persistence', () => { let db: GamePrismaClient; @@ -21,8 +65,13 @@ integration('general access tracking persistence', () => { db = connector.prisma; closeDb = () => connector.disconnect(); await db.generalAccessLog.deleteMany({ - where: { generalId: { in: [generalId, secondGeneralId, rollbackGeneralId] } }, + where: { + generalId: { + in: [generalId, secondGeneralId, rollbackGeneralId, zeroWeightGeneralId, endpointGeneralId], + }, + }, }); + await db.inputEvent.deleteMany({ where: { requestId: { startsWith: endpointRequestPrefix } } }); await db.worldState.deleteMany({ where: { scenarioCode } }); const world = await db.worldState.create({ data: { @@ -35,12 +84,47 @@ integration('general access tracking persistence', () => { }, }); worldStateId = world.id; + await db.general.deleteMany({ where: { id: endpointGeneralId } }); + await db.general.create({ + data: { + id: endpointGeneralId, + userId: endpointUserId, + name: '접속경계', + turnTime: new Date('2026-07-26T03:00:00.000Z'), + }, + }); + await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } }); + await db.yearbookHistory.create({ + data: { + profileName: yearbookProfile, + sourceId: 1, + year: 184, + month: 12, + map: { + year: 184, + month: 12, + startYear: 180, + cityList: [], + nationList: [], + }, + nations: [], + globalHistory: ['기록'], + globalAction: ['행동'], + }, + }); }); afterAll(async () => { await db.generalAccessLog.deleteMany({ - where: { generalId: { in: [generalId, secondGeneralId, rollbackGeneralId] } }, + where: { + generalId: { + in: [generalId, secondGeneralId, rollbackGeneralId, zeroWeightGeneralId, endpointGeneralId], + }, + }, }); + await db.inputEvent.deleteMany({ where: { requestId: { startsWith: endpointRequestPrefix } } }); + await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } }); + await db.general.deleteMany({ where: { id: endpointGeneralId } }); await db.worldState.deleteMany({ where: { id: worldStateId } }); await closeDb?.(); }); @@ -233,4 +317,151 @@ integration('general access tracking persistence', () => { refreshTotal: 2_147_483_647, }); }); + + it('records weight zero membership and last refresh without changing counters', async () => { + const now = new Date('2026-07-26T03:40:00.000Z'); + await upsertGeneralAccess(db, { + worldStateId, + year: 186, + month: 2, + tickSeconds: 600, + generalId: zeroWeightGeneralId, + userId: 'zero-weight-user', + now, + periodStartedAt: now, + scoreStartedAt: now, + weight: 0, + }); + + await expect( + db.generalAccessLog.findUniqueOrThrow({ where: { generalId: zeroWeightGeneralId } }) + ).resolves.toMatchObject({ + userId: 'zero-weight-user', + lastRefresh: now, + refresh: 0, + refreshTotal: 0, + refreshScore: 0, + refreshScoreTotal: 0, + }); + await expect( + db.trafficPeriod.findUniqueOrThrow({ + where: { + worldStateId_year_month: { + worldStateId, + year: 186, + month: 2, + }, + }, + include: { generals: true }, + }) + ).resolves.toMatchObject({ + refresh: 0, + online: 1, + generals: [ + { + generalId: zeroWeightGeneralId, + userId: 'zero-weight-user', + refresh: 0, + lastRefresh: now, + }, + ], + }); + }); + + it('runs parser, access, business event, zero-weight, admin, and yearbook cache boundaries on PostgreSQL', async () => { + const context = { + auth: endpointAuth(), + db, + generalAccessTracking: true, + requestId: endpointRequestPrefix, + profile: { + id: `${yearbookProfile}:default`, + name: yearbookProfile, + scenario: 'default', + }, + } as unknown as GameApiContext; + const boundaryCaller = endpointBoundaryRouter.createCaller(context); + + await expect(boundaryCaller.world.getGeneralDirectory({ accepted: false as true })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull(); + + await expect(boundaryCaller.world.getGeneralDirectory({ accepted: true })).resolves.toEqual({ ok: true }); + await expect( + db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } }) + ).resolves.toMatchObject({ + refresh: 2, + refreshTotal: 2, + }); + + await expect(boundaryCaller.general.setMySetting({ accepted: true })).resolves.toEqual({ ok: true }); + await expect( + db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } }) + ).resolves.toMatchObject({ + refresh: 2, + refreshTotal: 2, + }); + + await expect(boundaryCaller.board.writeArticle({ accepted: true })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'business rejected', + }); + await expect( + db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } }) + ).resolves.toMatchObject({ + refresh: 3, + refreshTotal: 3, + }); + + const adminCaller = endpointBoundaryRouter.createCaller({ + ...context, + auth: endpointAuth(['admin']), + }); + await expect(adminCaller.world.getGeneralDirectory({ accepted: true })).resolves.toEqual({ ok: true }); + await expect( + db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } }) + ).resolves.toMatchObject({ + refresh: 3, + refreshTotal: 3, + }); + + const yearbookCaller = appRouter.createCaller({ + ...context, + requestId: `${endpointRequestPrefix}-yearbook`, + }); + const first = await yearbookCaller.yearbook.getHistory({ year: 184, month: 12 }); + expect(first.notModified).toBe(false); + await expect( + db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } }) + ).resolves.toMatchObject({ + refresh: 4, + refreshTotal: 4, + }); + + const cached = await yearbookCaller.yearbook.getHistory({ + year: 184, + month: 12, + hash: first.hash, + }); + expect(cached).toEqual({ notModified: true, hash: first.hash }); + await expect( + db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } }) + ).resolves.toMatchObject({ + refresh: 4, + refreshTotal: 4, + }); + + await yearbookCaller.yearbook.getHistory({ + year: 184, + month: 12, + hash: 'stale-hash', + }); + await expect( + db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } }) + ).resolves.toMatchObject({ + refresh: 5, + refreshTotal: 5, + }); + }); }); diff --git a/app/game-api/test/generalAccessTracking.test.ts b/app/game-api/test/generalAccessTracking.test.ts index c6b75a0..87450fc 100644 --- a/app/game-api/test/generalAccessTracking.test.ts +++ b/app/game-api/test/generalAccessTracking.test.ts @@ -1,8 +1,19 @@ import { describe, expect, it, vi } from 'vitest'; +import { TRPCError } from '@trpc/server'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import { z } from 'zod'; +import type { GameApiContext } from '../src/context.js'; import type { DatabaseClient } from '../src/context.js'; -import { accessPageWeights, recordGeneralAccess, resolveAccessWindows } from '../src/services/generalAccess.js'; +import { accessAuthedInputProcedure, router } from '../src/trpc.js'; +import { + accessPageWeights, + generalAccessEndpointWeights, + recordGeneralAccess, + recordGeneralAccessWeight, + resolveGeneralAccessEndpointWeight, + resolveAccessWindows, +} from '../src/services/generalAccess.js'; const auth = (roles = ['user']): GameSessionTokenPayload => ({ version: 1, @@ -48,9 +59,56 @@ const buildDb = (meta: Record = {}) => { }; describe('general access tracking', () => { - it('uses the legacy weight two for both global directory pages', () => { + it('owns every migrated Ref call at one explicit server endpoint weight', () => { + expect(generalAccessEndpointWeights).toEqual({ + '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, + 'general.setMySetting': 0, + 'npc.setNationPolicy': 0, + 'npc.setNationPriority': 0, + 'npc.setGeneralPriority': 0, + 'battle.simulate': 0, + }); + }); + + it('counts only current-profile yearbook reads like Ref Global.GetHistory', () => { + expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', {}, 'che')).toBe(1); + expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', { serverID: 'che' }, 'che')).toBe(1); + expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', { serverID: 'hwe' }, 'che')).toBeNull(); + expect(resolveGeneralAccessEndpointWeight('unknown.path', {}, 'che')).toBeUndefined(); + }); + + it('keeps the legacy route weight while endpoint-owned directories use the server map', () => { expect(accessPageWeights['nation-list']).toBe(2); - expect(accessPageWeights['general-list']).toBe(2); + expect(generalAccessEndpointWeights['world.getGeneralDirectory']).toBe(2); }); it('uses the latest processed game turn as the traffic period and score window', () => { @@ -68,7 +126,7 @@ describe('general access tracking', () => { const { db, executeRaw, queryRaw, transaction, findGeneral } = buildDb(); const now = new Date('2026-07-26T03:05:00.000Z'); - await expect(recordGeneralAccess({ auth: auth(), db }, 'npc-list', now)).resolves.toBe(true); + await expect(recordGeneralAccess({ auth: auth(), db }, 'nation-list', now)).resolves.toBe(true); expect(findGeneral).toHaveBeenCalledWith({ where: { userId: 'user-7' }, orderBy: { id: 'asc' }, @@ -105,6 +163,120 @@ describe('general access tracking', () => { expect(accessStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z')); }); + it('accepts legacy weight zero to refresh timestamps without incrementing counters', async () => { + 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); + 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); + expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(0); + expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(now); + }); + + 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 + ); + expect(fixture.findGeneral).not.toHaveBeenCalled(); + }); + + it('parses input, commits access, and only then opens the failing business event boundary', async () => { + const events: string[] = []; + let transactionCount = 0; + const transactionClient = { + $queryRaw: vi.fn(async () => [{ id: 41 }]), + $executeRaw: vi.fn(async () => 1), + inputEvent: { + update: vi.fn(async () => ({})), + }, + }; + const db = { + general: { + findFirst: vi.fn(async () => ({ + id: 7, + userId: 'user-7', + })), + }, + worldState: { + findFirst: vi.fn(async () => ({ + id: 3, + currentYear: 185, + currentMonth: 4, + tickSeconds: 600, + meta: { + opentime: '2026-07-25T00:00:00.000Z', + lastTurnTime: '2026-07-26T03:00:00.000Z', + }, + })), + }, + inputEvent: { + create: vi.fn(async () => { + events.push('input-event-create'); + return {}; + }), + update: vi.fn(async () => { + events.push('input-event-failed'); + return {}; + }), + updateMany: vi.fn(async () => ({ count: 0 })), + }, + $transaction: vi.fn(async (callback: (client: typeof transactionClient) => Promise) => { + transactionCount += 1; + events.push(transactionCount === 1 ? 'access-transaction' : 'business-transaction'); + return callback(transactionClient); + }), + }; + const trackedRouter = router({ + board: router({ + writeArticle: accessAuthedInputProcedure( + z.object({ + value: z.string().transform((value) => { + events.push('input-parse'); + return value; + }), + }) + ).mutation(() => { + events.push('resolver'); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'business rejected' }); + }), + }), + }); + const context = { + auth: auth(), + db, + generalAccessTracking: true, + requestId: 'access-boundary-test', + profile: { id: 'che:default', name: 'che' }, + } as unknown as GameApiContext; + + await expect(trackedRouter.createCaller(context).board.writeArticle({ value: 'ok' })).rejects.toMatchObject({ + message: 'business rejected', + }); + expect(events).toEqual([ + 'input-parse', + 'access-transaction', + 'input-event-create', + 'business-transaction', + 'resolver', + 'input-event-failed', + ]); + + events.length = 0; + transactionCount = 0; + await expect( + trackedRouter.createCaller(context).board.writeArticle({ value: 7 } as never) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + expect(events).toEqual([]); + }); + 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); diff --git a/app/game-frontend/e2e/directoryLists.spec.ts b/app/game-frontend/e2e/directoryLists.spec.ts index ee51849..7a743dc 100644 --- a/app/game-frontend/e2e/directoryLists.spec.ts +++ b/app/game-frontend/e2e/directoryLists.spec.ts @@ -161,9 +161,9 @@ const install = async ( ); await page.route('**/che/api/trpc/**', async (route) => { const requestBody = route.request().postDataJSON() as - | Record - | undefined; + Record | undefined; const results = operationNames(route).map((operation, operationIndex) => { + if (operation === 'auth.status') return response({ ok: true }); if (operation === 'lobby.info') { return response({ myGeneral: mode === 'no-general' ? null : { id: 1, name: '조회자' } }); } @@ -191,7 +191,8 @@ const install = async ( }; } const sort = parseSort(route); - const rows = sort === 8 ? [...generals].sort((left, right) => left.killturn - right.killturn) : generals; + const rows = + sort === 8 ? [...generals].sort((left, right) => left.killturn - right.killturn) : generals; return response({ sort, generals: rows }); } return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; @@ -200,6 +201,30 @@ const install = async ( }); }; +const installAccessBoundary = async (page: Page, accessPages: string[]) => { + await page.addInitScript(() => { + localStorage.setItem('sammo-game-token', 'ga_access_boundary'); + localStorage.setItem('sammo-game-profile', 'che:default'); + }); + await page.route('**/image/**', (route) => route.abort('failed')); + await page.route('**/che/api/trpc/**', async (route) => { + const requestBody = route.request().postDataJSON() as + Record | undefined; + const results = operationNames(route).map((operation, operationIndex) => { + if (operation === 'auth.status') return response({ ok: true }); + if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '조회자' } }); + if (operation === 'public.recordAccess') { + const payload = requestBody?.[String(operationIndex)]; + const pageName = payload?.json?.page ?? payload?.page; + if (typeof pageName === 'string') accessPages.push(pageName); + return response({ recorded: true }); + } + return response({}); + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); + }); +}; + test('nation and general directories preserve the fixed legacy Chromium geometry', async ({ page }) => { const accessPages: string[] = []; await install(page, 'general', accessPages); @@ -283,7 +308,8 @@ test('nation and general directories preserve the fixed legacy Chromium geometry ).toBe(65); } } - await expect.poll(() => accessPages).toEqual(expect.arrayContaining(['nation-list', 'general-list'])); + await expect.poll(() => accessPages).toContain('nation-list'); + expect(accessPages).not.toContain('general-list'); const header = page.locator('.general-table thead td').first(); expect(await header.evaluate((element) => getComputedStyle(element).backgroundImage)).toContain('back_green.jpg'); @@ -345,3 +371,51 @@ test('an authenticated account without a general is redirected away from both di await expect(page).toHaveURL(/\/che\/join$/); } }); + +test('route access belongs only to the eight Ref page boundaries', async ({ page }) => { + const accessPages: string[] = []; + await installAccessBoundary(page, accessPages); + const retained = [ + ['nation/info', 'nation-info'], + ['nation/cities', 'nation-cities'], + ['nation-list', 'nation-list'], + ['current-city', 'current-city'], + ['dynasty', 'dynasty'], + ['dynasty/1', 'dynasty'], + ['traffic', 'traffic'], + ['npc-control', 'npc-control'], + ] as const; + for (const [path, pageName] of retained) { + const before = accessPages.length; + await page.goto(path); + await expect.poll(() => accessPages.length).toBe(before + 1); + expect(accessPages.at(-1)).toBe(pageName); + } + + const endpointOwned = [ + './', + 'global-info', + 'general-list', + 'diplomacy', + 'nation/generals', + 'nation/personnel', + 'nation/finance', + 'battle-center', + 'board', + 'board/secret', + 'best-general', + 'hall-of-fame', + 'yearbook', + 'nation-betting', + 'npc-list', + 'my-page', + 'tournament', + 'betting', + ]; + for (const path of endpointOwned) { + const before = accessPages.length; + await page.goto(path); + await page.waitForTimeout(50); + expect(accessPages).toHaveLength(before); + } +}); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 2496e0e..7d0420f 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -26,12 +26,16 @@ type FixtureState = { instantRetreatEnabled?: boolean; instantRetreatAttempts?: number; instantRetreatInputs?: Array>; + buildNationCandidateEnabled?: boolean; + buildNationCandidateAttempts?: number; + buildNationCandidateInputs?: Array>; dieOnPrestartShow?: boolean; dieOnPrestartAvailableAt?: string; dieOnPrestartAttempts?: number; dieOnPrestartInputs?: Array>; generalMeQueries?: number; generalLogQueries?: number; + ensurePrestartQueries?: number; nationNoticeInput?: string; settingMutations: Array>; accessPages: string[]; @@ -47,7 +51,7 @@ const myGeneral = (state: FixtureState) => ({ id: 7, name: '검증장수', npcState: 0, - nationId: 1, + nationId: state.buildNationCandidateEnabled ? 0 : 1, cityId: 1, troopId: 0, picture: null, @@ -165,12 +169,14 @@ const install = async (page: Page, state: FixtureState) => { state.generalMeQueries = (state.generalMeQueries ?? 0) + 1; return response(myGeneral(state)); } - if (operation === 'general.ensureDieOnPrestartStatus') + if (operation === 'general.ensureDieOnPrestartStatus') { + state.ensurePrestartQueries = (state.ensurePrestartQueries ?? 0) + 1; return response({ show: state.dieOnPrestartShow ?? false, available: false, availableAt: state.dieOnPrestartAvailableAt ?? null, }); + } if (operation === 'general.getFrontStatus') return response({ onlineUserCount: 1, @@ -196,7 +202,9 @@ const install = async (page: Page, state: FixtureState) => { }, meta: { turntime: '2026-01-01T00:00:00.000Z', - opentime: '2025-12-01T00:00:00.000Z', + opentime: state.buildNationCandidateEnabled + ? '2026-02-01T00:00:00.000Z' + : '2025-12-01T00:00:00.000Z', autorun_user: {}, }, }); @@ -261,6 +269,20 @@ const install = async (page: Page, state: FixtureState) => { } return response({ ok: true }); } + if (operation === 'general.buildNationCandidate') { + state.buildNationCandidateInputs?.push(jsonInput); + state.buildNationCandidateAttempts = (state.buildNationCandidateAttempts ?? 0) + 1; + if (state.buildNationCandidateAttempts === 1) { + return { + error: { + message: '요청 처리 결과를 확인하지 못했습니다.', + code: -32000, + data: { code: 'TIMEOUT', httpStatus: 408, path: operation }, + }, + }; + } + return response({ ok: true }); + } if (operation === 'general.dieOnPrestart') { state.dieOnPrestartInputs?.push(jsonInput); state.dieOnPrestartAttempts = (state.dieOnPrestartAttempts ?? 0) + 1; @@ -395,7 +417,8 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac await page.goto('my-page'); await expect(page.locator('.title-row')).toContainText('내 정 보'); await expect(page.locator('#set_my_setting')).toBeVisible(); - await expect.poll(() => state.accessPages).toContain('my-page'); + await expect.poll(() => state.generalMeQueries).toBeGreaterThan(0); + expect(state.accessPages).not.toContain('my-page'); const noDefenceOption = page.locator('option[value="999"]'); await expect(noDefenceOption).toHaveText('× [훈련 -3,사기 -6]'); await expect(page.locator('#defence_train option')).toHaveText([ @@ -533,6 +556,7 @@ test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후 instantRetreatInputs: [], generalMeQueries: 0, generalLogQueries: 0, + ensurePrestartQueries: 0, settingMutations: [], accessPages: [], }; @@ -547,18 +571,21 @@ test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후 const instantRetreatButton = page.getByRole('button', { name: '접경 귀환' }); await expect(instantRetreatButton).toBeVisible(); await expect.poll(() => state.generalMeQueries).toBe(1); + await expect.poll(() => state.ensurePrestartQueries).toBe(1); await instantRetreatButton.click(); await expect.poll(() => state.instantRetreatInputs?.length).toBe(1); await expect .poll(() => dialogs.some((message) => message.includes('요청 처리 결과를 확인하지 못했습니다.'))) .toBe(true); - expect(state.generalMeQueries).toBe(1); + await expect.poll(() => state.generalMeQueries).toBe(2); + await expect.poll(() => state.ensurePrestartQueries).toBe(2); await instantRetreatButton.click(); await expect.poll(() => state.instantRetreatInputs?.length).toBe(2); - await expect.poll(() => state.generalMeQueries).toBe(2); - await expect.poll(() => state.generalLogQueries).toBe(8); + await expect.poll(() => state.generalMeQueries).toBe(3); + await expect.poll(() => state.ensurePrestartQueries).toBe(3); + await expect.poll(() => state.generalLogQueries).toBe(12); await page.evaluate(() => new Promise((resolveFrame) => requestAnimationFrame(() => resolveFrame()))); await instantRetreatButton.click(); @@ -690,6 +717,40 @@ test('가오픈 장수 삭제는 레거시 표시와 확인을 보존하고 time }); }); +test('사전 거병은 timeout reload 뒤 같은 ID를 재시도하고 성공 reload 뒤 새 ID를 만든다', async ({ page }) => { + const state: FixtureState = { + permission: 'head', + myset: 3, + buildNationCandidateEnabled: true, + buildNationCandidateAttempts: 0, + buildNationCandidateInputs: [], + ensurePrestartQueries: 0, + settingMutations: [], + accessPages: [], + }; + page.on('dialog', (dialog) => dialog.accept()); + await install(page, state); + await page.goto('my-page'); + + const build = page.getByRole('button', { name: '사전 거병' }); + await expect(build).toBeVisible(); + await expect.poll(() => state.ensurePrestartQueries).toBe(1); + + await build.click(); + await expect.poll(() => state.buildNationCandidateInputs?.length).toBe(1); + await expect.poll(() => state.ensurePrestartQueries).toBe(2); + + await build.click(); + await expect.poll(() => state.buildNationCandidateInputs?.length).toBe(2); + await expect.poll(() => state.ensurePrestartQueries).toBe(3); + + await build.click(); + await expect.poll(() => state.buildNationCandidateInputs?.length).toBe(3); + const requestIds = state.buildNationCandidateInputs?.map((input) => input.clientRequestId); + expect(requestIds?.[1]).toBe(requestIds?.[0]); + expect(requestIds?.[2]).not.toBe(requestIds?.[1]); +}); + test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => { const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; await install(page, head); diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index b9f7354..922fd50 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -41,32 +41,14 @@ import { useSessionStore } from '../stores/session'; import { trpc } from '../utils/trpc'; const accessPageByRouteName = { - home: 'front-info', 'nation-info': 'nation-info', 'nation-cities': 'nation-cities', - 'global-info': 'global-info', 'nation-list': 'nation-list', - 'general-list': 'general-list', 'current-city': 'current-city', - diplomacy: 'diplomacy', - 'nation-generals': 'nation-generals', - 'nation-personnel': 'nation-personnel', - 'nation-finance': 'nation-finance', - 'battle-center': 'battle-center', - board: 'board', - 'board-secret': 'board', - 'best-general': 'best-general', - 'hall-of-fame': 'hall-of-fame', 'dynasty-list': 'dynasty', 'dynasty-detail': 'dynasty', - yearbook: 'yearbook', - 'nation-betting': 'nation-betting', traffic: 'traffic', - 'npc-list': 'npc-list', - 'my-page': 'my-page', 'npc-control': 'npc-control', - tournament: 'tournament', - betting: 'betting', } as const; const routes = [ diff --git a/app/game-frontend/src/views/BettingView.vue b/app/game-frontend/src/views/BettingView.vue index fb657e2..bdc0a3a 100644 --- a/app/game-frontend/src/views/BettingView.vue +++ b/app/game-frontend/src/views/BettingView.vue @@ -86,9 +86,10 @@ const placeBet = async (targetId: number) => { try { await trpc.tournament.placeBet.mutate({ targetId, amount }); message.value = '베팅이 등록되었습니다.'; - await load(); } catch (value) { message.value = errorText(value); + } finally { + await load(); } }; diff --git a/app/game-frontend/src/views/HallOfFameView.vue b/app/game-frontend/src/views/HallOfFameView.vue index 426c8f0..788d63d 100644 --- a/app/game-frontend/src/views/HallOfFameView.vue +++ b/app/game-frontend/src/views/HallOfFameView.vue @@ -106,10 +106,7 @@ watch([selectedSeason, selectedScenario], () => { void loadHall(); }); -onMounted(async () => { - await loadOptions(); - await loadHall(); -}); +onMounted(loadOptions);