Files
core2026/app/game-api/src/context.ts
T
Hide_D 5bc1e3473f perf: 접속 점수를 Redis 배치로 지연 반영
대시보드 벌점을 read-model source와 outbox에서 제외하고 수동 projection의 가점을 Redis에 합산한다. 5초 worker가 월별 traffic과 접속 점수를 멱등 PostgreSQL batch로 반영하며 메인 제한 gate는 Redis TTL marker만 조회한다.
2026-08-17 14:50:34 +00:00

160 lines
6.6 KiB
TypeScript

import { z } from 'zod';
import type { ChangeJournal } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { DatabaseClient as InfraDatabaseClient, RedisConnector, GamePrisma } from '@sammo-ts/infra';
import { normalizeScenarioEffect, SCENARIO_EFFECT_KEYS } from '@sammo-ts/logic';
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';
import type { ProfileStatusSource } from './auth/profileStatusSource.js';
import type { ContentImageUploadStore } from './services/remoteContentImageStore.js';
import type { ReadModelOutboxWakeup } from './realtime/outboxWorker.js';
export interface GameProfile {
id: string;
scenario: string;
name: string;
}
export const zWorldStateConfig = z.object({
maxUserCnt: z.number().optional(),
fictionMode: z.string().optional(),
fiction: z.number().optional(),
joinMode: z.string().optional(),
blockGeneralCreate: z.number().optional(),
npcMode: z.number().optional(),
showImgLevel: z.number().optional(),
tournamentTrig: z.boolean().optional(),
extendedGeneral: z.boolean().optional(),
turnTermMinutes: z.number().optional(),
syncTurnTime: z.boolean().optional(),
environment: z
.object({
scenarioEffect: z
.union([z.enum(SCENARIO_EFFECT_KEYS), z.literal(''), z.literal('None'), z.null()])
.transform(normalizeScenarioEffect)
.optional(),
})
.optional(),
});
export type WorldStateConfig = z.infer<typeof zWorldStateConfig>;
export const zWorldStateMeta = z.object({
starttime: z.string().optional(),
opentime: z.string().optional(),
preopenAt: z.string().optional(),
turntime: z.string().optional(),
otherTextInfo: z.string().optional(),
isUnited: z.number().optional(),
isunited: z.number().optional(),
autorun_user: z
.object({
limit_minutes: z.number().optional(),
options: z.record(z.string(), z.boolean()).optional(),
})
.nullable()
.optional(),
});
export type WorldStateMeta = z.infer<typeof zWorldStateMeta>;
type PrismaWorldStateRow = GamePrisma.WorldStateGetPayload<Record<string, never>>;
type PrismaGeneralRow = GamePrisma.GeneralGetPayload<Record<string, never>>;
type WorldClockFields = 'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick';
type GeneralClockFields = 'turnTick' | 'recentWarTick';
// Transitional API fixtures may still model the pre-clock row. Runtime Prisma
// rows always include these nullable columns after migration.
export type WorldStateRow = Omit<PrismaWorldStateRow, WorldClockFields> &
Partial<Pick<PrismaWorldStateRow, WorldClockFields>>;
export type GeneralRow = Omit<PrismaGeneralRow, GeneralClockFields> &
Partial<Pick<PrismaGeneralRow, GeneralClockFields>>;
export type GeneralTurnRow = GamePrisma.GeneralTurnGetPayload<Record<string, never>>;
export type NationTurnRow = GamePrisma.NationTurnGetPayload<Record<string, never>>;
export type CityRow = GamePrisma.CityGetPayload<Record<string, never>>;
export type NationRow = GamePrisma.NationGetPayload<Record<string, never>>;
export type TroopRow = GamePrisma.TroopGetPayload<Record<string, never>>;
export type JsonValue = GamePrisma.JsonValue;
export type JsonObject = GamePrisma.JsonObject;
export type JsonArray = GamePrisma.JsonArray;
export type InputJsonValue = GamePrisma.InputJsonValue;
export type DatabaseClient = InfraDatabaseClient;
export interface GameApiContext {
requestId?: string;
generalAccessTracking?: boolean;
/** Validated server-issued proof for one realtime refresh burst. */
realtimeAccessGranted?: boolean;
/** Set only while an API input-event transaction owns the mutation. */
changeJournal?: ChangeJournal;
/** Post-commit scheduling hint for the durable outbox dispatcher. */
readModelOutbox?: ReadModelOutboxWakeup;
db: DatabaseClient;
redis: RedisConnector['client'];
turnDaemon: TurnDaemonTransport;
battleSim: BattleSimTransport;
profile: GameProfile;
uploadDir: string;
uploadPath: string;
uploadPublicUrl: string | null;
contentImageUpload?: ContentImageUploadStore;
auth: GameSessionTokenPayload | null;
accessToken?: string;
accessTokenStore: RedisAccessTokenStore;
flushStore: FlushStore;
gameTokenSecret: string;
accountIconSource?: AccountIconSource;
// Runtime context always supplies this. Partial router fixtures may omit it;
// access scoring then fails open and never penalizes a test-only request.
profileStatusSource?: ProfileStatusSource;
}
export const createGameApiContext = (options: {
requestId?: string;
realtimeAccessGranted?: boolean;
db: DatabaseClient;
redis: RedisConnector['client'];
turnDaemon: TurnDaemonTransport;
battleSim: BattleSimTransport;
profile: GameProfile;
uploadDir: string;
uploadPath: string;
uploadPublicUrl: string | null;
contentImageUpload?: ContentImageUploadStore;
auth: GameSessionTokenPayload | null;
accessToken?: string;
accessTokenStore: RedisAccessTokenStore;
flushStore: FlushStore;
gameTokenSecret: string;
accountIconSource?: AccountIconSource;
profileStatusSource: ProfileStatusSource;
readModelOutbox?: ReadModelOutboxWakeup;
}): GameApiContext => {
return {
requestId: options.requestId,
generalAccessTracking: true,
...(options.realtimeAccessGranted ? { realtimeAccessGranted: true } : {}),
db: options.db,
redis: options.redis,
turnDaemon: options.turnDaemon,
battleSim: options.battleSim,
profile: options.profile,
uploadDir: options.uploadDir,
uploadPath: options.uploadPath,
uploadPublicUrl: options.uploadPublicUrl,
...(options.contentImageUpload ? { contentImageUpload: options.contentImageUpload } : {}),
auth: options.auth,
...(options.accessToken ? { accessToken: options.accessToken } : {}),
accessTokenStore: options.accessTokenStore,
flushStore: options.flushStore,
gameTokenSecret: options.gameTokenSecret,
...(options.accountIconSource ? { accountIconSource: options.accountIconSource } : {}),
profileStatusSource: options.profileStatusSource,
...(options.readModelOutbox ? { readModelOutbox: options.readModelOutbox } : {}),
};
};