From 5f20413552c92bfa9e71e24931f7aa869d623fbc Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 11:21:08 +0000 Subject: [PATCH] feat: synchronize account icons across game profiles --- .env.example | 6 + app/game-api/src/auth/accountIconSource.ts | 106 +++ app/game-api/src/auth/flushStore.ts | 28 +- app/game-api/src/config.ts | 36 +- app/game-api/src/context.ts | 4 + app/game-api/src/router/auth/index.ts | 23 +- app/game-api/src/router/general/index.ts | 24 +- app/game-api/src/router/join/index.ts | 11 +- app/game-api/src/server.ts | 128 +++- .../services/accountIconResetReconciler.ts | 238 +++++++ app/game-api/src/services/accountIconSync.ts | 146 +++++ .../src/services/bestEffortResourceCloser.ts | 40 ++ app/game-api/src/trpc.ts | 1 + .../test/accountIconResetFlush.test.ts | 52 ++ ...untIconResetReconciler.integration.test.ts | 358 ++++++++++ .../test/accountIconResetReconciler.test.ts | 130 ++++ app/game-api/test/accountIconSource.test.ts | 115 ++++ .../test/bestEffortResourceCloser.test.ts | 55 ++ app/game-api/test/config.test.ts | 18 + .../test/createGeneral.integration.test.ts | 12 + app/game-api/test/flushStore.test.ts | 68 ++ app/game-api/test/router.test.ts | 244 ++++++- app/game-engine/src/turn/commandRegistry.ts | 22 + .../src/turn/joinCreateGeneralService.ts | 8 + .../src/turn/worldCommandHandler.ts | 84 ++- .../test/accountIconCommand.test.ts | 199 ++++++ .../test/accountIconCommandRegistry.test.ts | 71 ++ ...accountIconPersistence.integration.test.ts | 460 +++++++++++++ .../gatewayRuntimeAction.integration.test.ts | 17 +- app/game-frontend/e2e/directoryLists.spec.ts | 67 +- .../e2e/selectGeneralLive.spec.ts | 6 +- app/game-frontend/package.json | 3 +- .../src/components/main/MessagePlate.vue | 21 +- app/game-frontend/src/utils/generalIcon.ts | 74 +++ .../src/views/BestGeneralView.vue | 36 +- app/game-frontend/src/views/BoardView.vue | 11 +- .../src/views/CurrentCityView.vue | 14 +- .../src/views/GeneralListView.vue | 23 +- .../src/views/HallOfFameView.vue | 15 +- app/game-frontend/src/views/JoinView.vue | 21 +- app/game-frontend/src/views/MyPageView.vue | 6 +- .../src/views/NationPersonnelView.vue | 8 +- .../src/views/SelectGeneralView.vue | 51 +- app/game-frontend/src/views/TroopView.vue | 7 +- app/game-frontend/test/generalIcon.test.ts | 61 ++ app/gateway-api/src/account/router.ts | 89 ++- app/gateway-api/src/adminRouter.ts | 46 +- .../src/auth/accountIconInternalRoute.ts | 86 +++ .../src/auth/accountIconProjection.ts | 18 + app/gateway-api/src/auth/flushPublisher.ts | 6 +- .../src/auth/inMemoryUserRepository.ts | 55 ++ .../src/auth/postgresUserRepository.ts | 87 ++- app/gateway-api/src/auth/userRepository.ts | 13 +- app/gateway-api/src/config.ts | 8 +- .../src/orchestrator/gatewayOrchestrator.ts | 2 + .../src/orchestrator/orchestratorFactory.ts | 1 + app/gateway-api/src/router.ts | 8 +- app/gateway-api/src/server.ts | 5 + .../accountIconDailyCas.integration.test.ts | 157 +++++ .../test/accountIconInternalRoute.test.ts | 103 +++ app/gateway-api/test/adminOperations.test.ts | 43 +- app/gateway-api/test/authFlow.test.ts | 203 +++++- .../test/orchestratorOperations.test.ts | 1 + app/gateway-api/test/orchestratorPlan.test.ts | 2 + .../test/orchestratorWorkspaceCleanup.test.ts | 1 + .../test/passwordCredential.test.ts | 4 +- .../e2e/account-icon-sync.spec.ts | 616 ++++++++++++++++++ .../e2e/lobby-game-auth.spec.ts | 3 +- .../e2e/playwright.config.mjs | 3 +- app/gateway-frontend/index.html | 1 + app/gateway-frontend/package.json | 2 +- app/gateway-frontend/src/assets/main.css | 1 + .../src/views/AccountView.vue | 569 +++++++++++++++- app/gateway-frontend/src/views/AdminView.vue | 17 +- app/gateway-frontend/src/views/LobbyView.vue | 15 +- .../common/src/auth/accountIconProjection.ts | 73 +++ packages/common/src/auth/gameToken.ts | 9 +- packages/common/src/index.ts | 1 + packages/common/src/turnDaemon/types.ts | 21 + .../test/account-icon-projection.test.ts | 44 ++ packages/common/test/game-token.test.ts | 47 ++ packages/infra/package.json | 1 + .../migration.sql | 42 ++ packages/infra/prisma/gateway.prisma | 2 + .../scripts/verify-account-icon-migration.sh | 282 ++++++++ .../measure-ref-account-icon-modal.mjs | 209 ++++++ tools/run-conditional-integration.sh | 11 + 87 files changed, 5755 insertions(+), 280 deletions(-) create mode 100644 app/game-api/src/auth/accountIconSource.ts create mode 100644 app/game-api/src/services/accountIconResetReconciler.ts create mode 100644 app/game-api/src/services/accountIconSync.ts create mode 100644 app/game-api/src/services/bestEffortResourceCloser.ts create mode 100644 app/game-api/test/accountIconResetFlush.test.ts create mode 100644 app/game-api/test/accountIconResetReconciler.integration.test.ts create mode 100644 app/game-api/test/accountIconResetReconciler.test.ts create mode 100644 app/game-api/test/accountIconSource.test.ts create mode 100644 app/game-api/test/bestEffortResourceCloser.test.ts create mode 100644 app/game-api/test/flushStore.test.ts create mode 100644 app/game-engine/test/accountIconCommand.test.ts create mode 100644 app/game-engine/test/accountIconCommandRegistry.test.ts create mode 100644 app/game-engine/test/accountIconPersistence.integration.test.ts create mode 100644 app/game-frontend/src/utils/generalIcon.ts create mode 100644 app/game-frontend/test/generalIcon.test.ts create mode 100644 app/gateway-api/src/auth/accountIconInternalRoute.ts create mode 100644 app/gateway-api/src/auth/accountIconProjection.ts create mode 100644 app/gateway-api/test/accountIconDailyCas.integration.test.ts create mode 100644 app/gateway-api/test/accountIconInternalRoute.test.ts create mode 100644 app/gateway-frontend/e2e/account-icon-sync.spec.ts create mode 100644 packages/common/src/auth/accountIconProjection.ts create mode 100644 packages/common/test/account-icon-projection.test.ts create mode 100644 packages/common/test/game-token.test.ts create mode 100644 packages/infra/prisma/gateway-migrations/20260731001000_add_account_icon_revision/migration.sql create mode 100644 packages/infra/scripts/verify-account-icon-migration.sh create mode 100644 tools/frontend-legacy-parity/scripts/measure-ref-account-icon-modal.mjs diff --git a/.env.example b/.env.example index 46353e7..6fe4ecf 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,10 @@ GAME_TOKEN_SECRET=change-me # Gateway API GATEWAY_API_HOST=0.0.0.0 GATEWAY_API_PORT=13000 +# Internal projection-only API used by profile Game APIs. Defaults to the local Gateway API port. +GATEWAY_INTERNAL_API_URL=http://127.0.0.1:13000 +# 실행 중 Game API가 누락된 관리자 아이콘 초기화를 Gateway 원장에서 다시 확인하는 주기입니다(최소 1000ms). +ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS=30000 GATEWAY_PUBLIC_URL=http://localhost:13000 GATEWAY_REDIS_PREFIX=sammo:gateway GATEWAY_DB_SCHEMA=public @@ -52,6 +56,8 @@ TRPC_PATH=/trpc # Frontend public URLs # These values are public and become part of the browser bundle. VITE_GATEWAY_WEB_URL=/gateway/ +# Account-uploaded icons are served by the Gateway and remain a public browser URL. +VITE_GATEWAY_USER_ICON_BASE_URL=/gateway/api/user-icons VITE_BOARD_COMMUNITY_URL=/xe/community # Set the remaining links only after their public routes are confirmed. VITE_BOARD_REQUEST_URL= diff --git a/app/game-api/src/auth/accountIconSource.ts b/app/game-api/src/auth/accountIconSource.ts new file mode 100644 index 0000000..8d4235e --- /dev/null +++ b/app/game-api/src/auth/accountIconSource.ts @@ -0,0 +1,106 @@ +import { isCanonicalIsoTimestamp, parseAccountIconProjection, type AccountIconProjection } from '@sammo-ts/common'; +import { createHmac } from 'node:crypto'; + +const INTERNAL_TOKEN_CONTEXT = 'sammo:account-icon-source:v1'; + +export interface AccountIconSource { + get(userId: string): Promise; +} + +export interface AccountIconResetProjection { + userId: string; + resetRevision: string; + current: AccountIconProjection; +} + +export interface AccountIconResetSource { + listResets(userIds: string[]): Promise; +} + +const deriveInternalToken = (secret: string): string => + createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex'); + +const parseResetProjection = (value: unknown): AccountIconResetProjection => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('invalid account icon reset projection'); + } + const record = value as Record; + if (Object.keys(record).sort().join(',') !== 'current,resetRevision,userId') { + throw new Error('invalid account icon reset projection'); + } + if ( + typeof record.userId !== 'string' || + typeof record.resetRevision !== 'string' || + !isCanonicalIsoTimestamp(record.resetRevision) + ) { + throw new Error('invalid account icon reset projection'); + } + return { + userId: record.userId, + resetRevision: record.resetRevision, + current: parseAccountIconProjection(record.current), + }; +}; + +export class GatewayHttpAccountIconSource implements AccountIconSource, AccountIconResetSource { + private readonly baseUrl: string; + + constructor( + baseUrl: string, + private readonly secret: string, + private readonly timeoutMs = 2_000 + ) { + this.baseUrl = baseUrl.replace(/\/$/, ''); + } + + async get(userId: string): Promise { + const response = await fetch(`${this.baseUrl}/internal/account-icons/${encodeURIComponent(userId)}`, { + headers: { + 'x-sammo-internal-token': deriveInternalToken(this.secret), + }, + signal: AbortSignal.timeout(this.timeoutMs), + }); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(`Gateway account icon request failed with HTTP ${response.status}.`); + } + return parseAccountIconProjection(await response.json()); + } + + async listResets(userIds: string[]): Promise { + if (userIds.length === 0) { + return []; + } + const response = await fetch(`${this.baseUrl}/internal/account-icon-resets`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-sammo-internal-token': deriveInternalToken(this.secret), + }, + body: JSON.stringify({ userIds }), + signal: AbortSignal.timeout(this.timeoutMs), + }); + if (!response.ok) { + throw new Error(`Gateway account icon reset request failed with HTTP ${response.status}.`); + } + const payload = (await response.json()) as unknown; + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('invalid account icon reset response'); + } + const record = payload as Record; + if (Object.keys(record).sort().join(',') !== 'resets' || !Array.isArray(record.resets)) { + throw new Error('invalid account icon reset response'); + } + const resets = record.resets.map(parseResetProjection); + const requested = new Set(userIds); + if ( + resets.some((reset) => !requested.has(reset.userId)) || + new Set(resets.map((reset) => reset.userId)).size !== resets.length + ) { + throw new Error('invalid account icon reset response'); + } + return resets; + } +} diff --git a/app/game-api/src/auth/flushStore.ts b/app/game-api/src/auth/flushStore.ts index d2708b2..594590f 100644 --- a/app/game-api/src/auth/flushStore.ts +++ b/app/game-api/src/auth/flushStore.ts @@ -2,6 +2,7 @@ export interface GatewayUserFlushEvent { userId: string; flushedAt: string; reason?: string; + iconRevision?: string; } export interface FlushStore { @@ -35,6 +36,9 @@ export class RedisGatewayFlushSubscriber { }; private readonly channel: string; private readonly store: FlushStore; + private readonly onFlush?: (event: GatewayUserFlushEvent) => Promise | void; + private readonly onFlushError?: (error: unknown, event: GatewayUserFlushEvent) => void; + private readonly pendingFlushes = new Set>(); constructor( client: { @@ -42,11 +46,15 @@ export class RedisGatewayFlushSubscriber { unsubscribe: (channel: string) => Promise; }, channel: string, - store: FlushStore + store: FlushStore, + onFlush?: (event: GatewayUserFlushEvent) => Promise | void, + onFlushError?: (error: unknown, event: GatewayUserFlushEvent) => void ) { this.client = client; this.channel = channel; this.store = store; + this.onFlush = onFlush; + this.onFlushError = onFlushError; } async start(): Promise { @@ -57,6 +65,23 @@ export class RedisGatewayFlushSubscriber { return; } this.store.applyFlush(payload); + if (this.onFlush) { + let flush: Promise; + try { + flush = Promise.resolve(this.onFlush(payload)); + } catch (error) { + this.onFlushError?.(error, payload); + return; + } + const tracked = flush + .catch((error: unknown) => { + this.onFlushError?.(error, payload); + }) + .finally(() => { + this.pendingFlushes.delete(tracked); + }); + this.pendingFlushes.add(tracked); + } } catch { return; } @@ -65,5 +90,6 @@ export class RedisGatewayFlushSubscriber { async stop(): Promise { await this.client.unsubscribe(this.channel); + await Promise.all(this.pendingFlushes); } } diff --git a/app/game-api/src/config.ts b/app/game-api/src/config.ts index 5ab333e..45e51fd 100644 --- a/app/game-api/src/config.ts +++ b/app/game-api/src/config.ts @@ -1,5 +1,13 @@ import { parseNumberWithFallback } from '@sammo-ts/common'; +const parseReconcileInterval = (value: string | undefined): number => { + const parsed = parseNumberWithFallback(value, 30_000, 'ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS'); + if (!Number.isSafeInteger(parsed) || parsed < 1_000) { + throw new Error('ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS must be an integer of at least 1000.'); + } + return parsed; +}; + export interface GameApiConfig { host: string; port: number; @@ -19,6 +27,8 @@ export interface GameApiConfig { auctionTimerRetentionSeconds: number; tournamentPollMs: number; gameTokenSecret: string; + gatewayInternalApiUrl: string; + accountIconResetReconcileIntervalMs: number; flushChannel: string; } @@ -43,7 +53,11 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env profile, scenario, profileName, - daemonRequestTimeoutMs: parseNumberWithFallback(env.DAEMON_REQUEST_TIMEOUT_MS, 5000, 'DAEMON_REQUEST_TIMEOUT_MS'), + daemonRequestTimeoutMs: parseNumberWithFallback( + env.DAEMON_REQUEST_TIMEOUT_MS, + 5000, + 'DAEMON_REQUEST_TIMEOUT_MS' + ), battleSimRequestTimeoutMs: parseNumberWithFallback( env.BATTLE_SIM_REQUEST_TIMEOUT_MS, 8000, @@ -54,27 +68,17 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env 60, 'BATTLE_SIM_RESULT_TTL_SECONDS' ), - auctionTimerPollMs: parseNumberWithFallback( - env.AUCTION_TIMER_POLL_MS, - 1000, - 'AUCTION_TIMER_POLL_MS' - ), - auctionTimerResyncMs: parseNumberWithFallback( - env.AUCTION_TIMER_RESYNC_MS, - 300000, - 'AUCTION_TIMER_RESYNC_MS' - ), + auctionTimerPollMs: parseNumberWithFallback(env.AUCTION_TIMER_POLL_MS, 1000, 'AUCTION_TIMER_POLL_MS'), + auctionTimerResyncMs: parseNumberWithFallback(env.AUCTION_TIMER_RESYNC_MS, 300000, 'AUCTION_TIMER_RESYNC_MS'), auctionTimerRetentionSeconds: parseNumberWithFallback( env.AUCTION_TIMER_RETENTION_SECONDS, 21600, 'AUCTION_TIMER_RETENTION_SECONDS' ), - tournamentPollMs: parseNumberWithFallback( - env.TOURNAMENT_POLL_MS, - 1000, - 'TOURNAMENT_POLL_MS' - ), + tournamentPollMs: parseNumberWithFallback(env.TOURNAMENT_POLL_MS, 1000, 'TOURNAMENT_POLL_MS'), gameTokenSecret: secret, + gatewayInternalApiUrl: env.GATEWAY_INTERNAL_API_URL ?? 'http://127.0.0.1:13000', + accountIconResetReconcileIntervalMs: parseReconcileInterval(env.ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS), flushChannel: `${gatewayPrefix}:flush`, }; }; diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index ba561d7..45a8306 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -7,6 +7,7 @@ import type { TurnDaemonTransport } from './daemon/transport.js'; import type { BattleSimTransport } from './battleSim/transport.js'; import type { FlushStore } from './auth/flushStore.js'; import type { RedisAccessTokenStore } from './auth/accessTokenStore.js'; +import type { AccountIconSource } from './auth/accountIconSource.js'; export interface GameProfile { id: string; @@ -84,6 +85,7 @@ export interface GameApiContext { accessTokenStore: RedisAccessTokenStore; flushStore: FlushStore; gameTokenSecret: string; + accountIconSource?: AccountIconSource; } export const createGameApiContext = (options: { @@ -100,6 +102,7 @@ export const createGameApiContext = (options: { accessTokenStore: RedisAccessTokenStore; flushStore: FlushStore; gameTokenSecret: string; + accountIconSource?: AccountIconSource; }): GameApiContext => { return { requestId: options.requestId, @@ -116,5 +119,6 @@ export const createGameApiContext = (options: { accessTokenStore: options.accessTokenStore, flushStore: options.flushStore, gameTokenSecret: options.gameTokenSecret, + ...(options.accountIconSource ? { accountIconSource: options.accountIconSource } : {}), }; }; diff --git a/app/game-api/src/router/auth/index.ts b/app/game-api/src/router/auth/index.ts index b97db67..9a3bbe6 100644 --- a/app/game-api/src/router/auth/index.ts +++ b/app/game-api/src/router/auth/index.ts @@ -5,7 +5,8 @@ import { isAfter, isValid, parseISO } from 'date-fns'; import { z } from 'zod'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; -import { authedProcedure, procedure, router } from '../../trpc.js'; +import { authedProcedure, engineProcedure, router } from '../../trpc.js'; +import { enqueueProfileIconResetForUser } from '../../services/accountIconSync.js'; const parseDate = (value: string): Date | null => { const parsed = parseISO(value); @@ -21,11 +22,7 @@ const resolveTtlSeconds = (expiresAt: string): number => { return ttl > 0 ? ttl : 0; }; -const verifyGatewayToken = ( - token: string, - profileName: string, - secret: string -): GameSessionTokenPayload | null => { +const verifyGatewayToken = (token: string, profileName: string, secret: string): GameSessionTokenPayload | null => { const payload = decryptGameSessionToken(token, secret); if (!payload) { return null; @@ -52,14 +49,10 @@ export const authRouter = router({ } return { userId }; }), - exchangeGatewayToken: procedure + exchangeGatewayToken: engineProcedure .input(z.object({ gatewayToken: z.string().min(1) })) .mutation(async ({ ctx, input }) => { - const payload = verifyGatewayToken( - input.gatewayToken, - ctx.profile.name, - ctx.gameTokenSecret - ); + const payload = verifyGatewayToken(input.gatewayToken, ctx.profile.name, ctx.gameTokenSecret); if (!payload) { throw new TRPCError({ code: 'UNAUTHORIZED', @@ -88,6 +81,12 @@ export const authRouter = router({ }); } + if (payload.user.profileIconResetAt && payload.user.profileIconResetAt === payload.user.iconUpdatedAt) { + // 일반 계정 아이콘 변경은 Ref처럼 사용자가 고른 서버에만 적용한다. + // 관리자 reset만 다음 인증 경계에서 durable하게 복구한다. + await enqueueProfileIconResetForUser(ctx, payload.user.id, payload.user.profileIconResetAt); + } + const used = await ctx.accessTokenStore.markGatewayTokenUsed(payload.sessionId, ttlSeconds); if (!used) { throw new TRPCError({ diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 9a7600c..b24b6fd 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -11,10 +11,12 @@ import { accessEngineAuthedProcedure, accessEngineAuthedInputProcedure, authedProcedure, + engineAuthedProcedure, router, } from '../../trpc.js'; import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js'; import { resolveAccessWindows } from '../../services/generalAccess.js'; +import { adjustAccountIconForUser } from '../../services/accountIconSync.js'; import { getMyGeneral } from '../shared/general.js'; import { resolveNationNotice } from '../nation/shared.js'; @@ -169,6 +171,13 @@ const resolvePenalty = (penalty: unknown): Record => { }; export const generalRouter = router({ + adjustIcon: engineAuthedProcedure.mutation(({ ctx }) => { + const userId = ctx.auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + return adjustAccountIconForUser(ctx, userId); + }), me: authedProcedure.query(async ({ ctx }) => { const userId = ctx.auth?.user.id; if (!userId) { @@ -326,12 +335,15 @@ export const generalRouter = router({ availableAt: result.availableAt ?? null, }; }), - dieOnPrestart: accessEngineAuthedInputProcedure(zImmediateActionInput) - .mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'dieOnPrestart')), - buildNationCandidate: accessEngineAuthedInputProcedure(zImmediateActionInput) - .mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'buildNationCandidate')), - instantRetreat: accessEngineAuthedInputProcedure(zImmediateActionInput) - .mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'instantRetreat')), + dieOnPrestart: accessEngineAuthedInputProcedure(zImmediateActionInput).mutation(({ ctx, input }) => + requestImmediateAction(ctx, input, 'dieOnPrestart') + ), + buildNationCandidate: accessEngineAuthedInputProcedure(zImmediateActionInput).mutation(({ ctx, input }) => + requestImmediateAction(ctx, input, 'buildNationCandidate') + ), + instantRetreat: accessEngineAuthedInputProcedure(zImmediateActionInput).mutation(({ ctx, input }) => + requestImmediateAction(ctx, input, 'instantRetreat') + ), vacation: authedProcedure.mutation(async ({ ctx }) => { const general = await getMyGeneral(ctx); const result = await ctx.turnDaemon.requestCommand({ diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index d7c962a..79d9bad 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -14,6 +14,7 @@ import { WAR_TRAIT_KEYS, } from '@sammo-ts/logic'; import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js'; +import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js'; import { getSelectionPoolStatus, reserveSelectionPool, resolveSelectionMaxGeneral } from '../../services/selectPool.js'; import { ConflictingTurnDaemonCommandError, @@ -465,6 +466,7 @@ export const joinRouter = router({ }); } const userId = auth.user.id; + const accountIcon = input.pic ? await loadAuthoritativeAccountIcon(ctx, userId) : null; const commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId); const result = await requestJoinCreateCommand(ctx, { type: 'joinCreateGeneral', @@ -479,8 +481,13 @@ export const joinRouter = router({ pic: input.pic, character: input.character, profileId: ctx.profile.id, - ...(auth.user.picture !== undefined ? { ownerPicture: auth.user.picture } : {}), - ...(auth.user.imageServer !== undefined ? { ownerImageServer: auth.user.imageServer } : {}), + ...(accountIcon + ? { + ownerPicture: accountIcon.picture, + ownerImageServer: accountIcon.imageServer, + ownerIconRevision: accountIcon.revision, + } + : {}), ...(auth.user.canUseGeneralPicture !== undefined ? { ownerCanUsePicture: auth.user.canUseGeneralPicture, diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 145ebe9..e005910 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -22,6 +22,10 @@ import { buildBattleSimQueueKeys } from './battleSim/keys.js'; import { RedisBattleSimTransport } from './battleSim/redisTransport.js'; import { RedisRealtimeEventHub } from './realtime/eventHub.js'; import { formatSseFrame } from './realtime/sse.js'; +import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js'; +import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js'; +import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js'; +import { createBestEffortResourceCloser } from './services/bestEffortResourceCloser.js'; const extractBearerToken = (value: string | string[] | undefined): string | null => { if (!value) { @@ -59,13 +63,32 @@ const resolveAuthFromToken = async ( export const createGameApiServer = async () => { const config = resolveGameApiConfigFromEnv(); + const app = fastify({ + logger: true, + routerOptions: { + maxParamLength: 2048, + }, + }); const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile })); const redis = createRedisConnector(resolveRedisConfigFromEnv()); await postgres.connect(); - await redis.connect(); + try { + await redis.connect(); + } catch (error) { + await postgres.disconnect(); + throw error; + } + const accountIconSource = new GatewayHttpAccountIconSource(config.gatewayInternalApiUrl, config.gameTokenSecret); const turnDaemon = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs); + const accountIconResetReconciler = new AccountIconResetReconciler( + postgres.prisma, + accountIconSource, + turnDaemon, + config.accountIconResetReconcileIntervalMs, + (error) => app.log.error({ err: error }, 'account icon reset reconciliation failed') + ); const battleSim = new RedisBattleSimTransport(redis.client, { keys: buildBattleSimQueueKeys(config.profileName), requestTimeoutMs: config.battleSimRequestTimeoutMs, @@ -73,21 +96,70 @@ export const createGameApiServer = async () => { }); const flushStore = new InMemoryFlushStore(); const flushSubscriberClient = redis.client.duplicate(); - await flushSubscriberClient.connect(); - const flushSubscriber = new RedisGatewayFlushSubscriber(flushSubscriberClient, config.flushChannel, flushStore); - await flushSubscriber.start(); + try { + await flushSubscriberClient.connect(); + } catch (error) { + await redis.disconnect(); + await postgres.disconnect(); + throw error; + } + const flushSubscriber = new RedisGatewayFlushSubscriber( + flushSubscriberClient, + config.flushChannel, + flushStore, + createAdminProfileIconResetFlushHandler(accountIconSource, turnDaemon), + (error, event) => { + app.log.error({ err: error, userId: event.userId, reason: event.reason }, 'gateway flush handler failed'); + } + ); const accessTokenStore = new RedisAccessTokenStore(redis.client, config.profileName); const realtimeSubscriberClient = redis.client.duplicate(); - await realtimeSubscriberClient.connect(); + try { + await realtimeSubscriberClient.connect(); + } catch (error) { + await flushSubscriberClient.quit(); + await redis.disconnect(); + await postgres.disconnect(); + throw error; + } const realtimeHub = new RedisRealtimeEventHub(realtimeSubscriberClient, buildGameEventChannel(config.profileName)); - await realtimeHub.start(); - - const app = fastify({ - logger: true, - routerOptions: { - maxParamLength: 2048, + let flushSubscriberStarted = false; + let realtimeHubStarted = false; + const closeResources = createBestEffortResourceCloser([ + { + name: 'account-icon-reset-reconciler', + run: () => accountIconResetReconciler.stop(), }, - }); + { + name: 'gateway-flush-subscriber', + run: async () => { + if (flushSubscriberStarted) await flushSubscriber.stop(); + }, + }, + { + name: 'gateway-flush-redis-client', + run: async () => { + await flushSubscriberClient.quit(); + }, + }, + { + name: 'realtime-redis-client', + run: async () => { + if (realtimeHubStarted) await realtimeHub.stop(); + else await realtimeSubscriberClient.quit(); + }, + }, + { + name: 'redis', + run: () => redis.disconnect(), + }, + { + name: 'postgres', + run: () => postgres.disconnect(), + }, + ]); + + app.addHook('onClose', closeResources); await app.register(cors, { origin: true, @@ -127,6 +199,7 @@ export const createGameApiServer = async () => { accessTokenStore, flushStore, gameTokenSecret: config.gameTokenSecret, + accountIconSource, }); }, }, @@ -198,15 +271,19 @@ export const createGameApiServer = async () => { app.get('/healthz', async () => ({ ok: true, profile: config.profileName, + accountIconReconciliation: accountIconResetReconciler.getHealth(), })); - app.addHook('onClose', async () => { - await flushSubscriber.stop(); - await flushSubscriberClient.quit(); - await realtimeHub.stop(); - await redis.disconnect(); - await postgres.disconnect(); - }); + try { + await realtimeHub.start(); + realtimeHubStarted = true; + await flushSubscriber.start(); + flushSubscriberStarted = true; + accountIconResetReconciler.start(); + } catch (error) { + await closeResources(); + throw error; + } return { app, @@ -216,8 +293,13 @@ export const createGameApiServer = async () => { export const runGameApiServer = async (): Promise => { const { app, config } = await createGameApiServer(); - await app.listen({ - host: config.host, - port: config.port, - }); + try { + await app.listen({ + host: config.host, + port: config.port, + }); + } catch (error) { + await app.close(); + throw error; + } }; diff --git a/app/game-api/src/services/accountIconResetReconciler.ts b/app/game-api/src/services/accountIconResetReconciler.ts new file mode 100644 index 0000000..99e1875 --- /dev/null +++ b/app/game-api/src/services/accountIconResetReconciler.ts @@ -0,0 +1,238 @@ +import { isCanonicalIsoTimestamp, isRecord, type AccountIconProjection } from '@sammo-ts/common'; + +import type { AccountIconResetProjection, AccountIconResetSource } from '../auth/accountIconSource.js'; +import type { DatabaseClient } from '../context.js'; +import type { TurnDaemonTransport } from '../daemon/transport.js'; + +const BATCH_SIZE = 500; +const MAX_TERMINAL_REQUEUES = 3; + +type GeneralIconState = { + id: number; + userId: string | null; + picture: string | null; + imageServer: number; + meta: unknown; +}; + +export type AccountIconResetReconcilerHealth = { + running: boolean; + lastSuccessAt: string | null; + lastErrorAt: string | null; + lastError: string | null; +}; + +const readCurrentRevision = (meta: unknown): string | null => { + if (!isRecord(meta)) { + return null; + } + const value = meta.accountIconUpdatedAt; + return typeof value === 'string' && isCanonicalIsoTimestamp(value) ? value : null; +}; + +const projectionForGeneral = (general: GeneralIconState, reset: AccountIconResetProjection): AccountIconProjection => { + const currentRevision = readCurrentRevision(general.meta); + if (currentRevision) { + return { + revision: reset.resetRevision, + picture: 'default.jpg', + imageServer: 0, + }; + } + + // Existing installations have no per-General watermark. If the rendered + // tuple already equals a post-reset Gateway projection, seed that newer + // revision instead of replaying the historical reset over it. + if ( + reset.current.revision > reset.resetRevision && + general.picture === reset.current.picture && + general.imageServer === reset.current.imageServer + ) { + return reset.current; + } + return { + revision: reset.resetRevision, + picture: 'default.jpg', + imageServer: 0, + }; +}; + +const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const stableJson = (value: unknown): string => { + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`) + .join(',')}}`; + } + return JSON.stringify(value) ?? 'null'; +}; + +export class AccountIconResetReconciler { + private timer: NodeJS.Timeout | null = null; + private inFlight: Promise | null = null; + private lastSuccessAt: string | null = null; + private lastErrorAt: string | null = null; + private lastError: string | null = null; + + constructor( + private readonly db: DatabaseClient, + private readonly source: AccountIconResetSource, + private readonly turnDaemon: TurnDaemonTransport, + private readonly intervalMs: number, + private readonly onError: (error: unknown) => void = () => undefined + ) {} + + getHealth(): AccountIconResetReconcilerHealth { + return { + running: this.timer !== null, + lastSuccessAt: this.lastSuccessAt, + lastErrorAt: this.lastErrorAt, + lastError: this.lastError, + }; + } + + private async sendWithTerminalRecovery(userId: string, projection: AccountIconProjection): Promise { + const baseRequestId = `general:adjustIcon:${userId}:${projection.revision}`; + const events = await this.db.inputEvent.findMany({ + where: { + OR: [{ requestId: baseRequestId }, { requestId: { startsWith: `${baseRequestId}:retry:` } }], + }, + select: { + requestId: true, + status: true, + eventType: true, + payload: true, + }, + orderBy: { sequence: 'asc' }, + }); + const latest = events.at(-1); + if (latest) { + const expected = { + type: 'adjustGeneralIcon', + requestId: latest.requestId, + userId, + picture: projection.picture, + imageServer: projection.imageServer, + iconRevision: projection.revision, + }; + if (latest.eventType !== 'adjustGeneralIcon' || stableJson(latest.payload) !== stableJson(expected)) { + throw new Error('account icon reset event payload conflicts with the durable journal'); + } + } + if (latest?.status === 'PENDING' || latest?.status === 'PROCESSING') { + return; + } + + const retryCount = events.filter(({ requestId }) => requestId !== baseRequestId).length; + if (latest && retryCount >= MAX_TERMINAL_REQUEUES) { + throw new Error(`account icon reset exhausted ${MAX_TERMINAL_REQUEUES} terminal retries`); + } + const requestId = latest ? `${baseRequestId}:retry:${retryCount + 1}` : baseRequestId; + await this.turnDaemon.sendCommand({ + type: 'adjustGeneralIcon', + requestId, + userId, + picture: projection.picture, + imageServer: projection.imageServer, + iconRevision: projection.revision, + }); + } + + private async reconcilePage(generals: GeneralIconState[]): Promise { + const userIds = [...new Set(generals.flatMap((general) => (general.userId ? [general.userId] : [])))]; + if (userIds.length === 0) { + return []; + } + const resets = await this.source.listResets(userIds); + const resetByUserId = new Map(resets.map((reset) => [reset.userId, reset])); + const failures: Error[] = []; + + for (const general of generals) { + if (!general.userId) continue; + const reset = resetByUserId.get(general.userId); + if (!reset) continue; + const currentRevision = readCurrentRevision(general.meta); + if (currentRevision && currentRevision >= reset.resetRevision) { + continue; + } + try { + await this.sendWithTerminalRecovery(general.userId, projectionForGeneral(general, reset)); + } catch (error) { + failures.push( + new Error(`account icon reset failed for user ${general.userId}: ${errorMessage(error)}`, { + cause: error, + }) + ); + } + } + return failures; + } + + async reconcileOnce(): Promise { + const failures: Error[] = []; + let cursorId: number | null = null; + try { + while (true) { + const generals: GeneralIconState[] = await this.db.general.findMany({ + where: { + userId: { not: null }, + npcState: 0, + }, + select: { + id: true, + userId: true, + picture: true, + imageServer: true, + meta: true, + }, + orderBy: { id: 'asc' }, + take: BATCH_SIZE, + ...(cursorId === null ? {} : { cursor: { id: cursorId }, skip: 1 }), + }); + failures.push(...(await this.reconcilePage(generals))); + if (generals.length < BATCH_SIZE) break; + cursorId = generals.at(-1)?.id ?? null; + if (cursorId === null) break; + } + if (failures.length > 0) { + throw new AggregateError(failures, `${failures.length} account icon reset reconciliation(s) failed`); + } + this.lastSuccessAt = new Date().toISOString(); + this.lastErrorAt = null; + this.lastError = null; + } catch (error) { + this.lastErrorAt = new Date().toISOString(); + this.lastError = errorMessage(error); + throw error; + } + } + + start(): void { + if (this.timer) { + return; + } + const run = (): void => { + if (this.inFlight) { + return; + } + this.inFlight = this.reconcileOnce() + .catch((error: unknown) => this.onError(error)) + .finally(() => { + this.inFlight = null; + }); + }; + run(); + this.timer = setInterval(run, this.intervalMs); + this.timer.unref?.(); + } + + async stop(): Promise { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + await this.inFlight; + } +} diff --git a/app/game-api/src/services/accountIconSync.ts b/app/game-api/src/services/accountIconSync.ts new file mode 100644 index 0000000..3064af3 --- /dev/null +++ b/app/game-api/src/services/accountIconSync.ts @@ -0,0 +1,146 @@ +import { TRPCError } from '@trpc/server'; +import { isCanonicalIsoTimestamp, type AccountIconProjection } from '@sammo-ts/common'; + +import type { GameApiContext } from '../context.js'; +import { ConflictingTurnDaemonCommandError } from '../daemon/databaseTransport.js'; +import type { AccountIconSource } from '../auth/accountIconSource.js'; +import type { GatewayUserFlushEvent } from '../auth/flushStore.js'; +import type { TurnDaemonTransport } from '../daemon/transport.js'; + +export const loadAuthoritativeAccountIcon = async ( + ctx: GameApiContext, + userId: string +): Promise => { + if (!ctx.accountIconSource) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Gateway 계정 아이콘 원장이 구성되지 않았습니다.', + }); + } + try { + const projection = await ctx.accountIconSource.get(userId); + if (!projection) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Gateway에서 계정 정보를 찾을 수 없습니다.', + }); + } + return projection; + } catch (error) { + if (error instanceof TRPCError) { + throw error; + } + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Gateway 계정 아이콘 정보를 확인할 수 없습니다.', + }); + } +}; + +export const adjustAccountIconForUser = async ( + ctx: GameApiContext, + userId: string +): Promise<{ + ok: true; + generalId: number | null; + updated: boolean; +}> => { + const projection = await loadAuthoritativeAccountIcon(ctx, userId); + const requestId = `general:adjustIcon:${userId}:${projection.revision}`; + try { + const result = await ctx.turnDaemon.requestCommand({ + type: 'adjustGeneralIcon', + requestId, + userId, + picture: projection.picture, + imageServer: projection.imageServer, + iconRevision: projection.revision, + }); + if (!result) { + throw new TRPCError({ + code: 'TIMEOUT', + message: '요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.', + }); + } + if (result.type !== 'adjustGeneralIcon') { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: '턴 데몬이 올바르지 않은 아이콘 적용 결과를 반환했습니다.', + }); + } + if (!result.ok) { + throw new TRPCError({ + code: result.code, + message: result.reason, + }); + } + return { + ok: true, + generalId: result.generalId, + updated: result.updated, + }; + } catch (error) { + if ( + error instanceof ConflictingTurnDaemonCommandError || + (error instanceof Error && error.name === 'ConflictingTurnDaemonCommandError') + ) { + throw new TRPCError({ + code: 'CONFLICT', + message: '이미 접수된 아이콘 적용 요청과 최신 계정 정보가 다릅니다.', + }); + } + throw error; + } +}; + +export const enqueueProfileIconResetForUser = async ( + ctx: GameApiContext, + userId: string, + expectedResetRevision: string +): Promise => { + const projection = await loadAuthoritativeAccountIcon(ctx, userId); + if ( + projection.revision !== expectedResetRevision || + projection.picture !== 'default.jpg' || + projection.imageServer !== 0 + ) { + return false; + } + await ctx.turnDaemon.sendCommand({ + type: 'adjustGeneralIcon', + requestId: `general:adjustIcon:${userId}:${projection.revision}`, + userId, + picture: projection.picture, + imageServer: projection.imageServer, + iconRevision: projection.revision, + }); + return true; +}; + +export const createAdminProfileIconResetFlushHandler = + (source: AccountIconSource, turnDaemon: TurnDaemonTransport) => + async (event: GatewayUserFlushEvent): Promise => { + if (event.reason !== 'admin-profile-icon-reset') { + return; + } + if (!event.iconRevision || !isCanonicalIsoTimestamp(event.iconRevision)) { + return; + } + const projection = await source.get(event.userId); + if ( + !projection || + projection.revision !== event.iconRevision || + projection.picture !== 'default.jpg' || + projection.imageServer !== 0 + ) { + return; + } + await turnDaemon.sendCommand({ + type: 'adjustGeneralIcon', + requestId: `general:adjustIcon:${event.userId}:${projection.revision}`, + userId: event.userId, + picture: projection.picture, + imageServer: projection.imageServer, + iconRevision: projection.revision, + }); + }; diff --git a/app/game-api/src/services/bestEffortResourceCloser.ts b/app/game-api/src/services/bestEffortResourceCloser.ts new file mode 100644 index 0000000..c52951d --- /dev/null +++ b/app/game-api/src/services/bestEffortResourceCloser.ts @@ -0,0 +1,40 @@ +export interface ResourceCleanupStep { + name: string; + run: () => Promise; +} + +/** + * 종료 단계 하나가 실패해도 나머지 연결을 모두 닫고, 다음 호출에서는 실패한 + * 단계만 재시도합니다. 동시에 들어온 종료 요청은 같은 실행을 공유합니다. + */ +export const createBestEffortResourceCloser = (steps: readonly ResourceCleanupStep[]): (() => Promise) => { + const completed = new Set(); + let closing: Promise | null = null; + + return async (): Promise => { + if (completed.size === steps.length) return; + if (closing) return closing; + + closing = (async () => { + const errors: Error[] = []; + for (const step of steps) { + if (completed.has(step.name)) continue; + try { + await step.run(); + completed.add(step.name); + } catch (error) { + errors.push(new Error(`Failed to close resource: ${step.name}`, { cause: error })); + } + } + if (errors.length > 0) { + throw new AggregateError(errors, 'One or more resources failed to close.'); + } + })(); + + try { + await closing; + } finally { + closing = null; + } + }; +}; diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 75f5218..1210aed 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -107,6 +107,7 @@ export const accessAuthedProcedure: typeof procedure = t.procedure // 커밋하는 mutation에 사용한다. API input-event transaction으로 한 번 더 // 감싸면 daemon이 아직 commit되지 않은 command를 볼 수 없어 교착된다. export const engineAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware); +export const engineProcedure: typeof procedure = t.procedure; export const accessEngineAuthedProcedure: typeof procedure = t.procedure .use(requireAuthMiddleware) .use(generalAccessEndpointMiddleware); diff --git a/app/game-api/test/accountIconResetFlush.test.ts b/app/game-api/test/accountIconResetFlush.test.ts new file mode 100644 index 0000000..7a2e99f --- /dev/null +++ b/app/game-api/test/accountIconResetFlush.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { createAdminProfileIconResetFlushHandler } from '../src/services/accountIconSync.js'; + +describe('administrator profile icon reset flush', () => { + it('enqueues only the administrator reset projection', async () => { + const transport = new InMemoryTurnDaemonTransport(); + const get = vi.fn(async () => ({ + revision: '2026-07-31T09:00:00.001Z', + picture: 'default.jpg', + imageServer: 0, + })); + const handler = createAdminProfileIconResetFlushHandler({ get }, transport); + + await handler({ + userId: 'user-1', + flushedAt: '2026-07-31T09:00:00.000Z', + reason: 'account-icon-changed', + }); + expect(get).not.toHaveBeenCalled(); + expect(transport.commands).toHaveLength(0); + + await handler({ + userId: 'user-1', + flushedAt: '2026-07-31T09:00:00.001Z', + reason: 'admin-profile-icon-reset', + iconRevision: '2026-07-31T09:00:00.001Z', + }); + expect(transport.commands.at(-1)?.command).toEqual({ + type: 'adjustGeneralIcon', + requestId: 'general:adjustIcon:user-1:2026-07-31T09:00:00.001Z', + userId: 'user-1', + picture: 'default.jpg', + imageServer: 0, + iconRevision: '2026-07-31T09:00:00.001Z', + }); + + get.mockResolvedValueOnce({ + revision: '2026-07-31T09:00:00.002Z', + picture: 'default.jpg', + imageServer: 0, + }); + await handler({ + userId: 'user-1', + flushedAt: '2026-07-31T09:00:00.003Z', + reason: 'admin-profile-icon-reset', + iconRevision: '2026-07-31T09:00:00.001Z', + }); + expect(transport.commands).toHaveLength(1); + }); +}); diff --git a/app/game-api/test/accountIconResetReconciler.integration.test.ts b/app/game-api/test/accountIconResetReconciler.integration.test.ts new file mode 100644 index 0000000..912d011 --- /dev/null +++ b/app/game-api/test/accountIconResetReconciler.integration.test.ts @@ -0,0 +1,358 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { SystemClock } from '@sammo-ts/common'; +import { + createDatabaseTurnHooks, + DatabaseTurnDaemonCommandQueue, + EngineStateManager, + InMemoryTurnStateStore, + InMemoryTurnWorld, + loadTurnWorldFromDatabase, + TurnDaemonLifecycle, + type TurnGeneral, + type TurnWorldSnapshot, + type TurnWorldState, +} from '@sammo-ts/game-engine'; +import { createTurnDaemonCommandHandler } from '@sammo-ts/game-engine/turn/worldCommandHandler.js'; +import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; +import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic'; + +import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js'; +import { AccountIconResetReconciler } from '../src/services/accountIconResetReconciler.js'; + +const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const generalId = 991_744; +const userId = 'account-icon-reset-reconcile-user'; +const revision = '2026-07-31T10:00:00.001Z'; +const requestId = `general:adjustIcon:${userId}:${revision}`; +const retryRequestId = `${requestId}:retry:1`; +const lifecycleGeneralId = 991_745; +const lifecycleWorldId = 991_745; +const lifecycleUserId = 'account-icon-reset-lifecycle-user'; +const lifecycleRevision = '2026-07-31T10:20:00.001Z'; +const lifecycleRequestId = `general:adjustIcon:${lifecycleUserId}:${lifecycleRevision}`; +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; + +const scenarioConfig: ScenarioConfig = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'che', unitSet: 'che' }, +}; +const scenarioMeta: ScenarioMeta = { + title: '계정 아이콘 reset lifecycle 통합', + startYear: 190, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, +}; +const map: MapDefinition = { id: 'account-icon-reset-lifecycle', name: scenarioMeta.title, cities: [] }; +const lifecycleState: TurnWorldState = { + id: lifecycleWorldId, + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('2026-07-31T10:00:00.000Z'), + meta: { killturn: 24, scenarioMeta }, +}; +const lifecycleGeneral: TurnGeneral = { + id: lifecycleGeneralId, + userId: lifecycleUserId, + name: '초기화lifecycle장수', + nationId: 0, + cityId: 0, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + turnTime: new Date('2026-07-31T10:30:00.000Z'), + recentWarTime: null, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24, accountIconUpdatedAt: '2026-07-31T10:10:00.000Z', preserved: 'yes' }, + penalty: {}, + officerLevel: 0, + experience: 0, + dedication: 0, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 20, + npcState: 0, + picture: 'before-lifecycle-reset.png', + imageServer: 1, +}; + +const assertDedicatedDatabase = (rawUrl: string): void => { + const schema = new URL(rawUrl).searchParams.get('schema'); + if (!schema?.endsWith('immediate_action_integration')) { + throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`); + } +}; + +integration('account icon reset reconciliation PostgreSQL queue', () => { + let db: GamePrismaClient; + let disconnect: (() => Promise) | undefined; + + beforeAll(async () => { + assertDedicatedDatabase(databaseUrl!); + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + disconnect = () => connector.disconnect(); + await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestId } } }); + await db.inputEvent.deleteMany({ where: { requestId: { startsWith: lifecycleRequestId } } }); + await db.general.deleteMany({ where: { id: { in: [generalId, lifecycleGeneralId] } } }); + await db.worldState.deleteMany({ where: { id: lifecycleWorldId } }); + await db.worldState.create({ + data: { + id: lifecycleWorldId, + scenarioCode: 'account-icon-reset-lifecycle', + currentYear: lifecycleState.currentYear, + currentMonth: lifecycleState.currentMonth, + tickSeconds: lifecycleState.tickSeconds, + config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, + meta: lifecycleState.meta as GamePrisma.InputJsonValue, + }, + }); + await db.general.create({ + data: { + id: generalId, + userId, + name: '초기화복구장수', + turnTime: new Date('2026-07-31T10:10:00.000Z'), + picture: 'before-reset.png', + imageServer: 1, + meta: { killturn: 24, accountIconUpdatedAt: '2026-07-31T09:00:00.000Z' }, + }, + }); + await db.general.create({ + data: { + id: lifecycleGeneral.id, + userId: lifecycleGeneral.userId, + name: lifecycleGeneral.name, + turnTime: lifecycleGeneral.turnTime, + picture: lifecycleGeneral.picture, + imageServer: lifecycleGeneral.imageServer, + meta: lifecycleGeneral.meta, + }, + }); + }); + + afterAll(async () => { + if (db) { + await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestId } } }); + await db.inputEvent.deleteMany({ where: { requestId: { startsWith: lifecycleRequestId } } }); + await db.general.deleteMany({ where: { id: { in: [generalId, lifecycleGeneralId] } } }); + await db.worldState.deleteMany({ where: { id: lifecycleWorldId } }); + } + await disconnect?.(); + }); + + it('deduplicates an active durable enqueue and creates one bounded retry after terminal failure', async () => { + const source = { + listResets: vi.fn(async () => [ + { + userId, + resetRevision: revision, + current: { + revision, + picture: 'default.jpg', + imageServer: 0, + }, + }, + ]), + }; + const reconciler = new AccountIconResetReconciler( + db, + source, + new DatabaseTurnDaemonTransport(db, 1_000), + 30_000 + ); + + await reconciler.reconcileOnce(); + await reconciler.reconcileOnce(); + + await expect(db.inputEvent.findMany({ where: { requestId } })).resolves.toEqual([ + expect.objectContaining({ + requestId, + target: 'ENGINE', + eventType: 'adjustGeneralIcon', + status: 'PENDING', + actorUserId: userId, + payload: { + type: 'adjustGeneralIcon', + requestId, + userId, + picture: 'default.jpg', + imageServer: 0, + iconRevision: revision, + }, + }), + ]); + + await db.inputEvent.update({ + where: { requestId }, + data: { + status: 'FAILED', + attempts: 3, + error: 'simulated terminal failure', + completedAt: new Date(), + }, + }); + await reconciler.reconcileOnce(); + await reconciler.reconcileOnce(); + + await expect( + db.inputEvent.findMany({ + where: { requestId: { startsWith: requestId } }, + orderBy: { sequence: 'asc' }, + }) + ).resolves.toEqual([ + expect.objectContaining({ requestId, status: 'FAILED' }), + expect.objectContaining({ + requestId: retryRequestId, + status: 'PENDING', + actorUserId: userId, + payload: { + type: 'adjustGeneralIcon', + requestId: retryRequestId, + userId, + picture: 'default.jpg', + imageServer: 0, + iconRevision: revision, + }, + }), + ]); + }); + + it('flows from reconciliation through the durable daemon lifecycle and persists one reset', async () => { + const source = { + listResets: vi.fn(async () => [ + { + userId: lifecycleUserId, + resetRevision: lifecycleRevision, + current: { + revision: lifecycleRevision, + picture: 'default.jpg', + imageServer: 0, + }, + }, + ]), + }; + const reconciler = new AccountIconResetReconciler( + db, + source, + new DatabaseTurnDaemonTransport(db, 1_000), + 30_000 + ); + await reconciler.reconcileOnce(); + + const snapshot: TurnWorldSnapshot = { + generals: [lifecycleGeneral], + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig, + scenarioMeta, + map, + }; + const world = new InMemoryTurnWorld(lifecycleState, snapshot, { schedule }); + const queue = new DatabaseTurnDaemonCommandQueue(db); + await queue.initialize(); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + const stateManager = new EngineStateManager(); + stateManager.register('world', { + capture: () => world.captureState(), + restore: (captured) => world.restoreState(captured), + }); + const lifecycle = new TurnDaemonLifecycle( + { + clock: new SystemClock(), + controlQueue: queue, + commandResponder: queue, + getNextTickTime: () => new Date(Date.now() + 3_600_000), + stateStore: new InMemoryTurnStateStore(world), + processor: { + run: async () => { + throw new Error('scheduled turn must not run in account icon lifecycle integration'); + }, + }, + commandHandler: createTurnDaemonCommandHandler({ world }), + hooks: hooks.hooks, + stateManager, + }, + { + profile: 'account-icon-reset-lifecycle', + defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 }, + } + ); + const waitForSuccess = async () => { + for (let attempt = 0; attempt < 200; attempt += 1) { + const event = await db.inputEvent.findUnique({ where: { requestId: lifecycleRequestId } }); + if (event?.status === 'SUCCEEDED' && event.lockedBy === null) return event; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + const observed = await db.inputEvent.findUnique({ where: { requestId: lifecycleRequestId } }); + throw new Error( + `Timed out waiting for ${lifecycleRequestId} to succeed: ${JSON.stringify( + observed && { + status: observed.status, + attempts: observed.attempts, + error: observed.error, + result: observed.result, + lockedBy: observed.lockedBy, + } + )}` + ); + }; + + let loop: Promise | undefined; + try { + loop = lifecycle.start(); + await expect(waitForSuccess()).resolves.toMatchObject({ + attempts: 1, + result: { + type: 'adjustGeneralIcon', + ok: true, + generalId: lifecycleGeneralId, + updated: true, + }, + }); + } finally { + await lifecycle.stop('account icon reset integration finished'); + await loop; + await hooks.close(); + } + + await expect(db.general.findUniqueOrThrow({ where: { id: lifecycleGeneralId } })).resolves.toMatchObject({ + picture: 'default.jpg', + imageServer: 0, + meta: { accountIconUpdatedAt: lifecycleRevision, preserved: 'yes' }, + }); + const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + expect(reloaded.snapshot.generals.find((entry) => entry.id === lifecycleGeneralId)).toMatchObject({ + picture: 'default.jpg', + imageServer: 0, + meta: { accountIconUpdatedAt: lifecycleRevision, preserved: 'yes' }, + }); + + await reconciler.reconcileOnce(); + await expect(db.inputEvent.count({ where: { requestId: { startsWith: lifecycleRequestId } } })).resolves.toBe( + 1 + ); + }, 15_000); +}); diff --git a/app/game-api/test/accountIconResetReconciler.test.ts b/app/game-api/test/accountIconResetReconciler.test.ts new file mode 100644 index 0000000..a7654bd --- /dev/null +++ b/app/game-api/test/accountIconResetReconciler.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { DatabaseClient } from '../src/context.js'; +import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { AccountIconResetReconciler } from '../src/services/accountIconResetReconciler.js'; + +const reset = (userId: string) => ({ + userId, + resetRevision: '2026-07-31T09:00:00.001Z', + current: { + revision: '2026-07-31T09:00:00.002Z', + picture: 'newer.png', + imageServer: 1, + }, +}); + +describe('AccountIconResetReconciler', () => { + it('replays stale resets, skips newer watermarks, and bootstraps matching post-reset tuples', async () => { + const db = { + general: { + findMany: vi.fn(async () => [ + { + id: 1, + userId: 'needs-reset', + picture: 'old.png', + imageServer: 1, + meta: { accountIconUpdatedAt: '2026-07-31T08:59:59.999Z' }, + }, + { + id: 2, + userId: 'already-newer', + picture: 'newer.png', + imageServer: 1, + meta: { accountIconUpdatedAt: '2026-07-31T09:00:00.002Z' }, + }, + { + id: 3, + userId: 'legacy-watermark', + picture: 'newer.png', + imageServer: 1, + meta: {}, + }, + ]), + }, + inputEvent: { + findMany: vi.fn(async () => []), + }, + } as unknown as DatabaseClient; + const source = { + listResets: vi.fn(async () => [reset('needs-reset'), reset('already-newer'), reset('legacy-watermark')]), + }; + const transport = new InMemoryTurnDaemonTransport(); + const reconciler = new AccountIconResetReconciler(db, source, transport, 30_000); + + await reconciler.reconcileOnce(); + + expect(source.listResets).toHaveBeenCalledWith(['needs-reset', 'already-newer', 'legacy-watermark']); + expect(transport.commands.map(({ command }) => command)).toEqual([ + { + type: 'adjustGeneralIcon', + requestId: 'general:adjustIcon:needs-reset:2026-07-31T09:00:00.001Z', + userId: 'needs-reset', + picture: 'default.jpg', + imageServer: 0, + iconRevision: '2026-07-31T09:00:00.001Z', + }, + { + type: 'adjustGeneralIcon', + requestId: 'general:adjustIcon:legacy-watermark:2026-07-31T09:00:00.002Z', + userId: 'legacy-watermark', + picture: 'newer.png', + imageServer: 1, + iconRevision: '2026-07-31T09:00:00.002Z', + }, + ]); + expect(reconciler.getHealth()).toMatchObject({ lastSuccessAt: expect.any(String), lastError: null }); + }); + + it('requeues terminal events and isolates a persistent user failure from later users', async () => { + const db = { + general: { + findMany: vi.fn(async () => + ['terminal', 'broken', 'later'].map((userId, index) => ({ + id: index + 1, + userId, + picture: 'old.png', + imageServer: 1, + meta: {}, + })) + ), + }, + inputEvent: { + findMany: vi.fn(async ({ where }: { where: { OR: Array<{ requestId: unknown }> } }) => { + const first = where.OR[0]?.requestId; + if (first === 'general:adjustIcon:broken:2026-07-31T09:00:00.001Z') { + throw new Error('broken event lookup'); + } + if (first === 'general:adjustIcon:terminal:2026-07-31T09:00:00.001Z') { + return [ + { + requestId: first, + status: 'FAILED', + eventType: 'adjustGeneralIcon', + payload: { + type: 'adjustGeneralIcon', + requestId: first, + userId: 'terminal', + picture: 'default.jpg', + imageServer: 0, + iconRevision: '2026-07-31T09:00:00.001Z', + }, + }, + ]; + } + return []; + }), + }, + } as unknown as DatabaseClient; + const source = { listResets: vi.fn(async () => ['terminal', 'broken', 'later'].map(reset)) }; + const transport = new InMemoryTurnDaemonTransport(); + const reconciler = new AccountIconResetReconciler(db, source, transport, 30_000); + + await expect(reconciler.reconcileOnce()).rejects.toThrow('1 account icon reset reconciliation'); + expect(transport.commands.map(({ command }) => command.requestId)).toEqual([ + 'general:adjustIcon:terminal:2026-07-31T09:00:00.001Z:retry:1', + 'general:adjustIcon:later:2026-07-31T09:00:00.001Z', + ]); + expect(reconciler.getHealth()).toMatchObject({ lastErrorAt: expect.any(String) }); + }); +}); diff --git a/app/game-api/test/accountIconSource.test.ts b/app/game-api/test/accountIconSource.test.ts new file mode 100644 index 0000000..dceda44 --- /dev/null +++ b/app/game-api/test/accountIconSource.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { GatewayHttpAccountIconSource } from '../src/auth/accountIconSource.js'; + +const projection = { + revision: '2026-07-31T09:00:00.001Z', + picture: 'latest.png', + imageServer: 1, +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('GatewayHttpAccountIconSource', () => { + it('uses an encoded path and a purpose-derived credential', async () => { + const fetchMock = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify(projection), { status: 200 }) + ); + vi.stubGlobal('fetch', fetchMock); + + const source = new GatewayHttpAccountIconSource('http://gateway.internal/', 'root-secret'); + await expect(source.get('user/한글')).resolves.toEqual(projection); + + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('http://gateway.internal/internal/account-icons/user%2F%ED%95%9C%EA%B8%80'); + expect(init?.headers).toMatchObject({ + 'x-sammo-internal-token': expect.not.stringContaining('root-secret'), + }); + }); + + it('returns null only for a missing user', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 404 })) + ); + await expect(new GatewayHttpAccountIconSource('http://gateway', 'secret').get('missing')).resolves.toBeNull(); + }); + + it.each([401, 500])('rejects HTTP %s', async (status) => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status })) + ); + await expect(new GatewayHttpAccountIconSource('http://gateway', 'secret').get('user')).rejects.toThrow( + `HTTP ${status}` + ); + }); + + it('rejects malformed or over-broad responses', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ ...projection, email: 'must-not-leak@example.test' }))) + ); + await expect(new GatewayHttpAccountIconSource('http://gateway', 'secret').get('user')).rejects.toThrow( + 'invalid' + ); + }); + + it('loads only strict reset projections for the requested users', async () => { + const fetchMock = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response( + JSON.stringify({ + resets: [ + { + userId: 'user-1', + resetRevision: '2026-07-31T09:00:00.001Z', + current: projection, + }, + ], + }) + ) + ); + vi.stubGlobal('fetch', fetchMock); + + const source = new GatewayHttpAccountIconSource('http://gateway.internal/', 'root-secret'); + await expect(source.listResets(['user-1'])).resolves.toEqual([ + { + userId: 'user-1', + resetRevision: '2026-07-31T09:00:00.001Z', + current: projection, + }, + ]); + expect(fetchMock.mock.calls[0]?.[0]).toBe('http://gateway.internal/internal/account-icon-resets'); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: 'POST', + body: JSON.stringify({ userIds: ['user-1'] }), + }); + }); + + it('rejects reset projections for users that were not requested', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ + resets: [ + { + userId: 'other-user', + resetRevision: '2026-07-31T09:00:00.001Z', + current: projection, + }, + ], + }) + ) + ) + ); + await expect( + new GatewayHttpAccountIconSource('http://gateway', 'secret').listResets(['user-1']) + ).rejects.toThrow('invalid'); + }); +}); diff --git a/app/game-api/test/bestEffortResourceCloser.test.ts b/app/game-api/test/bestEffortResourceCloser.test.ts new file mode 100644 index 0000000..692f4e5 --- /dev/null +++ b/app/game-api/test/bestEffortResourceCloser.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createBestEffortResourceCloser } from '../src/services/bestEffortResourceCloser.js'; + +describe('createBestEffortResourceCloser', () => { + it('continues after a failed step and retries only the unfinished step', async () => { + const calls: string[] = []; + let rejectMiddle = true; + const close = createBestEffortResourceCloser([ + { + name: 'first', + run: async () => { + calls.push('first'); + }, + }, + { + name: 'middle', + run: async () => { + calls.push('middle'); + if (rejectMiddle) throw new Error('temporary failure'); + }, + }, + { + name: 'last', + run: async () => { + calls.push('last'); + }, + }, + ]); + + await expect(close()).rejects.toThrow('One or more resources failed to close.'); + expect(calls).toEqual(['first', 'middle', 'last']); + + rejectMiddle = false; + await close(); + await close(); + expect(calls).toEqual(['first', 'middle', 'last', 'middle']); + }); + + it('shares one in-flight close across concurrent callers', async () => { + let release: (() => void) | undefined; + const pending = new Promise((resolve) => { + release = resolve; + }); + const run = vi.fn(async () => pending); + const close = createBestEffortResourceCloser([{ name: 'only', run }]); + + const first = close(); + const second = close(); + expect(run).toHaveBeenCalledTimes(1); + release?.(); + await Promise.all([first, second]); + expect(run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/game-api/test/config.test.ts b/app/game-api/test/config.test.ts index 2fedb5c..a6e9e96 100644 --- a/app/game-api/test/config.test.ts +++ b/app/game-api/test/config.test.ts @@ -25,4 +25,22 @@ describe('resolveGameApiConfigFromEnv', () => { expect(config.profileName).toBe('hwe:2'); }); + + it.each(['0', '-1', '1.5', '999', 'Infinity'])('rejects unsafe account icon reconcile interval %s', (value) => { + expect(() => + resolveGameApiConfigFromEnv({ + GAME_TOKEN_SECRET: 'test-secret', + ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS: value, + }) + ).toThrow('ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS'); + }); + + it('accepts a bounded integer account icon reconcile interval', () => { + expect( + resolveGameApiConfigFromEnv({ + GAME_TOKEN_SECRET: 'test-secret', + ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS: '1000', + }).accountIconResetReconcileIntervalMs + ).toBe(1000); + }); }); diff --git a/app/game-api/test/createGeneral.integration.test.ts b/app/game-api/test/createGeneral.integration.test.ts index 8fde508..4206f24 100644 --- a/app/game-api/test/createGeneral.integration.test.ts +++ b/app/game-api/test/createGeneral.integration.test.ts @@ -50,6 +50,7 @@ const buildAuth = (id: string, displayName: string, legacyMemberNo: number): Gam legacyMemberNo, picture: 'custom-owner.webp', imageServer: 2, + iconUpdatedAt: '2026-07-30T00:00:00.000Z', canUseGeneralPicture: true, }, sanctions: { @@ -92,6 +93,16 @@ integration('generic general creation through the durable turn daemon', () => { accessTokenStore: new RedisAccessTokenStore(redisClient, profile), flushStore: new InMemoryFlushStore(), gameTokenSecret: 'create-general-test-secret', + accountIconSource: { + get: async (accountId) => + accountId === auth.user.id && auth.user.iconUpdatedAt + ? { + revision: auth.user.iconUpdatedAt, + picture: auth.user.picture ?? 'default.jpg', + imageServer: auth.user.imageServer ?? 0, + } + : null, + }, }; }; @@ -273,6 +284,7 @@ integration('generic general creation through the durable turn daemon', () => { ownerName: '생성사용자', killturn: 6, inherit_spent_dyn: 4500, + accountIconUpdatedAt: '2026-07-30T00:00:00.000Z', }); const createdAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: created.id } }); if (!createdAccess.lastRefresh) { diff --git a/app/game-api/test/flushStore.test.ts b/app/game-api/test/flushStore.test.ts new file mode 100644 index 0000000..72f3474 --- /dev/null +++ b/app/game-api/test/flushStore.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js'; + +describe('RedisGatewayFlushSubscriber', () => { + it('reports asynchronous flush handler failures with event context', async () => { + let listener: ((message: string) => void) | undefined; + const client = { + subscribe: vi.fn(async (_channel: string, next: (message: string) => void) => { + listener = next; + }), + unsubscribe: vi.fn(async () => undefined), + }; + const error = new Error('durable enqueue unavailable'); + const onError = vi.fn(); + const subscriber = new RedisGatewayFlushSubscriber( + client, + 'flush', + new InMemoryFlushStore(), + async () => Promise.reject(error), + onError + ); + await subscriber.start(); + const event = { + userId: 'user-1', + flushedAt: '2026-07-31T09:00:00.001Z', + reason: 'admin-profile-icon-reset', + iconRevision: '2026-07-31T09:00:00.001Z', + }; + + listener?.(JSON.stringify(event)); + + await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(error, event)); + await subscriber.stop(); + }); + + it('unsubscribes and drains an in-flight durable flush before stopping', async () => { + let listener: ((message: string) => void) | undefined; + let release: (() => void) | undefined; + const client = { + subscribe: vi.fn(async (_channel: string, next: (message: string) => void) => { + listener = next; + }), + unsubscribe: vi.fn(async () => undefined), + }; + const onFlush = vi.fn( + async () => + new Promise((resolve) => { + release = resolve; + }) + ); + const subscriber = new RedisGatewayFlushSubscriber(client, 'flush', new InMemoryFlushStore(), onFlush); + await subscriber.start(); + + listener?.(JSON.stringify({ userId: 'user-1', flushedAt: '2026-07-31T09:00:00.001Z' })); + await vi.waitFor(() => expect(onFlush).toHaveBeenCalledOnce()); + let stopped = false; + const stopping = subscriber.stop().then(() => { + stopped = true; + }); + await vi.waitFor(() => expect(client.unsubscribe).toHaveBeenCalledOnce()); + expect(stopped).toBe(false); + + release?.(); + await stopping; + expect(stopped).toBe(true); + }); +}); diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 5f83ac7..dca6e75 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { DatabaseClient, @@ -15,7 +15,7 @@ import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js' import { InMemoryFlushStore } from '../src/auth/flushStore.js'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { appRouter } from '../src/router.js'; -import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; const profile: GameProfile = { id: 'che', @@ -114,6 +114,9 @@ const buildContext = (options?: { generalTurnWrites?: unknown[]; nationTurnWrites?: unknown[]; auth?: GameSessionTokenPayload | null; + currentAccountIcon?: unknown; + accountIconGet?: (userId: string) => Promise; + accessTokenStore?: RedisAccessTokenStore; worldStateReads?: { count: number }; }): GameApiContext => { const transport = options?.transport ?? new InMemoryTurnDaemonTransport(); @@ -227,10 +230,30 @@ const buildContext = (options?: { uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, - redis: {} as unknown as RedisConnector['client'], - accessTokenStore, + redis: { + get: async () => null, + } as unknown as RedisConnector['client'], + accessTokenStore: options?.accessTokenStore ?? accessTokenStore, flushStore: new InMemoryFlushStore(), gameTokenSecret: 'test-secret', + accountIconSource: { + get: async (userId: string) => { + if (options?.accountIconGet) { + return (await options.accountIconGet(userId)) as { + revision: string; + picture: string; + imageServer: number; + } | null; + } + return options?.currentAccountIcon === undefined + ? null + : (options.currentAccountIcon as { + revision: string; + picture: string; + imageServer: number; + }); + }, + }, }; }; @@ -280,6 +303,140 @@ describe('appRouter', () => { await expect(caller.auth.status()).resolves.toEqual({ userId: 'user-1' }); }); + it('keeps ordinary account icon changes on the explicitly selected servers', async () => { + const transport = new InMemoryTurnDaemonTransport(); + const accountIconGet = vi.fn(async () => { + throw new Error('ordinary exchange must not read the icon source'); + }); + const accessTokenStore = { + markGatewayTokenUsed: vi.fn(async () => true), + create: vi.fn(async () => ({ + accessToken: 'ga_access', + expiresAt: '2099-01-01T01:00:00.000Z', + })), + } as unknown as RedisAccessTokenStore; + const payload = buildAuth(); + payload.issuedAt = '2099-01-01T00:00:00.000Z'; + payload.expiresAt = '2099-01-01T01:00:00.000Z'; + payload.user.iconUpdatedAt = '2099-01-01T00:00:00.000Z'; + const caller = appRouter.createCaller( + buildContext({ + auth: null, + transport, + accountIconGet, + accessTokenStore, + }) + ); + + await expect( + caller.auth.exchangeGatewayToken({ + gatewayToken: encryptGameSessionToken(payload, 'test-secret'), + }) + ).resolves.toMatchObject({ accessToken: 'ga_access' }); + expect(accountIconGet).not.toHaveBeenCalled(); + expect(transport.commands).toHaveLength(0); + }); + + it('durably re-applies an administrator reset before consuming the one-time token', async () => { + const transport = new InMemoryTurnDaemonTransport(); + const calls: string[] = []; + const revision = '2099-01-01T00:00:00.001Z'; + const accessTokenStore = { + markGatewayTokenUsed: vi.fn(async () => { + calls.push('mark-used'); + return true; + }), + create: vi.fn(async () => ({ + accessToken: 'ga_access', + expiresAt: '2099-01-01T01:00:00.000Z', + })), + } as unknown as RedisAccessTokenStore; + const payload = buildAuth(); + payload.issuedAt = '2099-01-01T00:00:00.000Z'; + payload.expiresAt = '2099-01-01T01:00:00.000Z'; + payload.user.iconUpdatedAt = revision; + payload.user.profileIconResetAt = revision; + const caller = appRouter.createCaller( + buildContext({ + auth: null, + transport, + accessTokenStore, + accountIconGet: async () => { + calls.push('projection'); + return { + revision, + picture: 'default.jpg', + imageServer: 0, + }; + }, + }) + ); + + await caller.auth.exchangeGatewayToken({ + gatewayToken: encryptGameSessionToken(payload, 'test-secret'), + }); + + expect(calls).toEqual(['projection', 'mark-used']); + expect(transport.commands.at(-1)?.command).toEqual({ + type: 'adjustGeneralIcon', + requestId: `general:adjustIcon:${payload.user.id}:${revision}`, + userId: payload.user.id, + picture: 'default.jpg', + imageServer: 0, + iconRevision: revision, + }); + }); + + it('applies the current Gateway database icon instead of stale token claims', async () => { + const transport = new InMemoryTurnDaemonTransport(); + const currentAccountIcon = { + revision: '2026-07-31T09:00:00.000Z', + picture: 'latest.png', + imageServer: 1, + }; + const auth = buildAuth(); + auth.user.picture = 'stale.png'; + auth.user.imageServer = 0; + auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z'; + const requestId = `general:adjustIcon:${auth.user.id}:${currentAccountIcon.revision}`; + transport.setCommandResult(requestId, { + type: 'adjustGeneralIcon', + ok: true, + generalId: 1, + updated: true, + }); + const caller = appRouter.createCaller( + buildContext({ + auth, + transport, + currentAccountIcon, + }) + ); + + await expect(caller.general.adjustIcon()).resolves.toEqual({ + ok: true, + generalId: 1, + updated: true, + }); + expect(transport.commands.at(-1)?.command).toEqual({ + type: 'adjustGeneralIcon', + requestId, + userId: auth.user.id, + picture: 'latest.png', + imageServer: 1, + iconRevision: currentAccountIcon.revision, + }); + }); + + it('rejects icon adjustment without auth or a current Gateway account', async () => { + await expect(appRouter.createCaller(buildContext({ auth: null })).general.adjustIcon()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + await expect( + appRouter.createCaller(buildContext({ auth: buildAuth() })).general.adjustIcon() + ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + }); + it('rejects unauthenticated or game-blocked auth status checks', async () => { await expect(appRouter.createCaller(buildContext({ auth: null })).auth.status()).rejects.toMatchObject({ code: 'UNAUTHORIZED', @@ -356,6 +513,85 @@ describe('appRouter', () => { expect(worldStateReads.count).toBe(0); }); + it('does not require the Gateway icon source when creating a default-picture general', async () => { + const transport = new InMemoryTurnDaemonTransport(); + const clientRequestId = '1b9afacd-d29b-456d-8ef3-a6be4b497e6e'; + const requestId = `join-create:user-1:${clientRequestId}`; + transport.setCommandResult(requestId, { + type: 'joinCreateGeneral', + ok: true, + generalId: 41, + }); + const accountIconGet = vi.fn(async () => { + throw new Error('default-picture join must not read the icon source'); + }); + const caller = appRouter.createCaller( + buildContext({ + state: buildWorldState(), + transport, + accountIconGet, + }) + ); + + await expect( + caller.join.createGeneral({ + name: '기본전콘', + leadership: 55, + strength: 55, + intel: 55, + pic: false, + character: 'Random', + clientRequestId, + }) + ).resolves.toEqual({ ok: true, generalId: 41 }); + expect(accountIconGet).not.toHaveBeenCalled(); + expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerIconRevision'); + }); + + it('uses the authoritative projection instead of stale token claims for picture creation', async () => { + const transport = new InMemoryTurnDaemonTransport(); + const clientRequestId = '824454da-d0ab-48d2-a7d5-e2e5aaf83ba4'; + const requestId = `join-create:user-1:${clientRequestId}`; + const revision = '2026-07-31T09:00:00.001Z'; + transport.setCommandResult(requestId, { + type: 'joinCreateGeneral', + ok: true, + generalId: 42, + }); + const auth = buildAuth(); + auth.user.picture = 'stale.png'; + auth.user.imageServer = 0; + auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z'; + const caller = appRouter.createCaller( + buildContext({ + state: buildWorldState(), + auth, + transport, + currentAccountIcon: { + revision, + picture: 'latest.png', + imageServer: 1, + }, + }) + ); + + await caller.join.createGeneral({ + name: '최신전콘', + leadership: 55, + strength: 55, + intel: 55, + pic: true, + character: 'Random', + clientRequestId, + }); + + expect(transport.commands.at(-1)?.command).toMatchObject({ + ownerPicture: 'latest.png', + ownerImageServer: 1, + ownerIconRevision: revision, + }); + }); + it('queues turn daemon run commands', async () => { const transport = new InMemoryTurnDaemonTransport(); const caller = appRouter.createCaller(buildContext({ transport })); diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index f86f0ad..086bda6 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import type { TurnDaemonCommand, TurnDaemonCommandType, TurnDaemonCommandByType } from '@sammo-ts/common'; +import { isCanonicalIsoTimestamp } from '@sammo-ts/common'; export type TurnDaemonCommandEnvelope = { requestId: string; @@ -252,6 +253,17 @@ const zPatchGeneral = z.object({ }), }); +const zAdjustGeneralIcon = z + .object({ + type: z.literal('adjustGeneralIcon'), + requestId: z.string().min(1).optional(), + userId: z.string().min(1), + picture: z.string().min(1), + imageServer: z.number().int().nonnegative(), + iconRevision: z.string().refine(isCanonicalIsoTimestamp), + }) + .strict(); + const zJoinCreateGeneral = z .object({ type: z.literal('joinCreateGeneral'), @@ -268,6 +280,7 @@ const zJoinCreateGeneral = z profileId: z.string().min(1), ownerPicture: z.string().optional(), ownerImageServer: zFiniteNumber.optional(), + ownerIconRevision: z.string().refine(isCanonicalIsoTimestamp).optional(), ownerCanUsePicture: z.boolean().optional(), ownerLegacyPenalty: zRecord.optional(), inheritSpecial: z.string().optional(), @@ -566,6 +579,14 @@ const normalizePatchGeneral: CommandNormalizer<'patchGeneral'> = (envelope) => { return { ...command, requestId: envelope.requestId }; }; +const normalizeAdjustGeneralIcon: CommandNormalizer<'adjustGeneralIcon'> = (envelope) => { + const command = parseWith(zAdjustGeneralIcon, envelope.command); + if (!command || (command.requestId !== undefined && command.requestId !== envelope.requestId)) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizeJoinCreateGeneral: CommandNormalizer<'joinCreateGeneral'> = (envelope) => { const command = parseWith(zJoinCreateGeneral, envelope.command); if (!command) { @@ -662,6 +683,7 @@ const normalizers: CommandNormalizerMap = { adjustGeneralMeta: normalizeAdjustGeneralMeta, tournamentMatchResult: normalizeTournamentMatchResult, patchGeneral: normalizePatchGeneral, + adjustGeneralIcon: normalizeAdjustGeneralIcon, joinCreateGeneral: normalizeJoinCreateGeneral, npcPossessGeneral: normalizeNpcPossessGeneral, selectPoolCreate: normalizeSelectPoolCreate, diff --git a/app/game-engine/src/turn/joinCreateGeneralService.ts b/app/game-engine/src/turn/joinCreateGeneralService.ts index 53dfaaf..82e3773 100644 --- a/app/game-engine/src/turn/joinCreateGeneralService.ts +++ b/app/game-engine/src/turn/joinCreateGeneralService.ts @@ -51,6 +51,7 @@ export interface JoinCreateGeneralInput { profileId: string; ownerPicture?: string; ownerImageServer?: number; + ownerIconRevision?: string; ownerCanUsePicture?: boolean; ownerLegacyPenalty?: Record; inheritSpecial?: string; @@ -700,6 +701,12 @@ export const createGeneralFromJoin = async (options: { input.ownerPicture !== 'default.jpg'; const picture = canUseOwnerPicture ? input.ownerPicture! : 'default.jpg'; const imageServer = canUseOwnerPicture ? Math.max(0, Math.floor(input.ownerImageServer ?? 0)) : 0; + const accountIconUpdatedAt = + input.ownerIconRevision && + picture === input.ownerPicture && + imageServer === Math.max(0, Math.floor(input.ownerImageServer ?? 0)) + ? input.ownerIconRevision + : undefined; const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint; const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId); const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus; @@ -781,6 +788,7 @@ export const createGeneralFromJoin = async (options: { newvote: 0, inherit_spent_dyn: inheritRequiredPoint, prestart_delete_after: prestartDeleteAfter.toISOString(), + ...(accountIconUpdatedAt ? { accountIconUpdatedAt } : {}), }, }; if (!world.addGeneral(general)) { diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 687ae44..b409074 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -5,7 +5,7 @@ import type { TurnDaemonCommandResult, } from '../lifecycle/types.js'; import { GamePrisma, type DatabaseClient } from '@sammo-ts/infra'; -import { asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { asRecord, isCanonicalIsoTimestamp, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { LogCategory, LogFormat, @@ -133,7 +133,7 @@ interface CommandHandlerContext { const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => { if (!ctx.commandDb) { - throw new Error('ENGINE mutation transaction is required for selection-pool commands.'); + throw new Error('ENGINE mutation transaction is required for authenticated commands.'); } return ctx.commandDb as unknown as DatabaseClient; }; @@ -149,7 +149,8 @@ const resolveCommandAcceptedAt = async ( | 'joinCreateGeneral' | 'npcPossessGeneral' | 'selectPoolCreate' - | 'selectPoolReselect'; + | 'selectPoolReselect' + | 'adjustGeneralIcon'; } > ): Promise => { @@ -228,6 +229,9 @@ async function handleJoinCreateGeneral( profileId: command.profileId, ...(command.ownerPicture !== undefined ? { ownerPicture: command.ownerPicture } : {}), ...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}), + ...(command.ownerIconRevision !== undefined + ? { ownerIconRevision: command.ownerIconRevision } + : {}), ...(command.ownerCanUsePicture !== undefined ? { ownerCanUsePicture: command.ownerCanUsePicture } : {}), @@ -702,6 +706,78 @@ async function handlePatchGeneral( return { type: 'patchGeneral', ok: true, generalId: command.generalId }; } +async function handleAdjustGeneralIcon( + ctx: CommandHandlerContext, + command: Extract +): Promise { + const db = requireCommandDatabase(ctx); + await resolveCommandAcceptedAt(db, command); + const general = ctx.world + .listGenerals() + .find((candidate) => candidate.userId === command.userId && candidate.npcState === 0); + if (!general) { + return { + type: 'adjustGeneralIcon', + ok: true, + generalId: null, + updated: false, + }; + } + + const currentRevision = general.meta.accountIconUpdatedAt; + if (currentRevision !== undefined) { + if (typeof currentRevision !== 'string' || !isCanonicalIsoTimestamp(currentRevision)) { + return { + type: 'adjustGeneralIcon', + ok: false, + code: 'PRECONDITION_FAILED', + reason: '장수의 계정 아이콘 revision이 올바르지 않습니다.', + }; + } + const currentRevisionTime = new Date(currentRevision).getTime(); + const nextRevisionTime = new Date(command.iconRevision).getTime(); + if (nextRevisionTime < currentRevisionTime) { + return { + type: 'adjustGeneralIcon', + ok: false, + code: 'CONFLICT', + reason: '더 최신 계정 아이콘이 이미 적용되었습니다.', + }; + } + if (nextRevisionTime === currentRevisionTime) { + if (general.picture === command.picture && general.imageServer === command.imageServer) { + return { + type: 'adjustGeneralIcon', + ok: true, + generalId: general.id, + updated: false, + }; + } + return { + type: 'adjustGeneralIcon', + ok: false, + code: 'CONFLICT', + reason: '같은 revision에 다른 계정 아이콘이 요청되었습니다.', + }; + } + } + + ctx.world.updateGeneral(general.id, { + picture: command.picture, + imageServer: command.imageServer, + meta: { + ...general.meta, + accountIconUpdatedAt: command.iconRevision, + }, + }); + return { + type: 'adjustGeneralIcon', + ok: true, + generalId: general.id, + updated: true, + }; +} + async function handleShiftSchedule( ctx: CommandHandlerContext, command: Extract @@ -2296,6 +2372,8 @@ export const createTurnDaemonCommandHandler = (options: { handleTournamentMatchResult(ctx, command as Extract), patchGeneral: (command) => handlePatchGeneral(ctx, command as Extract), + adjustGeneralIcon: (command) => + handleAdjustGeneralIcon(ctx, command as Extract), joinCreateGeneral: (command) => handleJoinCreateGeneral(ctx, command as Extract), npcPossessGeneral: (command) => diff --git a/app/game-engine/test/accountIconCommand.test.ts b/app/game-engine/test/accountIconCommand.test.ts new file mode 100644 index 0000000..7c63935 --- /dev/null +++ b/app/game-engine/test/accountIconCommand.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GamePrisma } from '@sammo-ts/infra'; +import type { TurnSchedule } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; + +const revision = '2026-07-31T09:00:00.000Z'; +const requestId = `general:adjustIcon:user-1:${revision}`; +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; + +const buildGeneral = (overrides: Partial = {}): TurnGeneral => ({ + id: 1, + userId: 'user-1', + name: '아이콘장수', + nationId: 0, + cityId: 0, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + turnTime: new Date('2026-07-31T09:00:00.000Z'), + recentWarTime: null, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + officerLevel: 0, + experience: 0, + dedication: 0, + injury: 0, + gold: 0, + rice: 0, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 20, + npcState: 0, + picture: 'old.jpg', + imageServer: 0, + ...overrides, +}); + +const buildWorld = (generals: TurnGeneral[]) => { + const state: TurnWorldState = { + id: 1, + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('2026-07-31T09:00:00.000Z'), + meta: {}, + }; + const snapshot: TurnWorldSnapshot = { + generals, + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'test' }, + }, + scenarioMeta: { + title: 'test', + startYear: 190, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + }; + return new InMemoryTurnWorld(state, snapshot, { schedule }); +}; + +const buildCommandDb = (actorUserId = 'user-1') => + ({ + inputEvent: { + findUnique: vi.fn(async () => ({ + createdAt: new Date('2026-07-31T09:00:00.000Z'), + actorUserId, + target: 'ENGINE', + eventType: 'adjustGeneralIcon', + })), + }, + }) as unknown as GamePrisma.TransactionClient; + +const command = ( + overrides: Partial<{ + requestId: string; + userId: string; + picture: string; + imageServer: number; + iconRevision: string; + }> = {} +) => ({ + type: 'adjustGeneralIcon' as const, + requestId, + userId: 'user-1', + picture: 'new.png', + imageServer: 1, + iconRevision: revision, + ...overrides, +}); + +describe('adjustGeneralIcon ENGINE command', () => { + it('updates only the authenticated human general and preserves existing meta', async () => { + const human = buildGeneral(); + const possessedNpc = buildGeneral({ id: 2, npcState: 1 }); + const foreign = buildGeneral({ id: 3, userId: 'user-2' }); + const world = buildWorld([human, possessedNpc, foreign]); + const handler = createTurnDaemonCommandHandler({ world }); + + await expect(handler.handle(command(), { db: buildCommandDb() })).resolves.toEqual({ + type: 'adjustGeneralIcon', + ok: true, + generalId: human.id, + updated: true, + }); + expect(world.getGeneralById(human.id)).toMatchObject({ + picture: 'new.png', + imageServer: 1, + meta: { + killturn: 24, + accountIconUpdatedAt: revision, + }, + }); + expect(world.getGeneralById(possessedNpc.id)).toMatchObject({ picture: 'old.jpg', imageServer: 0 }); + expect(world.getGeneralById(foreign.id)).toMatchObject({ picture: 'old.jpg', imageServer: 0 }); + }); + + it('treats no human general as a successful no-op', async () => { + const world = buildWorld([buildGeneral({ id: 2, npcState: 1 }), buildGeneral({ id: 3, userId: 'user-2' })]); + const handler = createTurnDaemonCommandHandler({ world }); + + await expect(handler.handle(command(), { db: buildCommandDb() })).resolves.toEqual({ + type: 'adjustGeneralIcon', + ok: true, + generalId: null, + updated: false, + }); + }); + + it('keeps an identical revision idempotent and rejects rollback or tuple conflicts', async () => { + const world = buildWorld([ + buildGeneral({ + picture: 'new.png', + imageServer: 1, + meta: { killturn: 24, accountIconUpdatedAt: revision }, + }), + ]); + const handler = createTurnDaemonCommandHandler({ world }); + + await expect(handler.handle(command(), { db: buildCommandDb() })).resolves.toMatchObject({ + ok: true, + updated: false, + }); + await expect( + handler.handle(command({ iconRevision: '2026-07-31T08:59:59.000Z' }), { + db: buildCommandDb(), + }) + ).resolves.toMatchObject({ ok: false, code: 'CONFLICT' }); + await expect( + handler.handle(command({ picture: 'conflict.png' }), { db: buildCommandDb() }) + ).resolves.toMatchObject({ ok: false, code: 'CONFLICT' }); + expect(world.getGeneralById(1)).toMatchObject({ picture: 'new.png', imageServer: 1 }); + }); + + it('fails closed for a corrupt watermark or mismatched input-event actor', async () => { + const corruptWorld = buildWorld([buildGeneral({ meta: { killturn: 24, accountIconUpdatedAt: 'not-a-date' } })]); + const corruptHandler = createTurnDaemonCommandHandler({ world: corruptWorld }); + await expect(corruptHandler.handle(command(), { db: buildCommandDb() })).resolves.toMatchObject({ + ok: false, + code: 'PRECONDITION_FAILED', + }); + + const actorWorld = buildWorld([buildGeneral()]); + const actorHandler = createTurnDaemonCommandHandler({ world: actorWorld }); + await expect(actorHandler.handle(command(), { db: buildCommandDb('user-2') })).rejects.toThrow( + 'actor does not match' + ); + expect(actorWorld.getGeneralById(1)).toMatchObject({ picture: 'old.jpg', imageServer: 0 }); + }); +}); diff --git a/app/game-engine/test/accountIconCommandRegistry.test.ts b/app/game-engine/test/accountIconCommandRegistry.test.ts new file mode 100644 index 0000000..61585c6 --- /dev/null +++ b/app/game-engine/test/accountIconCommandRegistry.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import type { TurnDaemonCommand } from '@sammo-ts/common'; + +import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js'; + +const normalize = (command: Record) => + normalizeTurnDaemonCommand({ + requestId: 'icon-request', + sentAt: '2026-07-31T09:00:00.000Z', + command: command as unknown as TurnDaemonCommand, + }); + +describe('adjustGeneralIcon command registry', () => { + it('accepts only a canonical authenticated icon projection', () => { + expect( + normalize({ + type: 'adjustGeneralIcon', + userId: 'user-1', + picture: 'owner.png', + imageServer: 1, + iconRevision: '2026-07-31T09:00:00.000Z', + }) + ).toEqual({ + type: 'adjustGeneralIcon', + requestId: 'icon-request', + userId: 'user-1', + picture: 'owner.png', + imageServer: 1, + iconRevision: '2026-07-31T09:00:00.000Z', + }); + expect( + normalize({ + type: 'adjustGeneralIcon', + requestId: 'icon-request', + userId: 'user-1', + picture: 'owner.png', + imageServer: 1, + iconRevision: '2026-07-31T09:00:00.000Z', + }) + ).toEqual({ + type: 'adjustGeneralIcon', + requestId: 'icon-request', + userId: 'user-1', + picture: 'owner.png', + imageServer: 1, + iconRevision: '2026-07-31T09:00:00.000Z', + }); + }); + + it.each([ + ['empty owner', { userId: '' }], + ['empty picture', { picture: '' }], + ['negative image server', { imageServer: -1 }], + ['fractional image server', { imageServer: 1.5 }], + ['noncanonical revision', { iconRevision: '2026-07-31T09:00:00Z' }], + ['mismatched durable request ID', { requestId: 'different-request' }], + ['unknown field', { generalId: 1 }], + ])('rejects %s', (_label, override) => { + expect( + normalize({ + type: 'adjustGeneralIcon', + userId: 'user-1', + picture: 'owner.png', + imageServer: 1, + iconRevision: '2026-07-31T09:00:00.000Z', + ...override, + }) + ).toBeNull(); + }); +}); diff --git a/app/game-engine/test/accountIconPersistence.integration.test.ts b/app/game-engine/test/accountIconPersistence.integration.test.ts new file mode 100644 index 0000000..dd73e29 --- /dev/null +++ b/app/game-engine/test/accountIconPersistence.integration.test.ts @@ -0,0 +1,460 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common'; +import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; +import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic'; + +import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { EngineStateManager } from '../src/turn/engineStateManager.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; +import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js'; + +const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const worldId = 991_741; +const targetGeneralId = 991_741; +const npcGeneralId = 991_742; +const foreignGeneralId = 991_743; +const rollbackRequestId = 'integration:engine:account-icon:rollback'; +const actorMismatchRequestId = 'integration:engine:account-icon:actor-mismatch'; +const successRequestId = 'integration:engine:account-icon:success'; +const noGeneralRequestId = 'integration:engine:account-icon:no-general'; +const rollbackConstraint = 'account_icon_rollback_test'; +const targetUserId = 'account-icon-target-user'; +const npcUserId = 'account-icon-npc-user'; +const foreignUserId = 'account-icon-foreign-user'; +const initialPicture = 'account-icon-old.jpg'; +const initialImageServer = 0; +const nextPicture = 'account-icon-new.png'; +const nextImageServer = 1; +const rollbackRevision = '2026-07-31T09:05:00.000Z'; +const successRevision = '2026-07-31T09:10:00.000Z'; +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; + +const assertDedicatedDatabase = (rawUrl: string): void => { + const schema = new URL(rawUrl).searchParams.get('schema'); + if (!schema?.endsWith('immediate_action_integration')) { + throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`); + } +}; + +const scenarioConfig: ScenarioConfig = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 }, + iconPath: '', + map: {}, + const: {}, + environment: { + mapName: 'che', + unitSet: 'che', + }, +}; + +const scenarioMeta: ScenarioMeta = { + title: '계정 아이콘 영속성 통합', + startYear: 190, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, +}; + +const map: MapDefinition = { + id: 'account-icon-integration', + name: '계정 아이콘 영속성 통합', + cities: [], +}; + +const state: TurnWorldState = { + id: worldId, + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('2026-07-31T09:00:00.000Z'), + meta: { + killturn: 24, + lastTurnTime: '2026-07-31T09:00:00.000Z', + scenarioMeta, + }, +}; + +const buildGeneral = (overrides: Partial): TurnGeneral => ({ + id: targetGeneralId, + userId: targetUserId, + name: '아이콘통합장수', + nationId: 0, + cityId: 0, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + turnTime: new Date('2026-07-31T09:10:00.000Z'), + recentWarTime: null, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24, preserved: 'yes' }, + penalty: {}, + officerLevel: 0, + experience: 0, + dedication: 0, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 20, + npcState: 0, + picture: initialPicture, + imageServer: initialImageServer, + ...overrides, +}); + +const targetGeneral = buildGeneral({}); +const npcGeneral = buildGeneral({ + id: npcGeneralId, + userId: npcUserId, + name: '빙의NPC', + npcState: 1, +}); +const foreignGeneral = buildGeneral({ + id: foreignGeneralId, + userId: foreignUserId, + name: '타사용자장수', +}); +const generals = [targetGeneral, npcGeneral, foreignGeneral]; + +const buildCommand = ( + requestId: string, + userId: string, + iconRevision: string, + picture = nextPicture, + imageServer = nextImageServer +): Extract => ({ + type: 'adjustGeneralIcon', + requestId, + userId, + picture, + imageServer, + iconRevision, +}); + +const toGeneralCreate = (general: TurnGeneral): GamePrisma.GeneralCreateManyInput => ({ + id: general.id, + userId: general.userId, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + troopId: general.troopId, + npcState: general.npcState, + leadership: general.stats.leadership, + strength: general.stats.strength, + intel: general.stats.intelligence, + officerLevel: general.officerLevel, + experience: general.experience, + dedication: general.dedication, + injury: general.injury, + gold: general.gold, + rice: general.rice, + crew: general.crew, + crewTypeId: general.crewTypeId, + train: general.train, + atmos: general.atmos, + turnTime: general.turnTime, + recentWarTime: general.recentWarTime, + age: general.age, + picture: general.picture, + imageServer: general.imageServer, + meta: general.meta as GamePrisma.InputJsonValue, + penalty: general.penalty as GamePrisma.InputJsonValue, +}); + +integration('adjustGeneralIcon PostgreSQL persistence', () => { + let db: GamePrismaClient; + let disconnect: (() => Promise) | undefined; + let hooks: DatabaseTurnHooks | undefined; + + beforeAll(async () => { + assertDedicatedDatabase(databaseUrl!); + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + disconnect = () => connector.disconnect(); + + await db.$executeRawUnsafe(`ALTER TABLE input_event DROP CONSTRAINT IF EXISTS ${rollbackConstraint}`); + await db.inputEvent.deleteMany({ + where: { + requestId: { + in: [rollbackRequestId, actorMismatchRequestId, successRequestId, noGeneralRequestId], + }, + }, + }); + await db.rankData.deleteMany({ + where: { generalId: { in: generals.map((general) => general.id) } }, + }); + await db.general.deleteMany({ + where: { id: { in: generals.map((general) => general.id) } }, + }); + await db.worldState.deleteMany({ where: { id: worldId } }); + + await db.worldState.create({ + data: { + id: worldId, + scenarioCode: 'account-icon-integration', + currentYear: state.currentYear, + currentMonth: state.currentMonth, + tickSeconds: state.tickSeconds, + config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, + meta: state.meta as GamePrisma.InputJsonValue, + }, + }); + await db.general.createMany({ + data: generals.map(toGeneralCreate), + }); + }); + + afterAll(async () => { + await hooks?.close(); + if (!db) { + await disconnect?.(); + return; + } + await db.$executeRawUnsafe(`ALTER TABLE input_event DROP CONSTRAINT IF EXISTS ${rollbackConstraint}`); + await db.inputEvent.deleteMany({ + where: { + requestId: { + in: [rollbackRequestId, actorMismatchRequestId, successRequestId, noGeneralRequestId], + }, + }, + }); + await db.rankData.deleteMany({ + where: { generalId: { in: generals.map((general) => general.id) } }, + }); + await db.general.deleteMany({ + where: { id: { in: generals.map((general) => general.id) } }, + }); + await db.worldState.deleteMany({ where: { id: worldId } }); + await disconnect?.(); + }); + + it('commits the authenticated human icon with input_event and rolls both back on failure', async () => { + const snapshot: TurnWorldSnapshot = { + generals, + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig, + scenarioMeta, + map, + }; + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + const handler = createTurnDaemonCommandHandler({ world }); + hooks = await createDatabaseTurnHooks(databaseUrl!, world); + const stateManager = new EngineStateManager(); + stateManager.register('world', { + capture: () => world.captureState(), + restore: (captured) => world.restoreState(captured), + }); + const execute = async ( + command: Extract + ): Promise => { + if (!hooks?.hooks.executeCommand || !command.requestId) { + throw new Error('Database command execution hook is unavailable.'); + } + return stateManager.transaction(() => + hooks!.hooks.executeCommand!(command.requestId!, async (context) => { + const result = await handler.handle(command, context); + if (!result) { + throw new Error('adjustGeneralIcon command was not handled.'); + } + return result; + }) + ); + }; + const createInputEvent = async ( + command: Extract, + actorUserId = command.userId + ): Promise => { + await db.inputEvent.create({ + data: { + requestId: command.requestId!, + target: 'ENGINE', + eventType: command.type, + actorUserId, + status: 'PROCESSING', + lockedBy: 'account-icon-integration-worker', + leaseUntil: new Date('2026-07-31T09:30:00.000Z'), + attempts: 1, + payload: command as GamePrisma.InputJsonValue, + }, + }); + }; + + const rollbackCommand = buildCommand(rollbackRequestId, targetUserId, rollbackRevision, 'must-rollback.png', 7); + await createInputEvent(rollbackCommand); + await db.$executeRawUnsafe(` + ALTER TABLE input_event + ADD CONSTRAINT ${rollbackConstraint} + CHECK (request_id <> '${rollbackRequestId}' OR status <> 'SUCCEEDED') + `); + await expect(execute(rollbackCommand)).rejects.toThrow(`violates check constraint "${rollbackConstraint}"`); + expect(world.getGeneralById(targetGeneralId)).toMatchObject({ + picture: initialPicture, + imageServer: initialImageServer, + meta: { killturn: 24, preserved: 'yes' }, + }); + await expect(db.general.findUniqueOrThrow({ where: { id: targetGeneralId } })).resolves.toMatchObject({ + picture: initialPicture, + imageServer: initialImageServer, + meta: { killturn: 24, preserved: 'yes' }, + }); + await expect( + db.inputEvent.findUniqueOrThrow({ where: { requestId: rollbackRequestId } }) + ).resolves.toMatchObject({ + status: 'PROCESSING', + result: null, + lockedBy: 'account-icon-integration-worker', + }); + await db.$executeRawUnsafe(`ALTER TABLE input_event DROP CONSTRAINT ${rollbackConstraint}`); + + const actorMismatchCommand = buildCommand( + actorMismatchRequestId, + targetUserId, + rollbackRevision, + 'actor-mismatch.png', + 8 + ); + await createInputEvent(actorMismatchCommand, foreignUserId); + await expect(execute(actorMismatchCommand)).rejects.toThrow('actor does not match'); + expect(world.getGeneralById(targetGeneralId)).toMatchObject({ + picture: initialPicture, + imageServer: initialImageServer, + }); + await expect(db.general.findUniqueOrThrow({ where: { id: targetGeneralId } })).resolves.toMatchObject({ + picture: initialPicture, + imageServer: initialImageServer, + }); + await expect( + db.inputEvent.findUniqueOrThrow({ where: { requestId: actorMismatchRequestId } }) + ).resolves.toMatchObject({ + status: 'PROCESSING', + result: null, + }); + + const successCommand = buildCommand(successRequestId, targetUserId, successRevision); + await createInputEvent(successCommand); + await expect(execute(successCommand)).resolves.toEqual({ + type: 'adjustGeneralIcon', + ok: true, + generalId: targetGeneralId, + updated: true, + }); + expect(world.getGeneralById(targetGeneralId)).toMatchObject({ + picture: nextPicture, + imageServer: nextImageServer, + meta: { + killturn: 24, + preserved: 'yes', + accountIconUpdatedAt: successRevision, + }, + }); + expect(world.getGeneralById(npcGeneralId)).toMatchObject({ + picture: initialPicture, + imageServer: initialImageServer, + }); + expect(world.getGeneralById(foreignGeneralId)).toMatchObject({ + picture: initialPicture, + imageServer: initialImageServer, + }); + await expect(db.general.findUniqueOrThrow({ where: { id: targetGeneralId } })).resolves.toMatchObject({ + picture: nextPicture, + imageServer: nextImageServer, + meta: { + killturn: 24, + preserved: 'yes', + accountIconUpdatedAt: successRevision, + }, + }); + await expect(db.general.findUniqueOrThrow({ where: { id: npcGeneralId } })).resolves.toMatchObject({ + picture: initialPicture, + imageServer: initialImageServer, + }); + await expect(db.general.findUniqueOrThrow({ where: { id: foreignGeneralId } })).resolves.toMatchObject({ + picture: initialPicture, + imageServer: initialImageServer, + }); + await expect( + db.inputEvent.findUniqueOrThrow({ where: { requestId: successRequestId } }) + ).resolves.toMatchObject({ + status: 'SUCCEEDED', + result: { + type: 'adjustGeneralIcon', + ok: true, + generalId: targetGeneralId, + updated: true, + }, + error: null, + lockedBy: null, + leaseUntil: null, + completedAt: expect.any(Date), + }); + + const noGeneralCommand = buildCommand(noGeneralRequestId, npcUserId, successRevision, 'npc-must-stay.png', 9); + await createInputEvent(noGeneralCommand); + await expect(execute(noGeneralCommand)).resolves.toEqual({ + type: 'adjustGeneralIcon', + ok: true, + generalId: null, + updated: false, + }); + expect(world.getGeneralById(npcGeneralId)).toMatchObject({ + picture: initialPicture, + imageServer: initialImageServer, + }); + await expect(db.general.findUniqueOrThrow({ where: { id: npcGeneralId } })).resolves.toMatchObject({ + picture: initialPicture, + imageServer: initialImageServer, + meta: { killturn: 24, preserved: 'yes' }, + }); + await expect( + db.inputEvent.findUniqueOrThrow({ where: { requestId: noGeneralRequestId } }) + ).resolves.toMatchObject({ + status: 'SUCCEEDED', + result: { + type: 'adjustGeneralIcon', + ok: true, + generalId: null, + updated: false, + }, + }); + + const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + expect(reloaded.snapshot.generals.find((entry) => entry.id === targetGeneralId)).toMatchObject({ + picture: nextPicture, + imageServer: nextImageServer, + meta: { + killturn: 24, + preserved: 'yes', + accountIconUpdatedAt: successRevision, + }, + }); + expect(reloaded.snapshot.generals.find((entry) => entry.id === npcGeneralId)).toMatchObject({ + picture: initialPicture, + imageServer: initialImageServer, + npcState: 1, + }); + expect(reloaded.snapshot.generals.find((entry) => entry.id === foreignGeneralId)).toMatchObject({ + picture: initialPicture, + imageServer: initialImageServer, + userId: foreignUserId, + }); + }); +}); diff --git a/app/game-engine/test/gatewayRuntimeAction.integration.test.ts b/app/game-engine/test/gatewayRuntimeAction.integration.test.ts index 1429e68..8be39b2 100644 --- a/app/game-engine/test/gatewayRuntimeAction.integration.test.ts +++ b/app/game-engine/test/gatewayRuntimeAction.integration.test.ts @@ -9,6 +9,14 @@ const integration = describe.skipIf(!databaseUrl); const profileName = 'runtime:consumer-integration'; const actionId = '924f40ec-e9d2-432f-9867-e9fb3199f14a'; +const assertDedicatedSchema = (): void => { + const expected = process.env.GATEWAY_RUNTIME_INTEGRATION_SCHEMA; + const actual = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null; + if (!expected || !expected.endsWith('_gateway_runtime_integration') || actual !== expected) { + throw new Error('Refusing to mutate a Gateway database outside the runner-owned integration schema.'); + } +}; + const waitForApplied = async (db: GatewayPrismaClient): Promise => { const deadline = Date.now() + 4_000; while (Date.now() < deadline) { @@ -27,11 +35,14 @@ const waitForApplied = async (db: GatewayPrismaClient): Promise => { integration('gateway runtime action consumer', () => { let db: GatewayPrismaClient; let closeDb: (() => Promise) | undefined; + let initialized = false; beforeAll(async () => { + assertDedicatedSchema(); const connector = createGatewayPostgresConnector({ url: databaseUrl! }); await connector.connect(); db = connector.prisma; + initialized = true; closeDb = () => connector.disconnect(); await db.gatewayProfile.upsert({ where: { profileName }, @@ -48,8 +59,10 @@ integration('gateway runtime action consumer', () => { }); afterAll(async () => { - await db.gatewayRuntimeAction.deleteMany({ where: { profileName } }); - await db.gatewayProfile.deleteMany({ where: { profileName } }); + if (initialized) { + await db.gatewayRuntimeAction.deleteMany({ where: { profileName } }); + await db.gatewayProfile.deleteMany({ where: { profileName } }); + } await closeDb?.(); }); diff --git a/app/game-frontend/e2e/directoryLists.spec.ts b/app/game-frontend/e2e/directoryLists.spec.ts index 7a743dc..dfc06f4 100644 --- a/app/game-frontend/e2e/directoryLists.spec.ts +++ b/app/game-frontend/e2e/directoryLists.spec.ts @@ -4,6 +4,8 @@ import { resolve } from 'node:path'; import { expect, test, type Page, type Route } from '@playwright/test'; const response = (data: unknown) => ({ result: { data } }); +const gameBasePath = (process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/gu, ''); +const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? `${gameBasePath}:default`; const operationNames = (route: Route) => decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); @@ -72,8 +74,8 @@ const generals = [ id: 10, name: '조조', ownerName: null, - picture: 'default.jpg', - imageServer: 0, + picture: '계정 icon.png', + imageServer: 1, npcState: 0, age: 35, nationId: 1, @@ -101,7 +103,7 @@ const generals = [ id: 20, name: '유비', ownerName: '통일유저', - picture: 'default.jpg', + picture: '장수/유비 1.png', imageServer: 0, npcState: 0, age: 34, @@ -145,10 +147,10 @@ const install = async ( accessPages: string[] = [] ) => { let generalDirectoryCalls = 0; - await page.addInitScript(() => { + await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_directory'); - localStorage.setItem('sammo-game-profile', 'che:default'); - }); + localStorage.setItem('sammo-game-profile', profile); + }, gameProfile); await page.route('**/image/general/**', (route) => route.fulfill({ status: 200, @@ -156,10 +158,24 @@ const install = async ( body: '', }) ); + await page.route('**/gateway/api/user-icons/**', (route) => + route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: '', + }) + ); + await page.route('**/image/icons/**', (route) => + route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: '', + }) + ); await page.route('**/image/game/**', (route) => route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') }) ); - await page.route('**/che/api/trpc/**', async (route) => { + await page.route(`**/${gameBasePath}/api/trpc/**`, async (route) => { const requestBody = route.request().postDataJSON() as Record | undefined; const results = operationNames(route).map((operation, operationIndex) => { @@ -202,12 +218,12 @@ const install = async ( }; const installAccessBoundary = async (page: Page, accessPages: string[]) => { - await page.addInitScript(() => { + await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_access_boundary'); - localStorage.setItem('sammo-game-profile', 'che:default'); - }); + localStorage.setItem('sammo-game-profile', profile); + }, gameProfile); await page.route('**/image/**', (route) => route.abort('failed')); - await page.route('**/che/api/trpc/**', async (route) => { + await page.route(`**/${gameBasePath}/api/trpc/**`, async (route) => { const requestBody = route.request().postDataJSON() as Record | undefined; const results = operationNames(route).map((operation, operationIndex) => { @@ -315,6 +331,7 @@ test('nation and general directories preserve the fixed legacy Chromium geometry expect(await header.evaluate((element) => getComputedStyle(element).backgroundImage)).toContain('back_green.jpg'); const icon = page.locator('.general-icon').first(); await expect(icon).toBeVisible(); + await expect(icon).toHaveAttribute('src', '/gateway/api/user-icons/%EA%B3%84%EC%A0%95%20icon.png'); expect( await icon.evaluate((element) => { const image = element as HTMLImageElement; @@ -328,6 +345,11 @@ test('nation and general directories preserve the fixed legacy Chromium geometry }; }) ).toEqual({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64, objectFit: 'fill' }); + const nestedLegacyIcon = page.locator('.general-icon').nth(1); + await expect(nestedLegacyIcon).toHaveAttribute('src', '/image/general/%EC%9E%A5%EC%88%98/%EC%9C%A0%EB%B9%84%201.png'); + await expect + .poll(() => nestedLegacyIcon.evaluate((element) => (element as HTMLImageElement).naturalWidth)) + .toBe(64); const artifactRoot = process.env.DIRECTORY_PARITY_ARTIFACT_DIR; if (artifactRoot) { @@ -354,6 +376,23 @@ test('general directory submits the legacy sort selector and keeps wounded/bonus expect(await page.locator('#viewType').evaluate((element) => document.activeElement === element)).toBe(true); }); +test('a reused image element falls back for each newly broken account icon', async ({ page }) => { + await install(page); + await page.route('**/gateway/api/user-icons/**', (route) => + route.fulfill({ status: 404, contentType: 'text/plain', body: 'missing' }) + ); + await page.goto('general-list'); + + const icon = page.locator('.general-icon').first(); + await expect(icon).toHaveAttribute('src', /\/image\/icons\/default\.jpg$/); + await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64); + await icon.evaluate((element) => { + (element as HTMLImageElement).src = '/gateway/api/user-icons/second-missing.png'; + }); + await expect(icon).toHaveAttribute('src', /\/image\/icons\/default\.jpg$/); + await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64); +}); + test('a failed resort retains the selected value and existing rows', async ({ page }) => { await install(page, 'error-after-load'); await page.goto('general-list'); @@ -368,7 +407,7 @@ test('an authenticated account without a general is redirected away from both di await install(page, 'no-general'); for (const path of ['nation-list', 'general-list']) { await page.goto(path); - await expect(page).toHaveURL(/\/che\/join$/); + await expect(page).toHaveURL(new RegExp(`/${gameBasePath}/join$`, 'u')); } }); @@ -388,7 +427,9 @@ test('route access belongs only to the eight Ref page boundaries', async ({ page for (const [path, pageName] of retained) { const before = accessPages.length; await page.goto(path); + await expect(page.locator('#app')).toHaveAttribute('data-v-app', ''); await expect.poll(() => accessPages.length).toBe(before + 1); + await page.waitForLoadState('networkidle'); expect(accessPages.at(-1)).toBe(pageName); } @@ -415,7 +456,7 @@ test('route access belongs only to the eight Ref page boundaries', async ({ page for (const path of endpointOwned) { const before = accessPages.length; await page.goto(path); - await page.waitForTimeout(50); + await page.waitForLoadState('networkidle'); expect(accessPages).toHaveLength(before); } }); diff --git a/app/game-frontend/e2e/selectGeneralLive.spec.ts b/app/game-frontend/e2e/selectGeneralLive.spec.ts index 4f576e2..d52f782 100644 --- a/app/game-frontend/e2e/selectGeneralLive.spec.ts +++ b/app/game-frontend/e2e/selectGeneralLive.spec.ts @@ -220,7 +220,7 @@ test.describe('scenario 903 live selection pool', () => { ).toBe(true); expect(geometry.images.every((image) => image.objectFit === 'fill')).toBe(true); const fallbackImages = page.locator( - '.card-holder .portrait img[data-fallback-applied="true"]' + '.card-holder .portrait img[data-general-icon-fallback-source]' ); await expect(fallbackImages).not.toHaveCount(0); expect(assetTracker.userIconRequests).toBe(await fallbackImages.count()); @@ -389,7 +389,7 @@ test.describe('scenario 903 live selection pool', () => { const candidateCards = page.locator('.card-holder > .general-card'); const fallbackCard = candidateCards - .filter({ has: page.locator('img[data-fallback-applied="true"]') }) + .filter({ has: page.locator('img[data-general-icon-fallback-source]') }) .first(); await expect(fallbackCard).toBeVisible(); const initialName = await fallbackCard.locator('h4').first().textContent(); @@ -397,7 +397,7 @@ test.describe('scenario 903 live selection pool', () => { await fallbackCard.locator('.select-button').click(); await expect(page.locator('.selected-card')).toHaveCount(1); await expect( - page.locator('.selected-card img[data-fallback-applied="true"]') + page.locator('.selected-card img[data-general-icon-fallback-source]') ).toBeVisible(); expect(assetTracker.userIconRequests).toBeGreaterThan(userIconRequestsBeforePreview); await page.locator('.custom-form select').selectOption('che_안전'); diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 2fbdda0..c36d02f 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -7,6 +7,8 @@ "dev": "vite", "build": "vue-tsc && vite build", "preview": "vite preview", + "test": "pnpm test:unit", + "test:unit": "node --test test/**/*.test.ts", "test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs", "test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs", "test:e2e:npc-policy": "playwright test npcPolicy.spec.ts --config e2e/playwright.config.mjs", @@ -20,7 +22,6 @@ "test:e2e:die-on-prestart-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/dieOnPrestart.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "lint": "eslint .", "lint:fix": "eslint . --fix", - "test": "node -e \"console.log('test not configured')\"", "typecheck": "vue-tsc --noEmit" }, "dependencies": { diff --git a/app/game-frontend/src/components/main/MessagePlate.vue b/app/game-frontend/src/components/main/MessagePlate.vue index fc665d0..f678161 100644 --- a/app/game-frontend/src/components/main/MessagePlate.vue +++ b/app/game-frontend/src/components/main/MessagePlate.vue @@ -1,6 +1,7 @@