diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 213bda2a..3053dbe3 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -85,6 +85,8 @@ export type DatabaseClient = InfraDatabaseClient; export interface GameApiContext { requestId?: string; generalAccessTracking?: boolean; + /** Request-local identity already resolved by the realtime access gate. */ + realtimeAccessGeneralId?: number; db: DatabaseClient; redis: RedisConnector['client']; turnDaemon: TurnDaemonTransport; diff --git a/app/game-api/src/router/dashboard/index.ts b/app/game-api/src/router/dashboard/index.ts index 18804a62..1f463c33 100644 --- a/app/game-api/src/router/dashboard/index.ts +++ b/app/game-api/src/router/dashboard/index.ts @@ -9,26 +9,21 @@ import { getTurnCommandTable } from '../turns/index.js'; const zRevision = z.string().regex(/^[A-Za-z0-9_-]{22}$/u); -const zContextBundleInput = z - .object({ - include: z.object({ - context: z.boolean(), - commandTable: z.boolean(), - boardAccess: z.boolean(), - }), - known: z - .object({ - context: zRevision.optional(), - commandTable: zRevision.optional(), - boardAccess: zRevision.optional(), - }) - .optional(), - forceSnapshot: z.boolean().optional(), - }) - .refine((input) => Object.values(input.include).some(Boolean), { - message: 'At least one dashboard context slice must be requested.', - path: ['include'], - }); +const zContextBundleInput = z.object({ + include: z.object({ + context: z.boolean(), + commandTable: z.boolean(), + boardAccess: z.boolean(), + }), + known: z + .object({ + context: zRevision.optional(), + commandTable: zRevision.optional(), + boardAccess: zRevision.optional(), + }) + .optional(), + forceSnapshot: z.boolean().optional(), +}); export const dashboardRouter = router({ getContextBundleDelta: accessLimitAuthedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => { @@ -37,8 +32,20 @@ export const dashboardRouter = router({ throw new TRPCError({ code: 'UNAUTHORIZED' }); } - const currentContext = await getGeneralContext(ctx); - const generalId = currentContext?.general.id ?? null; + const includesProjection = Object.values(input.include).some(Boolean); + const currentContext = input.include.context ? await getGeneralContext(ctx) : undefined; + const generalId = + currentContext?.general.id ?? + ctx.realtimeAccessGeneralId ?? + (includesProjection + ? ( + await ctx.db.general.findFirst({ + where: { userId: viewerId }, + orderBy: { id: 'asc' }, + select: { id: true }, + }) + )?.id ?? null + : null); const [commandTable, boardAccess] = await Promise.all([ input.include.commandTable && generalId ? getTurnCommandTable(ctx, generalId) : Promise.resolve(undefined), input.include.boardAccess && generalId ? getBoardAccess(ctx) : Promise.resolve(undefined), diff --git a/app/game-api/src/services/generalAccess.ts b/app/game-api/src/services/generalAccess.ts index f65bfa64..c32bf76b 100644 --- a/app/game-api/src/services/generalAccess.ts +++ b/app/game-api/src/services/generalAccess.ts @@ -94,6 +94,7 @@ export const generalAccessLimitEndpoints = new Set([ export const generalAccessLimitBeforeRecordEndpoints = new Set(['general.getFrontStatus']); export type GeneralAccessState = { + generalId: number; refreshScore: number; refreshLimit: number; level: AccessLimitLevel; @@ -194,6 +195,7 @@ export const getGeneralAccessState = async ( : (access?.refreshScore ?? 0); const refreshLimit = resolveAccessRefreshLimit(worldState.tickSeconds, asRecord(worldState.meta).refreshLimit); return { + generalId: general.id, refreshScore, refreshLimit, level: resolveAccessLimitLevel(refreshScore, refreshLimit), diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 03220de5..09ca85f5 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -126,7 +126,12 @@ const generalAccessLimitMiddleware = t.middleware(async ({ ctx, next }) => { message: formatGeneralAccessLimitMessage(state), }); } - return next(); + return next({ + ctx: { + ...ctx, + ...(state ? { realtimeAccessGeneralId: state.generalId } : {}), + }, + }); }); export const router = t.router; diff --git a/app/game-api/test/dashboardRouter.test.ts b/app/game-api/test/dashboardRouter.test.ts index 6d9ab119..f1f23351 100644 --- a/app/game-api/test/dashboardRouter.test.ts +++ b/app/game-api/test/dashboardRouter.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { applyReadModelDelta } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; @@ -21,12 +21,47 @@ const auth: GameSessionTokenPayload = { sanctions: {}, }; -const buildContext = (authenticated: boolean) => { +const buildContext = (authenticated: boolean, generalAccessTracking = false) => { let generalName = '초기 장수'; const redisValues = new Map(); + const findGeneral = vi.fn(async () => ({ + id: 7, + name: generalName, + npcState: 0, + nationId: 0, + cityId: 0, + troopId: 0, + picture: null, + imageServer: 0, + leadership: 70, + strength: 60, + intel: 50, + officerLevel: 0, + gold: 1_000, + rice: 2_000, + crew: 300, + train: 80, + atmos: 90, + injury: 0, + experience: 100, + dedication: 200, + age: 20, + turnTime: new Date('2026-08-11T00:10:00.000Z'), + crewTypeId: 0, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + weaponCode: 'None', + horseCode: 'None', + bookCode: 'None', + itemCode: 'None', + meta: {}, + penalty: {}, + })); const context = { auth: authenticated ? auth : null, profile: { id: 'hwe', scenario: 'default', name: 'hwe:default' }, + generalAccessTracking, redis: { get: async (key: string) => redisValues.get(key) ?? null, set: async (key: string, value: string) => { @@ -36,45 +71,20 @@ const buildContext = (authenticated: boolean) => { }, db: { general: { - findFirst: async () => ({ - id: 7, - name: generalName, - npcState: 0, - nationId: 0, - cityId: 0, - troopId: 0, - picture: null, - imageServer: 0, - leadership: 70, - strength: 60, - intel: 50, - officerLevel: 0, - gold: 1_000, - rice: 2_000, - crew: 300, - train: 80, - atmos: 90, - injury: 0, - experience: 100, - dedication: 200, - age: 20, - turnTime: new Date('2026-08-11T00:00:00.000Z'), - crewTypeId: 0, - personalCode: 'None', - specialCode: 'None', - special2Code: 'None', - weaponCode: 'None', - horseCode: 'None', - bookCode: 'None', - itemCode: 'None', - meta: {}, - penalty: {}, - }), + findFirst: findGeneral, }, city: { findUnique: async () => null }, nation: { findUnique: async () => null }, generalAccessLog: { findUnique: async () => null }, - worldState: { findFirst: async () => ({ config: { const: {} } }) }, + worldState: { + findFirst: async () => ({ + currentYear: 185, + currentMonth: 1, + tickSeconds: 600, + config: { const: {} }, + meta: { lastTurnTime: '2026-08-11T00:00:00.000Z' }, + }), + }, }, } as unknown as GameApiContext; @@ -83,6 +93,7 @@ const buildContext = (authenticated: boolean) => { rename: (name: string) => { generalName = name; }, + findGeneral, }; }; @@ -128,12 +139,18 @@ describe('dashboardRouter.getContextBundleDelta', () => { ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); }); - it('rejects an empty bundle request', async () => { - const fixture = buildContext(true); + it('uses an all-false bundle as an access-only gate without projecting dashboard context', async () => { + const fixture = buildContext(true, true); await expect( dashboardRouter.createCaller(fixture.context).getContextBundleDelta({ include: { context: false, commandTable: false, boardAccess: false }, }) - ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + ).resolves.toEqual({ context: undefined, commandTable: undefined, boardAccess: undefined }); + expect(fixture.findGeneral).toHaveBeenCalledTimes(1); + expect(fixture.findGeneral).toHaveBeenCalledWith({ + where: { userId: auth.user.id }, + orderBy: { id: 'asc' }, + select: { id: true, turnTime: true }, + }); }); }); diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index d2e3166b..6b5fc236 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -13,7 +13,11 @@ import { useSessionStore } from './session'; import { createLatestRefreshQueue } from '../utils/latestRefreshQueue'; import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue'; import { structurallyShare } from '../utils/structuralShare'; -import { createMergedReadModelRefreshQueue } from '../utils/dashboardReadModel'; +import { + createMergedReadModelRefreshQueue, + resolveDashboardContextBundleInclude, + type DashboardContextBundleInclude, +} from '../utils/dashboardReadModel'; import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator'; import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery'; @@ -43,11 +47,6 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { type RecentRecord = Awaited>['global'][number]; type FrontStatus = Awaited>; type ContextBundleDelta = Awaited>; - type ContextBundleInclude = { - context: boolean; - commandTable: boolean; - boardAccess: boolean; - }; type DashboardReadModelPatch = { contextSnapshot?: GeneralContext; contextRevision?: string | null; @@ -479,7 +478,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { }; const fetchContextBundlePatch = async ( - include: ContextBundleInclude, + include: DashboardContextBundleInclude, forceSnapshot = false ): Promise => { const request = (force: boolean) => @@ -637,13 +636,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { if (plan.records) recordsError.value = null; if (plan.frontStatus) frontStatusError.value = null; try { - 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, - }); + // Every automatic refresh crosses this access-limit gate before + // any selected follow-up query starts. An all-false bundle is an + // access-only check and does not project general context. + const contextPatch = await fetchContextBundlePatch(resolveDashboardContextBundleInclude(plan)); accessLimited.value = false; const lobbyPromise = plan.lobby ? trpc.lobby.info.query() : Promise.resolve(undefined); const mapPromise = plan.map diff --git a/app/game-frontend/src/utils/dashboardReadModel.ts b/app/game-frontend/src/utils/dashboardReadModel.ts index b7d93e8e..381c5fa2 100644 --- a/app/game-frontend/src/utils/dashboardReadModel.ts +++ b/app/game-frontend/src/utils/dashboardReadModel.ts @@ -9,12 +9,25 @@ import { export type DashboardReadModelIdentity = RealtimeViewerIdentity; export type DashboardRefreshPlan = RealtimeReadModelInvalidation; +export type DashboardContextBundleInclude = { + context: boolean; + commandTable: boolean; + boardAccess: boolean; +}; export const resolveDashboardRefreshPlan = ( changes: RealtimeReadModelChanges, identity: DashboardReadModelIdentity ): DashboardRefreshPlan => resolveRealtimeReadModelInvalidation(changes, identity); +export const resolveDashboardContextBundleInclude = ( + plan: DashboardRefreshPlan +): DashboardContextBundleInclude => ({ + context: plan.context, + commandTable: plan.commands, + boardAccess: plan.boardAccess, +}); + type TimerHandle = ReturnType; export interface MergedReadModelRefreshQueue { diff --git a/app/game-frontend/test/dashboardReadModel.test.ts b/app/game-frontend/test/dashboardReadModel.test.ts index 2baae215..bc8f819c 100644 --- a/app/game-frontend/test/dashboardReadModel.test.ts +++ b/app/game-frontend/test/dashboardReadModel.test.ts @@ -2,7 +2,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createEmptyRealtimeReadModelChanges, createEmptyRealtimeReadModelInvalidation } from '@sammo-ts/common'; -import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../src/utils/dashboardReadModel.ts'; +import { + createMergedReadModelRefreshQueue, + resolveDashboardContextBundleInclude, + resolveDashboardRefreshPlan, +} from '../src/utils/dashboardReadModel.ts'; void test('last-turn-time-only events do not schedule any dashboard query', () => { const plan = resolveDashboardRefreshPlan(createEmptyRealtimeReadModelChanges(), { @@ -170,6 +174,30 @@ void test('targets a submitted survey projection to its own general', () => { assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 8, cityId: 3, nationId: 2 }).frontStatus, false); }); +void test('keeps the access bundle projection-free for map, records, and front-status-only plans', () => { + for (const slice of ['map', 'records', 'frontStatus'] as const) { + const plan = { ...createEmptyRealtimeReadModelInvalidation(), [slice]: true }; + assert.deepEqual(resolveDashboardContextBundleInclude(plan), { + context: false, + commandTable: false, + boardAccess: false, + }); + } +}); + +void test('selects only the requested context bundle projections', () => { + const plan = { + ...createEmptyRealtimeReadModelInvalidation(), + context: true, + commands: true, + }; + assert.deepEqual(resolveDashboardContextBundleInclude(plan), { + context: true, + commandTable: true, + boardAccess: false, + }); +}); + void test('merges browser-safe boolean invalidations and starts at most once per interval', async () => { let nowMs = 0; let nextTimerId = 1;