Merge branch 'main' into feature/ingame-message-parity

This commit is contained in:
2026-07-26 05:38:17 +00:00
42 changed files with 2094 additions and 336 deletions
@@ -4,16 +4,20 @@ import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportRes
import { processBattleSimJob } from './processor.js';
export class InMemoryBattleSimTransport {
private readonly results = new Map<string, BattleSimResultPayload>();
private readonly results = new Map<string, { requesterUserId: string; payload: BattleSimResultPayload }>();
public async simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse> {
public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse> {
const jobId = crypto.randomUUID();
const result = processBattleSimJob(payload);
this.results.set(jobId, result);
this.results.set(jobId, { requesterUserId, payload: result });
return { status: 'completed', jobId, payload: result };
}
public async getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null> {
return this.results.get(jobId) ?? null;
public async getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
const result = this.results.get(jobId);
if (!result || result.requesterUserId !== requesterUserId) {
return null;
}
return result.payload;
}
}
+22 -17
View File
@@ -44,16 +44,16 @@ export class RedisBattleSimTransport {
this.resultTtlSeconds = options.resultTtlSeconds;
}
private buildResultKey(jobId: string): string {
return `${this.keys.resultKeyPrefix}${jobId}`;
private buildResultKey(jobId: string, requesterUserId: string): string {
return `${this.keys.resultKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`;
}
private buildNotifyKey(jobId: string): string {
return `${this.keys.notifyKeyPrefix}${jobId}`;
private buildNotifyKey(jobId: string, requesterUserId: string): string {
return `${this.keys.notifyKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`;
}
private async readResult(jobId: string): Promise<BattleSimResultPayload | null> {
const raw = await this.client.get(this.buildResultKey(jobId));
private async readResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
const raw = await this.client.get(this.buildResultKey(jobId, requesterUserId));
if (!raw) {
return null;
}
@@ -64,44 +64,49 @@ export class RedisBattleSimTransport {
}
}
private async waitForResult(jobId: string, timeoutMs: number): Promise<BattleSimResultPayload | null> {
const existing = await this.readResult(jobId);
private async waitForResult(
jobId: string,
requesterUserId: string,
timeoutMs: number
): Promise<BattleSimResultPayload | null> {
const existing = await this.readResult(jobId, requesterUserId);
if (existing) {
return existing;
}
const notifyKey = this.buildNotifyKey(jobId);
const notifyKey = this.buildNotifyKey(jobId, requesterUserId);
const timeoutSec = toTimeoutSeconds(timeoutMs);
const signal = await this.client.blPop(notifyKey, timeoutSec);
if (!parseBlPopValue(signal)) {
return null;
}
return this.readResult(jobId);
return this.readResult(jobId, requesterUserId);
}
public async simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse> {
public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse> {
const jobId = crypto.randomUUID();
const job = {
jobId,
requesterUserId,
requestedAt: new Date().toISOString(),
payload,
};
await this.client.rPush(this.keys.queueKey, JSON.stringify(job));
const result = await this.waitForResult(jobId, this.requestTimeoutMs);
const result = await this.waitForResult(jobId, requesterUserId, this.requestTimeoutMs);
if (result) {
return { status: 'completed', jobId, payload: result };
}
return { status: 'queued', jobId };
}
public async getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null> {
return this.readResult(jobId);
public async getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
return this.readResult(jobId, requesterUserId);
}
public async pushResult(jobId: string, payload: BattleSimResultPayload): Promise<void> {
const resultKey = this.buildResultKey(jobId);
const notifyKey = this.buildNotifyKey(jobId);
public async pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload): Promise<void> {
const resultKey = this.buildResultKey(jobId, requesterUserId);
const notifyKey = this.buildNotifyKey(jobId, requesterUserId);
await this.client.set(resultKey, JSON.stringify(payload), {
EX: this.resultTtlSeconds,
});
+2 -2
View File
@@ -1,6 +1,6 @@
import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js';
export interface BattleSimTransport {
simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse>;
getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null>;
simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse>;
getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null>;
}
+1
View File
@@ -134,6 +134,7 @@ export interface BattleSimResultPayload {
export interface BattleSimJob {
jobId: string;
requesterUserId: string;
requestedAt: string;
payload: BattleSimJobPayload;
}
+42 -24
View File
@@ -18,7 +18,11 @@ const parseBlPopValue = (result: RedisBlPopResult): string | null => {
return result.element ?? null;
};
export const runBattleSimWorker = async (): Promise<void> => {
export interface BattleSimWorkerOptions {
signal?: AbortSignal;
}
export const runBattleSimWorker = async (options: BattleSimWorkerOptions = {}): Promise<void> => {
const config = resolveGameApiConfigFromEnv();
const redis = createRedisConnector(resolveRedisConfigFromEnv());
await redis.connect();
@@ -30,35 +34,49 @@ export const runBattleSimWorker = async (): Promise<void> => {
resultTtlSeconds: config.battleSimResultTtlSeconds,
});
const handleExit = async () => {
await redis.disconnect();
let stopped = options.signal?.aborted ?? false;
const handleExit = () => {
stopped = true;
};
const handleAbort = () => {
stopped = true;
};
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
options.signal?.addEventListener('abort', handleAbort, { once: true });
while (true) {
const item = await redis.client.blPop(keys.queueKey, 0);
const raw = parseBlPopValue(item);
if (!raw) {
continue;
}
try {
while (!stopped) {
// A finite block lets SIGTERM and test AbortSignal stop the worker without
// leaving a Redis operation or a detached lifecycle process behind.
const item = await redis.client.blPop(keys.queueKey, 1);
const raw = parseBlPopValue(item);
if (!raw) {
continue;
}
let job: BattleSimJob | null = null;
try {
job = JSON.parse(raw) as BattleSimJob;
} catch {
continue;
}
let job: BattleSimJob | null = null;
try {
job = JSON.parse(raw) as BattleSimJob;
} catch {
continue;
}
try {
const result = processBattleSimJob(job.payload);
await transport.pushResult(job.jobId, result);
} catch (error) {
const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류';
await transport.pushResult(job.jobId, {
result: false,
reason,
});
try {
const result = processBattleSimJob(job.payload);
await transport.pushResult(job.jobId, job.requesterUserId, result);
} catch (error) {
const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류';
await transport.pushResult(job.jobId, job.requesterUserId, {
result: false,
reason,
});
}
}
} finally {
process.off('SIGINT', handleExit);
process.off('SIGTERM', handleExit);
options.signal?.removeEventListener('abort', handleAbort);
await redis.disconnect();
}
};
+13 -5
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { getDexLevel } from '@sammo-ts/logic';
import { authedProcedure, procedure, router } from '../../trpc.js';
import { authedProcedure, readOnlyAuthedProcedure, router } from '../../trpc.js';
import { buildBattleSimEnvironment, buildBattleSimJobPayload } from '../../battleSim/environment.js';
import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js';
import {
@@ -30,6 +30,14 @@ const normalizeOptionalKey = (value: string | null): string | null => {
return value;
};
const getAuthenticatedUserId = (auth: { user: { id: string } } | null): string => {
const userId = auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' });
}
return userId;
};
const resolveExpLevel = (meta: Record<string, unknown>, experience: number): number => {
const expLevel = meta.explevel ?? meta.expLevel;
if (typeof expLevel === 'number' && Number.isFinite(expLevel)) {
@@ -48,7 +56,7 @@ const resolveDexValue = (meta: Record<string, unknown>, key: string): number =>
};
export const battleRouter = router({
simulate: procedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => {
simulate: readOnlyAuthedProcedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
@@ -58,10 +66,10 @@ export const battleRouter = router({
}
const payload = await buildBattleSimJobPayload(worldState, input, ctx.profile.id);
return ctx.battleSim.simulate(payload);
return ctx.battleSim.simulate(payload, getAuthenticatedUserId(ctx.auth));
}),
getSimulation: procedure.input(zBattleSimJobId).query(async ({ ctx, input }) => {
const result = await ctx.battleSim.getSimulationResult(input.jobId);
getSimulation: readOnlyAuthedProcedure.input(zBattleSimJobId).query(async ({ ctx, input }) => {
const result = await ctx.battleSim.getSimulationResult(input.jobId, getAuthenticatedUserId(ctx.auth));
if (!result) {
return { status: 'queued', jobId: input.jobId };
}
+20 -14
View File
@@ -7,6 +7,21 @@ import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundar
const t = initTRPC.context<GameApiContext>().create();
const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
if (!ctx.auth) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Unauthorized',
});
}
return next({
ctx: {
...ctx,
auth: ctx.auth,
},
});
});
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
if (type !== 'mutation' || !ctx.db.$transaction) {
return next();
@@ -46,17 +61,8 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
export const router = t.router;
export const procedure = t.procedure.use(inputEventMiddleware);
export const authedProcedure: typeof procedure = procedure.use(({ ctx, next }) => {
if (!ctx.auth) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Unauthorized',
});
}
return next({
ctx: {
...ctx,
auth: ctx.auth,
},
});
});
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
+2
View File
@@ -203,6 +203,8 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], 0),
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], 500),
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
};
};