feat: synchronize account icons across game profiles
This commit is contained in:
@@ -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<AccountIconProjection | null>;
|
||||
}
|
||||
|
||||
export interface AccountIconResetProjection {
|
||||
userId: string;
|
||||
resetRevision: string;
|
||||
current: AccountIconProjection;
|
||||
}
|
||||
|
||||
export interface AccountIconResetSource {
|
||||
listResets(userIds: string[]): Promise<AccountIconResetProjection[]>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
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<AccountIconProjection | null> {
|
||||
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<AccountIconResetProjection[]> {
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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> | void;
|
||||
private readonly onFlushError?: (error: unknown, event: GatewayUserFlushEvent) => void;
|
||||
private readonly pendingFlushes = new Set<Promise<void>>();
|
||||
|
||||
constructor(
|
||||
client: {
|
||||
@@ -42,11 +46,15 @@ export class RedisGatewayFlushSubscriber {
|
||||
unsubscribe: (channel: string) => Promise<void>;
|
||||
},
|
||||
channel: string,
|
||||
store: FlushStore
|
||||
store: FlushStore,
|
||||
onFlush?: (event: GatewayUserFlushEvent) => Promise<void> | 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<void> {
|
||||
@@ -57,6 +65,23 @@ export class RedisGatewayFlushSubscriber {
|
||||
return;
|
||||
}
|
||||
this.store.applyFlush(payload);
|
||||
if (this.onFlush) {
|
||||
let flush: Promise<void>;
|
||||
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<void> {
|
||||
await this.client.unsubscribe(this.channel);
|
||||
await Promise.all(this.pendingFlushes);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-16
@@ -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`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, number> => {
|
||||
};
|
||||
|
||||
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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
+105
-23
@@ -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<void> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<void> | 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<void> {
|
||||
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<Error[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
await this.inFlight;
|
||||
}
|
||||
}
|
||||
@@ -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<AccountIconProjection> => {
|
||||
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<boolean> => {
|
||||
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<void> => {
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface ResourceCleanupStep {
|
||||
name: string;
|
||||
run: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 종료 단계 하나가 실패해도 나머지 연결을 모두 닫고, 다음 호출에서는 실패한
|
||||
* 단계만 재시도합니다. 동시에 들어온 종료 요청은 같은 실행을 공유합니다.
|
||||
*/
|
||||
export const createBestEffortResourceCloser = (steps: readonly ResourceCleanupStep[]): (() => Promise<void>) => {
|
||||
const completed = new Set<string>();
|
||||
let closing: Promise<void> | null = null;
|
||||
|
||||
return async (): Promise<void> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<void>) | 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<void> | 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);
|
||||
});
|
||||
@@ -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) });
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<void>((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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<void>((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);
|
||||
});
|
||||
});
|
||||
@@ -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<unknown>;
|
||||
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 }));
|
||||
|
||||
Reference in New Issue
Block a user