diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index cf8888a2..c700e8fe 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -25,6 +25,7 @@ import { dynastyRouter } from './router/dynasty/index.js'; import { voteRouter } from './router/vote/index.js'; import { bettingRouter } from './router/betting/index.js'; import { archiveRouter } from './router/archive/index.js'; +import { dashboardRouter } from './router/dashboard/index.js'; export const appRouter = router({ health: healthRouter, @@ -52,6 +53,7 @@ export const appRouter = router({ vote: voteRouter, betting: bettingRouter, archive: archiveRouter, + dashboard: dashboardRouter, }); export type AppRouter = typeof appRouter; diff --git a/app/game-api/src/router/board/index.ts b/app/game-api/src/router/board/index.ts index e39c36a1..f6d7f046 100644 --- a/app/game-api/src/router/board/index.ts +++ b/app/game-api/src/router/board/index.ts @@ -34,6 +34,15 @@ const getBoardActor = async (ctx: Parameters[0]) => { return { general, permission }; }; +export const getBoardAccess = async (ctx: Parameters[0]) => { + const { permission } = await getBoardActor(ctx); + return { + permission, + canMeeting: permission >= 0, + canSecret: permission >= 2, + }; +}; + const parseDataUrl = (dataUrl: string): Buffer => { const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/); if (match) { @@ -82,14 +91,7 @@ const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise }; export const boardRouter = router({ - getAccess: authedProcedure.query(async ({ ctx }) => { - const { permission } = await getBoardActor(ctx); - return { - permission, - canMeeting: permission >= 0, - canSecret: permission >= 2, - }; - }), + getAccess: authedProcedure.query(({ ctx }) => getBoardAccess(ctx)), getArticles: accessAuthedInputProcedure(z.object({ isSecret: z.boolean() })).query(async ({ ctx, input }) => { const { general, permission } = await getBoardActor(ctx); assertBoardAccess(permission, input.isSecret); diff --git a/app/game-api/src/router/dashboard/index.ts b/app/game-api/src/router/dashboard/index.ts new file mode 100644 index 00000000..b36dec7c --- /dev/null +++ b/app/game-api/src/router/dashboard/index.ts @@ -0,0 +1,90 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import { authedProcedure, router } from '../../trpc.js'; +import { createReadModelDelta } from '../../services/readModelDeltaCache.js'; +import { getBoardAccess } from '../board/index.js'; +import { getGeneralContext } from '../general/index.js'; +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'], + }); + +export const dashboardRouter = router({ + getContextBundleDelta: authedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => { + const viewerId = ctx.auth?.user.id; + if (!viewerId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + + const currentContext = await getGeneralContext(ctx); + const generalId = currentContext?.general.id ?? 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), + ]); + const context = input.include.context ? currentContext : undefined; + + const [contextDelta, commandTableDelta, boardAccessDelta] = await Promise.all([ + context === undefined + ? Promise.resolve(undefined) + : createReadModelDelta({ + store: ctx.redis, + profile: ctx.profile.name, + viewerId, + slice: `main-context:${generalId ?? 'none'}`, + value: context, + knownRevision: input.known?.context, + forceSnapshot: input.forceSnapshot, + }), + commandTable === undefined + ? Promise.resolve(undefined) + : createReadModelDelta({ + store: ctx.redis, + profile: ctx.profile.name, + viewerId, + slice: `main-command-table:${generalId}`, + value: commandTable, + knownRevision: input.known?.commandTable, + forceSnapshot: input.forceSnapshot, + }), + boardAccess === undefined + ? Promise.resolve(undefined) + : createReadModelDelta({ + store: ctx.redis, + profile: ctx.profile.name, + viewerId, + slice: `main-board-access:${generalId}`, + value: boardAccess, + knownRevision: input.known?.boardAccess, + forceSnapshot: input.forceSnapshot, + }), + ]); + + return { + context: contextDelta, + commandTable: commandTableDelta, + boardAccess: boardAccessDelta, + }; + }), +}); diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 8bbbaff4..e5633a18 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -173,6 +173,171 @@ const resolvePenalty = (penalty: unknown): Record => { return result; }; +export const getGeneralContext = async (ctx: GameApiContext) => { + const userId = ctx.auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + + const general = await ctx.db.general.findFirst({ + where: { userId }, + select: { + id: true, + name: true, + npcState: true, + nationId: true, + cityId: true, + troopId: true, + picture: true, + imageServer: true, + leadership: true, + strength: true, + intel: true, + officerLevel: true, + gold: true, + rice: true, + crew: true, + train: true, + atmos: true, + injury: true, + experience: true, + dedication: true, + age: true, + turnTime: true, + crewTypeId: true, + personalCode: true, + specialCode: true, + special2Code: true, + weaponCode: true, + horseCode: true, + bookCode: true, + itemCode: true, + meta: true, + penalty: true, + }, + }); + + if (!general) { + return null; + } + + const [city, nation, worldState] = await Promise.all([ + general.cityId > 0 + ? ctx.db.city.findUnique({ + where: { id: general.cityId }, + select: { + id: true, + name: true, + level: true, + nationId: true, + population: true, + populationMax: true, + agriculture: true, + agricultureMax: true, + commerce: true, + commerceMax: true, + security: true, + securityMax: true, + trust: true, + trade: true, + defence: true, + defenceMax: true, + wall: true, + wallMax: true, + region: true, + supplyState: true, + frontState: true, + }, + }) + : null, + general.nationId > 0 + ? ctx.db.nation.findUnique({ + where: { id: general.nationId }, + select: { + id: true, + name: true, + color: true, + level: true, + gold: true, + rice: true, + tech: true, + typeCode: true, + capitalCityId: true, + }, + }) + : null, + ctx.db.worldState.findFirst({ select: { config: true } }), + ]); + + const metaRecord = asRecord(general.meta); + const worldConfig = asRecord(worldState?.config); + const constValues = asRecord(worldConfig.const ?? worldConfig.consts); + const settings = resolveUserSettings(metaRecord); + const penalties = resolvePenalty(general.penalty); + + return { + general: { + id: general.id, + name: general.name, + npcState: general.npcState, + nationId: general.nationId, + cityId: general.cityId, + troopId: general.troopId, + picture: general.picture, + imageServer: general.imageServer, + officerLevel: general.officerLevel, + stats: { + leadership: general.leadership, + strength: general.strength, + intelligence: general.intel, + }, + gold: general.gold, + rice: general.rice, + crew: general.crew, + train: general.train, + atmos: general.atmos, + injury: general.injury, + experience: general.experience, + dedication: general.dedication, + age: general.age, + turnTime: general.turnTime.toISOString(), + crewTypeId: general.crewTypeId, + traits: { + personal: general.personalCode, + specialWar: general.specialCode, + specialDomestic: general.special2Code, + }, + progression: { + experienceLevel: readNumber(metaRecord.explevel, 0), + dedicationLevel: readNumber(metaRecord.dedlevel, 0), + statExperience: { + leadership: readNumber(metaRecord.leadership_exp, 0), + strength: readNumber(metaRecord.strength_exp, 0), + intelligence: readNumber(metaRecord.intel_exp, 0), + }, + statUpgradeLimit: readNumber(constValues.upgradeLimit, 30), + dex: [1, 2, 3, 4, 5].map((index) => readNumber(metaRecord[`dex${index}`], 0)), + }, + items: { + horse: normalizeItemCode(general.horseCode), + weapon: normalizeItemCode(general.weaponCode), + book: normalizeItemCode(general.bookCode), + item: normalizeItemCode(general.itemCode), + }, + }, + iconChoices: ctx.auth?.user.canUseGeneralPicture === false ? [] : (ctx.auth?.user.icons ?? []), + canChangeIcon: general.npcState === 0 && ctx.auth?.user.canUseGeneralPicture !== false, + iconChangeAvailableAt: + typeof metaRecord.generalIconChangedAt === 'string' + ? new Date(new Date(metaRecord.generalIconChangedAt).getTime() + 24 * 60 * 60 * 1000).toISOString() + : null, + city, + nation, + settings, + penalties, + }; +}; + export const generalRouter = router({ adjustIcon: engineAuthedProcedure .input( @@ -201,170 +366,7 @@ export const generalRouter = router({ input?.clientRequestId ?? ctx.requestId ); }), - me: authedProcedure.query(async ({ ctx }) => { - const userId = ctx.auth?.user.id; - if (!userId) { - throw new TRPCError({ code: 'UNAUTHORIZED' }); - } - - const general = await ctx.db.general.findFirst({ - where: { userId }, - select: { - id: true, - name: true, - npcState: true, - nationId: true, - cityId: true, - troopId: true, - picture: true, - imageServer: true, - leadership: true, - strength: true, - intel: true, - officerLevel: true, - gold: true, - rice: true, - crew: true, - train: true, - atmos: true, - injury: true, - experience: true, - dedication: true, - age: true, - turnTime: true, - crewTypeId: true, - personalCode: true, - specialCode: true, - special2Code: true, - weaponCode: true, - horseCode: true, - bookCode: true, - itemCode: true, - meta: true, - penalty: true, - }, - }); - - if (!general) { - return null; - } - - const [city, nation, worldState] = await Promise.all([ - general.cityId > 0 - ? ctx.db.city.findUnique({ - where: { id: general.cityId }, - select: { - id: true, - name: true, - level: true, - nationId: true, - population: true, - populationMax: true, - agriculture: true, - agricultureMax: true, - commerce: true, - commerceMax: true, - security: true, - securityMax: true, - trust: true, - trade: true, - defence: true, - defenceMax: true, - wall: true, - wallMax: true, - region: true, - supplyState: true, - frontState: true, - }, - }) - : null, - general.nationId > 0 - ? ctx.db.nation.findUnique({ - where: { id: general.nationId }, - select: { - id: true, - name: true, - color: true, - level: true, - gold: true, - rice: true, - tech: true, - typeCode: true, - capitalCityId: true, - }, - }) - : null, - ctx.db.worldState.findFirst({ select: { config: true } }), - ]); - - const metaRecord = asRecord(general.meta); - const worldConfig = asRecord(worldState?.config); - const constValues = asRecord(worldConfig.const ?? worldConfig.consts); - const settings = resolveUserSettings(metaRecord); - const penalties = resolvePenalty(general.penalty); - - return { - general: { - id: general.id, - name: general.name, - npcState: general.npcState, - nationId: general.nationId, - cityId: general.cityId, - troopId: general.troopId, - picture: general.picture, - imageServer: general.imageServer, - officerLevel: general.officerLevel, - stats: { - leadership: general.leadership, - strength: general.strength, - intelligence: general.intel, - }, - gold: general.gold, - rice: general.rice, - crew: general.crew, - train: general.train, - atmos: general.atmos, - injury: general.injury, - experience: general.experience, - dedication: general.dedication, - age: general.age, - turnTime: general.turnTime.toISOString(), - crewTypeId: general.crewTypeId, - traits: { - personal: general.personalCode, - specialWar: general.specialCode, - specialDomestic: general.special2Code, - }, - progression: { - experienceLevel: readNumber(metaRecord.explevel, 0), - dedicationLevel: readNumber(metaRecord.dedlevel, 0), - statExperience: { - leadership: readNumber(metaRecord.leadership_exp, 0), - strength: readNumber(metaRecord.strength_exp, 0), - intelligence: readNumber(metaRecord.intel_exp, 0), - }, - statUpgradeLimit: readNumber(constValues.upgradeLimit, 30), - dex: [1, 2, 3, 4, 5].map((index) => readNumber(metaRecord[`dex${index}`], 0)), - }, - items: { - horse: normalizeItemCode(general.horseCode), - weapon: normalizeItemCode(general.weaponCode), - book: normalizeItemCode(general.bookCode), - item: normalizeItemCode(general.itemCode), - }, - }, - iconChoices: ctx.auth?.user.canUseGeneralPicture === false ? [] : (ctx.auth?.user.icons ?? []), - canChangeIcon: general.npcState === 0 && ctx.auth?.user.canUseGeneralPicture !== false, - iconChangeAvailableAt: - typeof metaRecord.generalIconChangedAt === 'string' - ? new Date(new Date(metaRecord.generalIconChangedAt).getTime() + 24 * 60 * 60 * 1000).toISOString() - : null, - city, - nation, - settings, - penalties, - }; - }), + me: authedProcedure.query(({ ctx }) => getGeneralContext(ctx)), ensureDieOnPrestartStatus: accessEngineAuthedProcedure.mutation(async ({ ctx }) => { const userId = ctx.auth?.user.id; if (!userId) { diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index ec76fa33..cb1fa847 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -42,17 +42,14 @@ const zPushAmount = z const zRepeatAmount = z.number().int().min(1).max(12); const buildTurnListSchema = (minimum: number, maximum: number) => - z - .array(z.number().int().min(minimum).max(maximum)) - .min(1); + z.array(z.number().int().min(minimum).max(maximum)).min(1); const buildBulkEntrySchema = (turnList: z.ZodType) => - z - .object({ - turnList, - action: z.string().min(1), - args: z.unknown().optional(), - }); + z.object({ + turnList, + action: z.string().min(1), + args: z.unknown().optional(), + }); const parseCommandArgs = async (scope: 'general' | 'nation', action: string, args: unknown) => { try { @@ -112,9 +109,107 @@ const assertReservedTurnPermission = async ( throw new TRPCError({ code: 'PRECONDITION_FAILED', message: - result.kind === 'deny' - ? `예약 불가능한 커맨드 :${result.reason}` - : '예약 권한을 확인할 정보가 부족합니다.', + result.kind === 'deny' ? `예약 불가능한 커맨드 :${result.reason}` : '예약 권한을 확인할 정보가 부족합니다.', + }); +}; + +export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number) => { + const [worldState, general] = await Promise.all([ctx.db.worldState.findFirst(), getOwnedGeneral(ctx, generalId)]); + + if (!worldState) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'World state is not initialized.', + }); + } + + const [city, nation, nationGenerals, cities, nations, generals, environment, traits, itemModules] = + await Promise.all([ + general.cityId > 0 + ? ctx.db.city.findUnique({ + where: { id: general.cityId }, + }) + : null, + general.nationId > 0 + ? ctx.db.nation.findUnique({ + where: { id: general.nationId }, + }) + : null, + general.nationId > 0 + ? ctx.db.general.findMany({ + where: { nationId: general.nationId }, + }) + : Promise.resolve(null), + ctx.db.city.findMany({ + select: { id: true, name: true, nationId: true }, + orderBy: { id: 'asc' }, + }), + ctx.db.nation.findMany({ + select: { id: true, name: true, color: true }, + orderBy: { id: 'asc' }, + }), + ctx.db.general.findMany({ + where: { npcState: { lt: 2 } }, + select: { id: true, name: true, nationId: true, cityId: true }, + orderBy: { id: 'asc' }, + }), + buildBattleSimEnvironment(worldState, ctx.profile.id), + loadBattleSimTraitOptions(), + loadItemModules([...ITEM_KEYS]), + ]); + + const nationById = new Map(nations.map((entry) => [entry.id, entry])); + const cityById = new Map(cities.map((entry) => [entry.id, entry])); + const items: TurnCommandInputOptions['items'] = { + horse: [{ value: 'None', label: '판매/해제' }], + weapon: [{ value: 'None', label: '판매/해제' }], + book: [{ value: 'None', label: '판매/해제' }], + item: [{ value: 'None', label: '판매/해제' }], + }; + for (const item of itemModules) { + if (item.buyable) { + items[item.slot].push({ value: item.key, label: item.name }); + } + } + const inputOptions: TurnCommandInputOptions = { + cities: cities.map((entry) => ({ + value: entry.id, + label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무주'})`, + })), + nations: nations.map((entry) => ({ + value: entry.id, + label: entry.name, + color: entry.color, + })), + generals: generals.map((entry) => ({ + value: entry.id, + label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무소속'} · ${ + cityById.get(entry.cityId)?.name ?? '재야' + })`, + })), + crewTypes: (environment.unitSet.crewTypes ?? []) + .filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible')) + .map((entry) => ({ value: entry.id, label: entry.name })), + armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => ({ + value: Number(value), + label, + })), + nationTypes: traits.nationTypes.map((entry) => ({ value: entry.key, label: entry.name })), + colors: TURN_COMMAND_NATION_COLORS.map((color, index) => ({ + value: index, + label: `색상 ${index + 1}`, + color, + })), + items, + }; + + return buildTurnCommandTable({ + worldState, + general, + city, + nation, + nationGenerals, + inputOptions, }); }; @@ -125,108 +220,7 @@ export const turnsRouter = router({ generalId: z.number().int().positive(), }) ) - .query(async ({ ctx, input }) => { - const [worldState, general] = await Promise.all([ - ctx.db.worldState.findFirst(), - getOwnedGeneral(ctx, input.generalId), - ]); - - if (!worldState) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: 'World state is not initialized.', - }); - } - - const [city, nation, nationGenerals, cities, nations, generals, environment, traits, itemModules] = - await Promise.all([ - general.cityId > 0 - ? ctx.db.city.findUnique({ - where: { id: general.cityId }, - }) - : null, - general.nationId > 0 - ? ctx.db.nation.findUnique({ - where: { id: general.nationId }, - }) - : null, - general.nationId > 0 - ? ctx.db.general.findMany({ - where: { nationId: general.nationId }, - }) - : Promise.resolve(null), - ctx.db.city.findMany({ - select: { id: true, name: true, nationId: true }, - orderBy: { id: 'asc' }, - }), - ctx.db.nation.findMany({ - select: { id: true, name: true, color: true }, - orderBy: { id: 'asc' }, - }), - ctx.db.general.findMany({ - where: { npcState: { lt: 2 } }, - select: { id: true, name: true, nationId: true, cityId: true }, - orderBy: { id: 'asc' }, - }), - buildBattleSimEnvironment(worldState, ctx.profile.id), - loadBattleSimTraitOptions(), - loadItemModules([...ITEM_KEYS]), - ]); - - const nationById = new Map(nations.map((entry) => [entry.id, entry])); - const cityById = new Map(cities.map((entry) => [entry.id, entry])); - const items: TurnCommandInputOptions['items'] = { - horse: [{ value: 'None', label: '판매/해제' }], - weapon: [{ value: 'None', label: '판매/해제' }], - book: [{ value: 'None', label: '판매/해제' }], - item: [{ value: 'None', label: '판매/해제' }], - }; - for (const item of itemModules) { - if (item.buyable) { - items[item.slot].push({ value: item.key, label: item.name }); - } - } - const inputOptions: TurnCommandInputOptions = { - cities: cities.map((entry) => ({ - value: entry.id, - label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무주'})`, - })), - nations: nations.map((entry) => ({ - value: entry.id, - label: entry.name, - color: entry.color, - })), - generals: generals.map((entry) => ({ - value: entry.id, - label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무소속'} · ${ - cityById.get(entry.cityId)?.name ?? '재야' - })`, - })), - crewTypes: (environment.unitSet.crewTypes ?? []) - .filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible')) - .map((entry) => ({ value: entry.id, label: entry.name })), - armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => ({ - value: Number(value), - label, - })), - nationTypes: traits.nationTypes.map((entry) => ({ value: entry.key, label: entry.name })), - colors: TURN_COMMAND_NATION_COLORS.map((color, index) => ({ - value: index, - label: `색상 ${index + 1}`, - color, - })), - items, - }; - - return buildTurnCommandTable({ - worldState, - general, - city, - nation, - nationGenerals, - inputOptions, - }); - }), + .query(({ ctx, input }) => getTurnCommandTable(ctx, input.generalId)), reserved: router({ getGeneral: authedProcedure .input( @@ -322,13 +316,7 @@ export const turnsRouter = router({ .input( z.object({ generalId: z.number().int().positive(), - entries: z - .array( - buildBulkEntrySchema( - buildTurnListSchema(-3, MAX_GENERAL_TURNS - 1) - ) - ) - .min(1), + entries: z.array(buildBulkEntrySchema(buildTurnListSchema(-3, MAX_GENERAL_TURNS - 1))).min(1), expectedRevision: z.number().int().nonnegative(), }) ) @@ -343,13 +331,7 @@ export const turnsRouter = router({ ); const worldState = await getReservationWorldState(ctx); for (const update of updates) { - await assertReservedTurnPermission( - worldState, - general, - 'general', - update.action, - update.args - ); + await assertReservedTurnPermission(worldState, general, 'general', update.action, update.args); } const snapshot = await mutateReservedTurns(() => setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision) @@ -472,13 +454,7 @@ export const turnsRouter = router({ .input( z.object({ generalId: z.number().int().positive(), - entries: z - .array( - buildBulkEntrySchema( - buildTurnListSchema(0, MAX_NATION_TURNS - 1) - ) - ) - .min(1), + entries: z.array(buildBulkEntrySchema(buildTurnListSchema(0, MAX_NATION_TURNS - 1))).min(1), expectedRevision: z.number().int().nonnegative(), }) ) @@ -505,22 +481,10 @@ export const turnsRouter = router({ ); const worldState = await getReservationWorldState(ctx); for (const update of updates) { - await assertReservedTurnPermission( - worldState, - general, - 'nation', - update.action, - update.args - ); + await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args); } const snapshot = await mutateReservedTurns(() => - setNationTurns( - ctx.db, - general.nationId, - general.officerLevel, - updates, - input.expectedRevision - ) + setNationTurns(ctx.db, general.nationId, general.officerLevel, updates, input.expectedRevision) ); return { ok: true, ...snapshot }; }), diff --git a/app/game-api/src/services/readModelDeltaCache.ts b/app/game-api/src/services/readModelDeltaCache.ts new file mode 100644 index 00000000..25cdb9b6 --- /dev/null +++ b/app/game-api/src/services/readModelDeltaCache.ts @@ -0,0 +1,110 @@ +import { createHash } from 'node:crypto'; +import { createJsonPatch, type ReadModelDelta } from '@sammo-ts/common'; + +const CACHE_TTL_SECONDS = 15 * 60; +const REVISION_LENGTH = 22; + +export interface ReadModelDeltaCacheStore { + get(key: string): Promise; + set(key: string, value: string, options: { EX: number }): Promise; +} + +export interface ReadModelDeltaRequest { + store: ReadModelDeltaCacheStore; + profile: string; + viewerId: string; + slice: string; + value: T; + knownRevision?: string; + forceSnapshot?: boolean; +} + +const canonicalize = (value: unknown): unknown => { + if (value instanceof Date) { + return value.toISOString(); + } + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, canonicalize(item)]) + ); + } + return value; +}; + +const digest = (value: string): string => + createHash('sha256').update(value).digest('base64url').slice(0, REVISION_LENGTH); + +const buildScope = (profile: string, viewerId: string, slice: string): string => + digest(`${profile}\0${viewerId}\0${slice}`); + +export const buildReadModelDeltaCacheKey = ( + profile: string, + viewerId: string, + slice: string, + revision: string +): string => `sammo:${profile}:private-read-model:${buildScope(profile, viewerId, slice)}:${revision}`; + +const canPatch = (value: unknown): value is Record | unknown[] => + value !== null && typeof value === 'object'; + +const storeSnapshot = async (store: ReadModelDeltaCacheStore, key: string, serialized: string): Promise => { + try { + await store.set(key, serialized, { EX: CACHE_TTL_SECONDS }); + } catch { + // Redis is a best-effort optimization. The caller still receives a full snapshot. + } +}; + +export const createReadModelDelta = async (request: ReadModelDeltaRequest): Promise> => { + const canonicalValue = canonicalize(request.value) as T; + const serialized = JSON.stringify(canonicalValue); + const revision = digest(serialized); + const currentKey = buildReadModelDeltaCacheKey(request.profile, request.viewerId, request.slice, revision); + + if (!request.forceSnapshot && request.knownRevision === revision) { + return { kind: 'unchanged', revision }; + } + + if (!request.forceSnapshot && request.knownRevision) { + const baselineKey = buildReadModelDeltaCacheKey( + request.profile, + request.viewerId, + request.slice, + request.knownRevision + ); + try { + const baselineSerialized = await request.store.get(baselineKey); + if (baselineSerialized) { + const baseline = JSON.parse(baselineSerialized) as unknown; + if (canPatch(baseline) && canPatch(canonicalValue)) { + const operations = createJsonPatch(baseline, canonicalValue); + const patch = { + kind: 'patch' as const, + baseRevision: request.knownRevision, + revision, + operations, + }; + const snapshot = { kind: 'snapshot' as const, revision, data: canonicalValue }; + await storeSnapshot(request.store, currentKey, serialized); + return Buffer.byteLength(JSON.stringify(patch)) < Buffer.byteLength(JSON.stringify(snapshot)) + ? patch + : snapshot; + } + } + } catch { + // Corrupt/missing cache data and Redis failures recover with a full snapshot. + } + } + + await storeSnapshot(request.store, currentKey, serialized); + return { + kind: 'snapshot', + revision, + data: canonicalValue, + }; +}; diff --git a/app/game-api/test/dashboardRouter.test.ts b/app/game-api/test/dashboardRouter.test.ts new file mode 100644 index 00000000..859be381 --- /dev/null +++ b/app/game-api/test/dashboardRouter.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; + +import { applyReadModelDelta } from '@sammo-ts/common'; +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; + +import type { GameApiContext } from '../src/context.js'; +import { dashboardRouter } from '../src/router/dashboard/index.js'; + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'hwe:default', + issuedAt: '2026-08-11T00:00:00.000Z', + expiresAt: '2026-08-12T00:00:00.000Z', + sessionId: 'dashboard-delta-session', + user: { + id: 'viewer-1', + username: 'dashboard-viewer', + displayName: '대시보드 사용자', + roles: [], + }, + sanctions: {}, +}; + +const buildContext = (authenticated: boolean) => { + let generalName = '초기 장수'; + const redisValues = new Map(); + const context = { + auth: authenticated ? auth : null, + profile: { id: 'hwe', scenario: 'default', name: 'hwe:default' }, + redis: { + get: async (key: string) => redisValues.get(key) ?? null, + set: async (key: string, value: string) => { + redisValues.set(key, value); + return 'OK'; + }, + }, + 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: {}, + }), + }, + city: { findUnique: async () => null }, + nation: { findUnique: async () => null }, + worldState: { findFirst: async () => ({ config: { const: {} } }) }, + }, + } as unknown as GameApiContext; + + return { + context, + rename: (name: string) => { + generalName = name; + }, + }; +}; + +const contextOnly = { + include: { context: true, commandTable: false, boardAccess: false }, +}; + +describe('dashboardRouter.getContextBundleDelta', () => { + it('returns a snapshot, unchanged revision, and applicable patch for the authenticated viewer', async () => { + const fixture = buildContext(true); + const caller = dashboardRouter.createCaller(fixture.context); + + const initial = await caller.getContextBundleDelta({ ...contextOnly, forceSnapshot: true }); + expect(initial.context?.kind).toBe('snapshot'); + if (!initial.context || initial.context.kind !== 'snapshot') throw new Error('initial snapshot missing'); + if (!initial.context.data) throw new Error('initial general context missing'); + const initialData = initial.context.data; + const initialRevision = initial.context.revision; + + const unchanged = await caller.getContextBundleDelta({ + ...contextOnly, + known: { context: initialRevision }, + }); + expect(unchanged.context).toEqual({ kind: 'unchanged', revision: initialRevision }); + + fixture.rename('갱신된 장수'); + const changed = await caller.getContextBundleDelta({ + ...contextOnly, + known: { context: initialRevision }, + }); + expect(changed.context?.kind).toBe('patch'); + if (!changed.context) throw new Error('context delta missing'); + const applied = applyReadModelDelta(initialData, initialRevision, changed.context).data; + if (!applied) throw new Error('patched general context missing'); + expect(applied.general.name).toBe('갱신된 장수'); + expect(Buffer.byteLength(JSON.stringify(changed))).toBeLessThan(1_000); + }); + + it('rejects anonymous requests before reading dashboard data', async () => { + const fixture = buildContext(false); + await expect( + dashboardRouter.createCaller(fixture.context).getContextBundleDelta(contextOnly) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('rejects an empty bundle request', async () => { + const fixture = buildContext(true); + await expect( + dashboardRouter.createCaller(fixture.context).getContextBundleDelta({ + include: { context: false, commandTable: false, boardAccess: false }, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); +}); diff --git a/app/game-api/test/readModelDeltaCache.integration.test.ts b/app/game-api/test/readModelDeltaCache.integration.test.ts new file mode 100644 index 00000000..06d3e2c0 --- /dev/null +++ b/app/game-api/test/readModelDeltaCache.integration.test.ts @@ -0,0 +1,68 @@ +import { randomUUID } from 'node:crypto'; + +import { applyReadModelDelta } from '@sammo-ts/common'; +import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra'; +import { describe, expect, it } from 'vitest'; + +import { buildReadModelDeltaCacheKey, createReadModelDelta } from '../src/services/readModelDeltaCache.js'; + +const liveDescribe = process.env.REDIS_URL ? describe : describe.skip; + +liveDescribe('read-model delta cache with live Redis', () => { + it('stores a private expiring baseline and serves an applicable patch', async () => { + const connector = createRedisConnector(resolveRedisConfigFromEnv()); + await connector.connect(); + + const runId = process.env.CONDITIONAL_INTEGRATION_RUN_ID ?? randomUUID(); + const profile = `hwe:dashboard-delta-${runId}`; + const viewerId = `viewer-${randomUUID()}`; + const slice = 'main-command-table:7'; + const initialValue = { + general: Array.from({ length: 48 }, (_, index) => ({ + key: `command-${index}`, + name: `명령 ${index}`, + possible: true, + inputFields: [{ key: 'amount', kind: 'number', required: true }], + })), + }; + const keys: string[] = []; + + try { + const initial = await createReadModelDelta({ + store: connector.client, + profile, + viewerId, + slice, + value: initialValue, + forceSnapshot: true, + }); + const initialKey = buildReadModelDeltaCacheKey(profile, viewerId, slice, initial.revision); + keys.push(initialKey); + expect(await connector.client.get(initialKey)).not.toBeNull(); + expect(await connector.client.ttl(initialKey)).toBeGreaterThan(0); + + const nextValue = structuredClone(initialValue); + const first = nextValue.general[0]; + if (!first) throw new Error('command fixture is empty'); + first.possible = false; + const changed = await createReadModelDelta({ + store: connector.client, + profile, + viewerId, + slice, + value: nextValue, + knownRevision: initial.revision, + }); + expect(changed.kind).toBe('patch'); + expect(applyReadModelDelta(initialValue, initial.revision, changed).data).toEqual(nextValue); + expect(Buffer.byteLength(JSON.stringify(changed))).toBeLessThan(1_000); + + const changedKey = buildReadModelDeltaCacheKey(profile, viewerId, slice, changed.revision); + keys.push(changedKey); + expect(await connector.client.get(changedKey)).not.toBeNull(); + } finally { + if (keys.length > 0) await connector.client.del(keys); + await connector.disconnect(); + } + }); +}); diff --git a/app/game-api/test/readModelDeltaCache.test.ts b/app/game-api/test/readModelDeltaCache.test.ts new file mode 100644 index 00000000..eddab611 --- /dev/null +++ b/app/game-api/test/readModelDeltaCache.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; + +import { applyReadModelDelta } from '@sammo-ts/common'; + +import { + buildReadModelDeltaCacheKey, + createReadModelDelta, + type ReadModelDeltaCacheStore, +} from '../src/services/readModelDeltaCache.js'; + +class MemoryStore implements ReadModelDeltaCacheStore { + readonly values = new Map(); + setCalls = 0; + + async get(key: string): Promise { + return this.values.get(key) ?? null; + } + + async set(key: string, value: string): Promise { + this.setCalls += 1; + this.values.set(key, value); + return 'OK'; + } +} + +const largeCommandTable = () => ({ + general: Array.from({ length: 48 }, (_, index) => ({ + key: `command-${index}`, + name: `명령 ${index}`, + reqArg: index % 2 === 0, + possible: true, + status: 'available', + inputFields: [{ key: 'amount', label: '수량', type: 'number' }], + })), + nation: [], + inputOptions: { + cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })), + }, +}); + +describe('createReadModelDelta', () => { + it('reduces unchanged and one-field updates below one kilobyte', async () => { + const store = new MemoryStore(); + const initialValue = largeCommandTable(); + const initial = await createReadModelDelta({ + store, + profile: 'hwe:default', + viewerId: 'user-1', + slice: 'command-table', + value: initialValue, + forceSnapshot: true, + }); + expect(initial.kind).toBe('snapshot'); + expect(Buffer.byteLength(JSON.stringify(initial))).toBeGreaterThan(5_000); + + const unchanged = await createReadModelDelta({ + store, + profile: 'hwe:default', + viewerId: 'user-1', + slice: 'command-table', + value: initialValue, + knownRevision: initial.revision, + }); + expect(unchanged.kind).toBe('unchanged'); + expect(Buffer.byteLength(JSON.stringify(unchanged))).toBeLessThan(1_000); + expect(store.setCalls).toBe(1); + + const nextValue = structuredClone(initialValue); + const firstCommand = nextValue.general[0]; + if (!firstCommand) throw new Error('command fixture is empty'); + firstCommand.possible = false; + firstCommand.status = 'blocked'; + const changed = await createReadModelDelta({ + store, + profile: 'hwe:default', + viewerId: 'user-1', + slice: 'command-table', + value: nextValue, + knownRevision: initial.revision, + }); + expect(changed.kind).toBe('patch'); + expect(Buffer.byteLength(JSON.stringify(changed))).toBeLessThan(1_000); + expect(applyReadModelDelta(initialValue, initial.revision, changed).data).toEqual(nextValue); + }); + + it('keeps private baselines in viewer-scoped keys', () => { + expect(buildReadModelDeltaCacheKey('hwe:default', 'user-1', 'context', 'revision')).not.toBe( + buildReadModelDeltaCacheKey('hwe:default', 'user-2', 'context', 'revision') + ); + }); + + it('falls back to a snapshot when Redis is unavailable', async () => { + const store: ReadModelDeltaCacheStore = { + get: async () => { + throw new Error('redis unavailable'); + }, + set: async () => { + throw new Error('redis unavailable'); + }, + }; + const delta = await createReadModelDelta({ + store, + profile: 'hwe:default', + viewerId: 'user-1', + slice: 'context', + value: { general: { id: 1 } }, + knownRevision: 'old-revision', + }); + + expect(delta).toMatchObject({ kind: 'snapshot', data: { general: { id: 1 } } }); + }); +}); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 5e7b4a56..fe89e09d 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -5,6 +5,7 @@ import { expect, test, type Page, type Route } from '@playwright/test'; const response = (data: unknown) => ({ result: { data } }); const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR; const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR; +const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production'; const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`; const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default'; const operationNames = (route: Route) => @@ -20,6 +21,95 @@ type NavigationFixture = { operations: string[]; generalName?: string; refreshDelayMs?: number; + largeCommandTable?: boolean; + dashboardResponses?: Array<{ + bytes: number; + contextKind: string | null; + commandTableKind: string | null; + boardAccessKind: string | null; + }>; +}; + +type DashboardBundleInput = { + include?: { context?: boolean; commandTable?: boolean; boardAccess?: boolean }; + known?: { context?: string; commandTable?: string; boardAccess?: string }; + forceSnapshot?: boolean; +}; + +const operationInput = (route: Route, index: number): DashboardBundleInput => { + const input = new URL(route.request().url()).searchParams.get('input'); + if (!input) return {}; + const parsed = JSON.parse(input) as Record; + const entry = (parsed[String(index)] ?? parsed) as { json?: DashboardBundleInput }; + return entry.json ?? (entry as DashboardBundleInput); +}; + +const commandTableFixture = (large: boolean) => ({ + general: large + ? [ + { + category: '일반', + values: Array.from({ length: 48 }, (_, index) => ({ + key: `command-${index}`, + name: `명령 ${index}`, + reqArg: index % 2 === 0, + possible: true, + status: 'available', + inputFields: [ + { + key: 'amount', + label: '수량', + kind: 'number', + required: true, + min: 1, + max: 10_000, + }, + ], + })), + }, + ] + : [], + nation: [], + inputOptions: { + cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })), + nations: [], + generals: [], + crewTypes: [], + armTypes: [], + nationTypes: [], + colors: [], + items: {}, + }, +}); + +const CONTEXT_INITIAL_REVISION = 'AAAAAAAAAAAAAAAAAAAAAA'; +const COMMAND_TABLE_REVISION = 'CCCCCCCCCCCCCCCCCCCCCC'; +const BOARD_ACCESS_REVISION = 'DDDDDDDDDDDDDDDDDDDDDD'; + +const contextRevision = (state: NavigationFixture) => { + const name = state.generalName ?? '메뉴검증장수'; + if (name === '메뉴검증장수') return CONTEXT_INITIAL_REVISION; + if (name === '부드럽게갱신된장수') return 'EEEEEEEEEEEEEEEEEEEEEE'; + if (name === '탭공유갱신장수') return 'FFFFFFFFFFFFFFFFFFFFFF'; + if (name === '리더만갱신장수') return 'GGGGGGGGGGGGGGGGGGGGGG'; + return 'HHHHHHHHHHHHHHHHHHHHHH'; +}; + +const deltaSlice = (value: T, revision: string, known: string | undefined, forceSnapshot: boolean) => { + if (forceSnapshot || !known) return { kind: 'snapshot' as const, revision, data: value }; + if (known === revision) return { kind: 'unchanged' as const, revision }; + return { + kind: 'patch' as const, + baseRevision: known, + revision, + operations: [ + { + op: 'replace' as const, + path: '/general/name', + value: (value as ReturnType).general.name, + }, + ], + }; }; const emptyMessages = (permission: number) => ({ @@ -134,14 +224,51 @@ const installFixture = async (page: Page, state: NavigationFixture) => { await page.route(`**${basePath}/api/trpc/**`, async (route) => { const operations = operationNames(route); state.operations.push(...operations); - if (operations.includes('general.me') && state.generalMeCalls > 0 && state.refreshDelayMs) { + if ( + operations.some((operation) => ['general.me', 'dashboard.getContextBundleDelta'].includes(operation)) && + state.generalMeCalls > 0 && + state.refreshDelayMs + ) { await new Promise((resolve) => setTimeout(resolve, state.refreshDelayMs)); } - const results = operations.map((operation) => { + const results = operations.map((operation, index) => { if (operation === 'auth.status') return response({ ok: true }); if (operation === 'lobby.info') { return response({ myGeneral: { id: 7, name: '메뉴검증장수' }, year: 185, month: 1, turnTerm: 10 }); } + if (operation === 'dashboard.getContextBundleDelta') { + state.generalMeCalls += 1; + const input = operationInput(route, index); + const include = input.include ?? {}; + const forceSnapshot = input.forceSnapshot === true; + const revision = contextRevision(state); + const context = include.context + ? deltaSlice(generalContext(state), revision, input.known?.context, forceSnapshot) + : undefined; + const commandTable = include.commandTable + ? forceSnapshot || !input.known?.commandTable + ? { + kind: 'snapshot' as const, + revision: COMMAND_TABLE_REVISION, + data: commandTableFixture(state.largeCommandTable === true), + } + : { kind: 'unchanged' as const, revision: COMMAND_TABLE_REVISION } + : undefined; + const boardAccess = include.boardAccess + ? forceSnapshot || !input.known?.boardAccess + ? { + kind: 'snapshot' as const, + revision: BOARD_ACCESS_REVISION, + data: { + permission: state.permission, + canMeeting: state.officerLevel >= 1, + canSecret: state.permission >= 2, + }, + } + : { kind: 'unchanged' as const, revision: BOARD_ACCESS_REVISION } + : undefined; + return response({ context, commandTable, boardAccess }); + } if (operation === 'general.me') { state.generalMeCalls += 1; return response(generalContext(state)); @@ -211,6 +338,22 @@ const installFixture = async (page: Page, state: NavigationFixture) => { if (operation === 'tournament.getState') return response({ stage: state.stage }); return response({ ok: true }); }); + operations.forEach((operation, index) => { + if (operation !== 'dashboard.getContextBundleDelta') return; + const item = results[index]; + if (!item) return; + const data = item.result.data as { + context?: { kind: string }; + commandTable?: { kind: string }; + boardAccess?: { kind: string }; + }; + (state.dashboardResponses ??= []).push({ + bytes: Buffer.byteLength(JSON.stringify(item)), + contextKind: data.context?.kind ?? null, + commandTableKind: data.commandTable?.kind ?? null, + boardAccessKind: data.boardAccess?.kind ?? null, + }); + }); await route.fulfill({ status: 200, contentType: 'application/json', @@ -638,6 +781,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl generalMeCalls: 0, operations: [], refreshDelayMs: 300, + largeCommandTable: true, }; await installRealtimeHarness(page); await installFixture(page, state); @@ -739,11 +883,12 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl expect(state.generalMeCalls).toBe(callsBeforeRefresh + 1); await expect(page.locator('.general-title')).toContainText('부드럽게갱신된장수'); const changedOperations = state.operations.slice(operationsBeforeChangedBurst); - expect(changedOperations).toEqual( - expect.arrayContaining(['general.me', 'turns.getCommandTable', 'board.getAccess']) - ); + expect(changedOperations).toEqual(['dashboard.getContextBundleDelta']); expect(changedOperations).not.toEqual( expect.arrayContaining([ + 'general.me', + 'turns.getCommandTable', + 'board.getAccess', 'lobby.info', 'world.getMap', 'messages.getRecent', @@ -753,6 +898,19 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl 'turns.reserved.getGeneral', ]) ); + const initialBundle = state.dashboardResponses?.find( + (entry) => entry.contextKind === 'snapshot' && entry.commandTableKind === 'snapshot' + ); + const realtimeBundle = state.dashboardResponses?.find( + (entry) => entry.contextKind === 'patch' && entry.commandTableKind === 'unchanged' + ); + expect(initialBundle?.bytes).toBeGreaterThan(5_000); + expect(realtimeBundle).toMatchObject({ + contextKind: 'patch', + commandTableKind: 'unchanged', + boardAccessKind: 'unchanged', + }); + expect(realtimeBundle?.bytes).toBeLessThan(1_000); const operationsBeforeSurvey = state.operations.length; await page.evaluate(() => { @@ -812,8 +970,10 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl expect(profile.cityMounted).toBe(true); expect(profile.generalMutations).toBeGreaterThan(0); expect(profile.cityMutations).toBe(0); - expect(profile.vueMeasures.some((name) => name.includes('GeneralBasicCard'))).toBe(true); - expect(profile.vueMeasures.some((name) => name.includes('CityBasicCard'))).toBe(false); + if (!productionBundle) { + expect(profile.vueMeasures.some((name) => name.includes('GeneralBasicCard'))).toBe(true); + expect(profile.vueMeasures.some((name) => name.includes('CityBasicCard'))).toBe(false); + } if (autoRefreshArtifactRoot) { await Promise.all([ page.screenshot({ path: resolve(autoRefreshArtifactRoot, 'auto-refresh-complete.png'), fullPage: true }), @@ -823,6 +983,15 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl { emittedTurnEvents: 100, selectiveGeneralRefreshes: state.generalMeCalls - callsBeforeRefresh, + responseBytes: { + initialSnapshot: initialBundle?.bytes ?? null, + realtimeDelta: realtimeBundle?.bytes ?? null, + reductionPercent: + initialBundle && realtimeBundle + ? Number(((1 - realtimeBundle.bytes / initialBundle.bytes) * 100).toFixed(2)) + : null, + }, + responseKinds: realtimeBundle ?? null, inFlightSkeletons: { general: 0, city: 0 }, ...profile, }, diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 52eb8542..cf19f087 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -9,6 +9,10 @@ const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default'; const baseURL = `http://127.0.0.1:${port}${basePath}/`; const gameApiUrl = process.env.PLAYWRIGHT_GAME_API_URL ?? `${basePath}/api/trpc`; const gatewayWebUrl = process.env.PLAYWRIGHT_GATEWAY_WEB_URL ?? '/gateway/'; +const useProductionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production'; +const frontendEnv = + `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} ` + + `VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl}`; export default defineConfig({ testDir: '.', @@ -55,7 +59,9 @@ export default defineConfig({ screenshot: 'only-on-failure', }, webServer: { - command: `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl} pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${port}`, + command: useProductionBundle + ? `${frontendEnv} pnpm --filter @sammo-ts/game-frontend build && ${frontendEnv} pnpm --filter @sammo-ts/game-frontend preview --host 127.0.0.1 --port ${port}` + : `${frontendEnv} pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${port}`, cwd: repositoryRoot, url: baseURL, reuseExistingServer: false, diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 00285b62..cefa7e1e 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -1,7 +1,12 @@ import { computed, ref, toRaw, watch } from 'vue'; import { defineStore } from 'pinia'; import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType } from '@sammo-ts/logic'; -import type { RealtimeEvent, RealtimeReadModelChanges } from '@sammo-ts/common'; +import { + applyReadModelDelta, + ReadModelDeltaMismatchError, + type RealtimeEvent, + type RealtimeReadModelChanges, +} from '@sammo-ts/common'; import { trpc } from '../utils/trpc'; import { useMapViewerStore } from './mapViewer'; import { useSessionStore } from './session'; @@ -36,7 +41,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { type ReservedTurnView = Awaited>['turns'][number]; 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; + commandTableRevision?: string | null; + boardAccessRevision?: string | null; general?: PresentGeneralContext['general'] | null; city?: PresentGeneralContext['city'] | null; nation?: PresentGeneralContext['nation'] | null; @@ -87,6 +102,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { let lastWorldHistoryId = 0; let recordGeneralId: number | null = null; let initialized = false; + let contextSnapshot: GeneralContext | undefined; + let contextRevision: string | null = null; + let commandTableRevision: string | null = null; + let boardAccessRevision: string | null = null; const messageDraftText = ref(''); const targetMailbox = ref(MESSAGE_MAILBOX_PUBLIC); @@ -298,14 +317,37 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { }; const applyDashboardPatch = (patch: DashboardReadModelPatch) => { - if (patch.general === null) { + if (patch.contextSnapshot === null) { + contextSnapshot = null; general.value = null; city.value = null; nation.value = null; + commandTable.value = null; + boardAccess.value = null; reservedGeneralTurns.value = null; reservedGeneralRevision.value = 0; - boardAccess.value = null; resetRecentRecords(null); + commandTableRevision = null; + boardAccessRevision = null; + } else if (patch.contextSnapshot !== undefined) { + contextSnapshot = patch.contextSnapshot; + general.value = structurallyShare(general.value, patch.contextSnapshot.general); + city.value = structurallyShare(city.value, patch.contextSnapshot.city); + nation.value = structurallyShare(nation.value, patch.contextSnapshot.nation); + } + if (patch.general === null) { + contextSnapshot = null; + general.value = null; + city.value = null; + nation.value = null; + commandTable.value = null; + boardAccess.value = null; + reservedGeneralTurns.value = null; + reservedGeneralRevision.value = 0; + resetRecentRecords(null); + contextRevision = null; + commandTableRevision = null; + boardAccessRevision = null; } else if (patch.general !== undefined) { general.value = structurallyShare(general.value, patch.general); } @@ -350,10 +392,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } else if (patch.frontStatus !== undefined) { updateFrontStatus(patch.frontStatus); } + if (patch.contextRevision !== undefined) contextRevision = patch.contextRevision; + if (patch.commandTableRevision !== undefined) commandTableRevision = patch.commandTableRevision; + if (patch.boardAccessRevision !== undefined) boardAccessRevision = patch.boardAccessRevision; }; const currentDashboardPatch = (): DashboardReadModelPatch => { const patch: DashboardReadModelPatch = {}; + patch.contextSnapshot = toRaw(contextSnapshot); + patch.contextRevision = contextRevision; + patch.commandTableRevision = commandTableRevision; + patch.boardAccessRevision = boardAccessRevision; patch.general = toRaw(general.value); patch.city = toRaw(city.value); patch.nation = toRaw(nation.value); @@ -373,6 +422,64 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { return patch; }; + const resolveContextBundlePatch = (bundle: ContextBundleDelta): DashboardReadModelPatch => { + const patch: DashboardReadModelPatch = {}; + + if (bundle.context) { + const applied = applyReadModelDelta(contextSnapshot, contextRevision, bundle.context); + patch.contextRevision = applied.revision; + if (bundle.context.kind !== 'unchanged') { + patch.contextSnapshot = applied.data; + } + } + if (bundle.commandTable) { + const current = commandTable.value === null ? undefined : toRaw(commandTable.value); + const applied = applyReadModelDelta(current, commandTableRevision, bundle.commandTable); + patch.commandTableRevision = applied.revision; + if (bundle.commandTable.kind !== 'unchanged') { + patch.commandTable = applied.data; + } + } + if (bundle.boardAccess) { + const current = boardAccess.value === null ? undefined : toRaw(boardAccess.value); + const applied = applyReadModelDelta(current, boardAccessRevision, bundle.boardAccess); + patch.boardAccessRevision = applied.revision; + if (bundle.boardAccess.kind !== 'unchanged') { + patch.boardAccess = applied.data; + } + } + + return patch; + }; + + const fetchContextBundlePatch = async ( + include: ContextBundleInclude, + forceSnapshot = false + ): Promise => { + const request = (force: boolean) => + trpc.dashboard.getContextBundleDelta.query({ + include, + known: force + ? undefined + : { + ...(contextRevision ? { context: contextRevision } : {}), + ...(commandTableRevision ? { commandTable: commandTableRevision } : {}), + ...(boardAccessRevision ? { boardAccess: boardAccessRevision } : {}), + }, + forceSnapshot: force || undefined, + }); + + const bundle = await request(forceSnapshot); + try { + return resolveContextBundlePatch(bundle); + } catch (error) { + if (forceSnapshot || !(error instanceof ReadModelDeltaMismatchError)) { + throw error; + } + return resolveContextBundlePatch(await request(true)); + } + }; + const refreshMainData = async () => { const isInitialLoad = !initialized; if (isInitialLoad) { @@ -385,16 +492,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { frontStatusError.value = null; try { - const context = await trpc.general.me.query(); + const contextPatch = await fetchContextBundlePatch( + { context: true, commandTable: true, boardAccess: true }, + true + ); + applyDashboardPatch(contextPatch); + const context = contextSnapshot; if (!context) { - general.value = null; - city.value = null; - nation.value = null; - reservedGeneralTurns.value = null; - reservedGeneralRevision.value = 0; - boardAccess.value = null; - resetRecentRecords(null); initialized = true; return; } @@ -418,29 +523,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { frontStatusError.value = resolveErrorMessage(err); return null; }); - const [ - layout, - lobby, - map, - commands, - messageData, - contacts, - access, - generalTurns, - records, - nextFrontStatus, - ] = await Promise.all([ - layoutPromise, - trpc.lobby.info.query(), - trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }), - trpc.turns.getCommandTable.query({ generalId: id }), - trpc.messages.getRecent.query({ generalId: id }), - trpc.messages.getContacts.query({ generalId: id }), - trpc.board.getAccess.query(), - generalTurnsPromise, - recordsPromise, - frontStatusPromise, - ]); + const [layout, lobby, map, messageData, contacts, generalTurns, records, nextFrontStatus] = + await Promise.all([ + layoutPromise, + trpc.lobby.info.query(), + trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }), + trpc.messages.getRecent.query({ generalId: id }), + trpc.messages.getContacts.query({ generalId: id }), + generalTurnsPromise, + recordsPromise, + frontStatusPromise, + ]); general.value = structurallyShare(general.value, context.general); city.value = structurallyShare(city.value, context.city); @@ -448,10 +541,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { mapLayout.value = structurallyShare(mapLayout.value, layout); lobbyInfo.value = structurallyShare(lobbyInfo.value, lobby); worldMap.value = structurallyShare(worldMap.value, map); - commandTable.value = structurallyShare(commandTable.value, commands); messages.value = structurallyShare(messages.value, messageData); messageContacts.value = structurallyShare(messageContacts.value, contacts); - boardAccess.value = structurallyShare(boardAccess.value, access); reservedGeneralTurns.value = structurallyShare( reservedGeneralTurns.value, generalTurns.turns @@ -517,20 +608,21 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { if (plan.records) recordsError.value = null; if (plan.frontStatus) frontStatusError.value = null; try { - const contextPromise = plan.context - ? trpc.general.me.query() - : Promise.resolve(undefined as GeneralContext | undefined); + const contextBundlePromise = + plan.context || plan.commands || plan.boardAccess + ? fetchContextBundlePatch({ + context: plan.context, + commandTable: plan.commands, + boardAccess: plan.boardAccess, + }) + : Promise.resolve(undefined); 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 }) : Promise.resolve(undefined); - const commandsPromise = plan.commands - ? trpc.turns.getCommandTable.query({ generalId: id }) - : Promise.resolve(undefined); const contactsPromise = plan.contacts ? trpc.messages.getContacts.query({ generalId: id }) : Promise.resolve(undefined); - const boardPromise = plan.boardAccess ? trpc.board.getAccess.query() : Promise.resolve(undefined); const reservedPromise = plan.reservedTurns ? trpc.turns.reserved.getGeneral.query({ generalId: id }) : Promise.resolve(undefined); @@ -549,32 +641,20 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { }) : Promise.resolve(undefined); - const [context, lobby, map, commands, contacts, access, generalTurns, records, nextFrontStatus] = - await Promise.all([ - contextPromise, - lobbyPromise, - mapPromise, - commandsPromise, - contactsPromise, - boardPromise, - reservedPromise, - recordsPromise, - frontPromise, - ]); + const [contextPatch, lobby, map, contacts, generalTurns, records, nextFrontStatus] = await Promise.all([ + contextBundlePromise, + lobbyPromise, + mapPromise, + contactsPromise, + reservedPromise, + recordsPromise, + frontPromise, + ]); - const patch: DashboardReadModelPatch = {}; - if (context === null) { - patch.general = null; - } else if (context !== undefined) { - patch.general = context.general; - patch.city = context.city; - patch.nation = context.nation; - } + const patch: DashboardReadModelPatch = contextPatch ? { ...contextPatch } : {}; if (lobby !== undefined) patch.lobbyInfo = lobby; if (map !== undefined) patch.worldMap = map; - if (commands !== undefined) patch.commandTable = commands; if (contacts !== undefined) patch.messageContacts = contacts; - if (access !== undefined) patch.boardAccess = access; if (generalTurns !== undefined) { patch.reservedGeneralTurns = generalTurns.turns; patch.reservedGeneralRevision = generalTurns.revision; diff --git a/packages/common/package.json b/packages/common/package.json index 8f767968..dc314c63 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -29,7 +29,8 @@ }, "dependencies": { "@noble/hashes": "^2.0.1", - "es-toolkit": "^1.43.0" + "es-toolkit": "^1.43.0", + "rfc6902": "^5.3.0" }, "devDependencies": { "tsdown": "^0.22.14", diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 5fd12ff5..dd0afbad 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -16,6 +16,7 @@ export * from './tournament/autoStart.js'; export * from './turnDaemon/types.js'; export * from './realtime/keys.js'; export * from './realtime/types.js'; +export * from './realtime/delta.js'; export * from './ranking/types.js'; export * from './ranking/legacyColor.js'; export * from './auth/accountIconProjection.js'; diff --git a/packages/common/src/realtime/delta.ts b/packages/common/src/realtime/delta.ts new file mode 100644 index 00000000..69b0cfa9 --- /dev/null +++ b/packages/common/src/realtime/delta.ts @@ -0,0 +1,88 @@ +import { applyPatch, createPatch, type Operation } from 'rfc6902'; + +export interface JsonPatchOperation { + op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test'; + path: string; + from?: string; + value?: unknown; +} + +export type ReadModelDelta = + | { + kind: 'snapshot'; + revision: string; + data: T; + } + | { + kind: 'unchanged'; + revision: string; + } + | { + kind: 'patch'; + baseRevision: string; + revision: string; + operations: JsonPatchOperation[]; + }; + +export class ReadModelDeltaMismatchError extends Error { + constructor(message: string) { + super(message); + this.name = 'ReadModelDeltaMismatchError'; + } +} + +export interface AppliedReadModelDelta { + data: T; + revision: string; +} + +const cloneJsonValue = (value: T): T => structuredClone(value); + +export const createJsonPatch = (current: unknown, next: unknown): JsonPatchOperation[] => createPatch(current, next); + +export const applyReadModelDelta = ( + current: T | undefined, + currentRevision: string | null, + delta: ReadModelDelta +): AppliedReadModelDelta => { + if (delta.kind === 'snapshot') { + return { + data: delta.data, + revision: delta.revision, + }; + } + + if (current === undefined || currentRevision === null) { + throw new ReadModelDeltaMismatchError('A delta cannot be applied before the initial snapshot.'); + } + + if (delta.kind === 'unchanged') { + if (currentRevision !== delta.revision) { + throw new ReadModelDeltaMismatchError( + `Unchanged revision mismatch: have ${currentRevision}, received ${delta.revision}.` + ); + } + return { + data: current, + revision: currentRevision, + }; + } + + if (currentRevision !== delta.baseRevision) { + throw new ReadModelDeltaMismatchError( + `Patch base revision mismatch: have ${currentRevision}, expected ${delta.baseRevision}.` + ); + } + + const next = cloneJsonValue(current); + const errors = applyPatch(next, delta.operations as Operation[]); + const failure = errors.find((error) => error !== null); + if (failure) { + throw new ReadModelDeltaMismatchError(`JSON Patch application failed: ${failure.message}`); + } + + return { + data: next, + revision: delta.revision, + }; +}; diff --git a/packages/common/test/realtimeDelta.test.ts b/packages/common/test/realtimeDelta.test.ts new file mode 100644 index 00000000..a5177c02 --- /dev/null +++ b/packages/common/test/realtimeDelta.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { applyReadModelDelta, ReadModelDeltaMismatchError } from '../src/realtime/delta.js'; + +describe('applyReadModelDelta', () => { + it('applies a JSON Patch without mutating the previous snapshot', () => { + const current = { + general: [{ key: '휴식', possible: true, status: 'available' }], + inputOptions: { cities: [{ value: 1, label: '업' }] }, + }; + + const applied = applyReadModelDelta(current, 'revision-1', { + kind: 'patch', + baseRevision: 'revision-1', + revision: 'revision-2', + operations: [{ op: 'replace', path: '/general/0/possible', value: false }], + }); + + expect(applied).toEqual({ + revision: 'revision-2', + data: { + general: [{ key: '휴식', possible: false, status: 'available' }], + inputOptions: { cities: [{ value: 1, label: '업' }] }, + }, + }); + expect(current.general[0]?.possible).toBe(true); + }); + + it('keeps the current object for an unchanged revision', () => { + const current = { value: 1 }; + const applied = applyReadModelDelta(current, 'revision-1', { + kind: 'unchanged', + revision: 'revision-1', + }); + + expect(applied.data).toBe(current); + }); + + it('rejects a patch based on a different snapshot', () => { + expect(() => + applyReadModelDelta({ value: 1 }, 'revision-2', { + kind: 'patch', + baseRevision: 'revision-1', + revision: 'revision-3', + operations: [{ op: 'replace', path: '/value', value: 2 }], + }) + ).toThrow(ReadModelDeltaMismatchError); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f4c54c0..6afabd95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -420,6 +420,9 @@ importers: es-toolkit: specifier: ^1.43.0 version: 1.43.0 + rfc6902: + specifier: ^5.3.0 + version: 5.3.0 devDependencies: tsdown: specifier: ^0.22.14 @@ -4186,6 +4189,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfc6902@5.3.0: + resolution: {integrity: sha512-8x9uqKB5FeC3jhdtmBtI2Z2GR0+VE1VLbQ7Luy5RcnCxUa4+Z1n8w2G1D+GevkRUT33nbbfpAK5O4LhRbnaIPQ==} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -8276,6 +8282,8 @@ snapshots: reusify@1.1.0: {} + rfc6902@5.3.0: {} + rfdc@1.4.1: {} rolldown-plugin-dts@0.27.13(@volar/typescript@2.4.27)(rolldown@1.2.0)(typescript@6.0.2)(vue-tsc@3.2.2(typescript@6.0.2)):