Merge branch 'main' into feature/ingame-message-parity
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ export interface BattleSimResultPayload {
|
||||
|
||||
export interface BattleSimJob {
|
||||
jobId: string;
|
||||
requesterUserId: string;
|
||||
requestedAt: string;
|
||||
payload: BattleSimJobPayload;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -19,19 +19,30 @@ const profile: GameProfile = {
|
||||
class QueuedBattleSimTransport implements BattleSimTransport {
|
||||
public simulateCalls = 0;
|
||||
public lastPayload: BattleSimJobPayload | null = null;
|
||||
public lastRequesterUserId: string | null = null;
|
||||
private readonly owners = new Map<string, string>();
|
||||
private readonly results = new Map<string, BattleSimResultPayload>();
|
||||
|
||||
async simulate(payload: BattleSimJobPayload) {
|
||||
async simulate(payload: BattleSimJobPayload, requesterUserId: string) {
|
||||
this.simulateCalls += 1;
|
||||
this.lastPayload = payload;
|
||||
return { status: 'queued', jobId: 'job-1' } as const;
|
||||
this.lastRequesterUserId = requesterUserId;
|
||||
const jobId = `job-${this.simulateCalls}`;
|
||||
this.owners.set(jobId, requesterUserId);
|
||||
return { status: 'queued', jobId } as const;
|
||||
}
|
||||
|
||||
async getSimulationResult(jobId: string) {
|
||||
async getSimulationResult(jobId: string, requesterUserId: string) {
|
||||
if (this.owners.get(jobId) !== requesterUserId) {
|
||||
return null;
|
||||
}
|
||||
return this.results.get(jobId) ?? null;
|
||||
}
|
||||
|
||||
pushResult(jobId: string, payload: BattleSimResultPayload) {
|
||||
pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload) {
|
||||
if (this.owners.get(jobId) !== requesterUserId) {
|
||||
throw new Error('requester mismatch');
|
||||
}
|
||||
this.results.set(jobId, payload);
|
||||
}
|
||||
}
|
||||
@@ -194,8 +205,13 @@ const buildBattleRequest = () => ({
|
||||
},
|
||||
});
|
||||
|
||||
const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTransport }): GameApiContext => {
|
||||
const db = {
|
||||
const buildContext = (options: {
|
||||
state: WorldStateRow;
|
||||
battleSim: BattleSimTransport;
|
||||
userId?: string | null;
|
||||
db?: Partial<DatabaseClient>;
|
||||
}): GameApiContext => {
|
||||
const db = options.db ?? {
|
||||
worldState: {
|
||||
findFirst: async () => options.state,
|
||||
},
|
||||
@@ -207,20 +223,23 @@ const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTrans
|
||||
},
|
||||
profile.name
|
||||
);
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: profile.name,
|
||||
issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(),
|
||||
expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(),
|
||||
sessionId: 'session-1',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: 'Tester',
|
||||
roles: [],
|
||||
},
|
||||
sanctions: {},
|
||||
};
|
||||
const auth: GameSessionTokenPayload | null =
|
||||
options.userId === null
|
||||
? null
|
||||
: {
|
||||
version: 1,
|
||||
profile: profile.name,
|
||||
issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(),
|
||||
expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(),
|
||||
sessionId: 'session-1',
|
||||
user: {
|
||||
id: options.userId ?? 'user-1',
|
||||
username: 'tester',
|
||||
displayName: 'Tester',
|
||||
roles: [],
|
||||
},
|
||||
sanctions: {},
|
||||
};
|
||||
return {
|
||||
db: db as unknown as DatabaseClient,
|
||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||
@@ -255,14 +274,204 @@ describe('battle router orchestration', () => {
|
||||
const response = await caller.battle.simulate(buildBattleRequest());
|
||||
expect(response.status).toBe('queued');
|
||||
expect(battleSim.simulateCalls).toBe(1);
|
||||
expect(battleSim.lastRequesterUserId).toBe('user-1');
|
||||
|
||||
const queued = await caller.battle.getSimulation({ jobId: response.jobId });
|
||||
expect(queued.status).toBe('queued');
|
||||
|
||||
battleSim.pushResult(response.jobId, { result: true, reason: 'success', avgWar: 1 });
|
||||
battleSim.pushResult(response.jobId, 'user-1', { result: true, reason: 'success', avgWar: 1 });
|
||||
|
||||
const completed = await caller.battle.getSimulation({ jobId: response.jobId });
|
||||
expect(completed.status).toBe('completed');
|
||||
expect(completed.payload?.result).toBe(true);
|
||||
});
|
||||
|
||||
it('requires login, allows a user without a general, and does not open an input-event transaction', async () => {
|
||||
const battleSim = new QueuedBattleSimTransport();
|
||||
const state: WorldStateRow = {
|
||||
id: 1,
|
||||
scenarioCode: 'default',
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: {},
|
||||
meta: {},
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
let transactionCalls = 0;
|
||||
const db = {
|
||||
worldState: { findFirst: async () => state },
|
||||
$transaction: async () => {
|
||||
transactionCalls += 1;
|
||||
throw new Error('simulation must not create an input event transaction');
|
||||
},
|
||||
} as unknown as DatabaseClient;
|
||||
|
||||
const anonymous = appRouter.createCaller(buildContext({ state, battleSim, userId: null, db }));
|
||||
await expect(anonymous.battle.simulate(buildBattleRequest())).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
|
||||
const noGeneralUser = appRouter.createCaller(
|
||||
buildContext({ state, battleSim, userId: 'user-without-general', db })
|
||||
);
|
||||
await expect(noGeneralUser.battle.simulate(buildBattleRequest())).resolves.toMatchObject({
|
||||
status: 'queued',
|
||||
});
|
||||
expect(transactionCalls).toBe(0);
|
||||
expect(battleSim.lastRequesterUserId).toBe('user-without-general');
|
||||
});
|
||||
|
||||
it('does not expose queued results across authenticated users', async () => {
|
||||
const battleSim = new QueuedBattleSimTransport();
|
||||
const state: WorldStateRow = {
|
||||
id: 1,
|
||||
scenarioCode: 'default',
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: {},
|
||||
meta: {},
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
const owner = appRouter.createCaller(buildContext({ state, battleSim, userId: 'owner-user' }));
|
||||
const other = appRouter.createCaller(buildContext({ state, battleSim, userId: 'other-user' }));
|
||||
const response = await owner.battle.simulate(buildBattleRequest());
|
||||
battleSim.pushResult(response.jobId, 'owner-user', { result: true, reason: 'success', avgWar: 7 });
|
||||
|
||||
await expect(owner.battle.getSimulation({ jobId: response.jobId })).resolves.toMatchObject({
|
||||
status: 'completed',
|
||||
payload: { avgWar: 7 },
|
||||
});
|
||||
await expect(other.battle.getSimulation({ jobId: response.jobId })).resolves.toEqual({
|
||||
status: 'queued',
|
||||
jobId: response.jobId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('battle simulator general import permissions', () => {
|
||||
const state: WorldStateRow = {
|
||||
id: 1,
|
||||
scenarioCode: 'default',
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: {},
|
||||
meta: {},
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
const buildGeneral = (overrides: Record<string, unknown>) => ({
|
||||
id: 1,
|
||||
userId: 'same-nation-user',
|
||||
name: '관전자',
|
||||
npcState: 0,
|
||||
nationId: 1,
|
||||
leadership: 70,
|
||||
strength: 71,
|
||||
intel: 72,
|
||||
officerLevel: 1,
|
||||
injury: 0,
|
||||
rice: 9000,
|
||||
crew: 5000,
|
||||
crewTypeId: 100,
|
||||
atmos: 100,
|
||||
train: 100,
|
||||
experience: 400,
|
||||
horseCode: null,
|
||||
weaponCode: null,
|
||||
bookCode: null,
|
||||
itemCode: null,
|
||||
personalCode: null,
|
||||
special2Code: null,
|
||||
meta: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const actor = buildGeneral({ id: 1, userId: 'same-nation-user', nationId: 1 });
|
||||
const ally = buildGeneral({
|
||||
id: 2,
|
||||
userId: 'ally-user',
|
||||
name: '아군 장수',
|
||||
nationId: 1,
|
||||
officerLevel: 4,
|
||||
rice: 4321,
|
||||
crew: 3210,
|
||||
train: 97,
|
||||
atmos: 96,
|
||||
horseCode: 'che_적토마',
|
||||
weaponCode: 'che_의천검',
|
||||
bookCode: 'che_손자병법',
|
||||
itemCode: 'che_옥새',
|
||||
meta: {
|
||||
dex1: 10000,
|
||||
rank_warnum: 33,
|
||||
rank_killnum: 22,
|
||||
rank_killcrew: 1111,
|
||||
},
|
||||
});
|
||||
const foreignActor = buildGeneral({ id: 3, userId: 'foreign-user', nationId: 2 });
|
||||
const generals = [actor, ally, foreignActor];
|
||||
const db = {
|
||||
worldState: { findFirst: async () => state },
|
||||
general: {
|
||||
findFirst: async ({ where }: { where: { userId: string } }) =>
|
||||
generals.find((general) => general.userId === where.userId) ?? null,
|
||||
findUnique: async ({ where }: { where: { id: number } }) =>
|
||||
generals.find((general) => general.id === where.id) ?? null,
|
||||
},
|
||||
} as unknown as DatabaseClient;
|
||||
|
||||
it('returns full ally details to the same nation but redacts them for another nation', async () => {
|
||||
const battleSim = new QueuedBattleSimTransport();
|
||||
const sameNation = appRouter.createCaller(buildContext({ state, battleSim, userId: 'same-nation-user', db }));
|
||||
const foreign = appRouter.createCaller(buildContext({ state, battleSim, userId: 'foreign-user', db }));
|
||||
|
||||
const visible = await sameNation.battle.getGeneralDetail({ generalId: ally.id });
|
||||
expect(visible.general).toMatchObject({
|
||||
name: '아군 장수',
|
||||
officer_level: 4,
|
||||
horse: 'che_적토마',
|
||||
crew: 3210,
|
||||
rice: 4321,
|
||||
train: 97,
|
||||
atmos: 96,
|
||||
warnum: 33,
|
||||
killnum: 22,
|
||||
killcrew: 1111,
|
||||
});
|
||||
|
||||
const redacted = await foreign.battle.getGeneralDetail({ generalId: ally.id });
|
||||
expect(redacted.general).toMatchObject({
|
||||
name: '아군 장수',
|
||||
officer_level: 1,
|
||||
horse: null,
|
||||
weapon: null,
|
||||
book: null,
|
||||
item: null,
|
||||
crew: 0,
|
||||
rice: 10000,
|
||||
dex1: 0,
|
||||
warnum: 0,
|
||||
killnum: 0,
|
||||
killcrew: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('requires a game general only for server-side general import', async () => {
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({
|
||||
state,
|
||||
battleSim: new QueuedBattleSimTransport(),
|
||||
userId: 'user-without-general',
|
||||
db,
|
||||
})
|
||||
);
|
||||
|
||||
await expect(caller.battle.getGeneralDetail({ generalId: ally.id })).rejects.toMatchObject({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildBattleSimQueueKeys } from '../src/battleSim/keys.js';
|
||||
import { RedisBattleSimTransport } from '../src/battleSim/redisTransport.js';
|
||||
import type { BattleSimJob, BattleSimJobPayload } from '../src/battleSim/types.js';
|
||||
|
||||
class FakeRedisClient {
|
||||
readonly values = new Map<string, string>();
|
||||
readonly lists = new Map<string, string[]>();
|
||||
|
||||
async rPush(key: string, value: string): Promise<number> {
|
||||
const list = this.lists.get(key) ?? [];
|
||||
list.push(value);
|
||||
this.lists.set(key, list);
|
||||
return list.length;
|
||||
}
|
||||
|
||||
async blPop(): Promise<null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async set(key: string, value: string): Promise<'OK'> {
|
||||
this.values.set(key, value);
|
||||
return 'OK';
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
async expire(): Promise<number> {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
describe('RedisBattleSimTransport requester isolation', () => {
|
||||
it('records the requester on queued jobs and scopes completed results to that user', async () => {
|
||||
const client = new FakeRedisClient();
|
||||
const keys = buildBattleSimQueueKeys('che:test');
|
||||
const transport = new RedisBattleSimTransport(client, {
|
||||
keys,
|
||||
requestTimeoutMs: 1,
|
||||
resultTtlSeconds: 60,
|
||||
});
|
||||
|
||||
const response = await transport.simulate({} as BattleSimJobPayload, 'user/one');
|
||||
expect(response.status).toBe('queued');
|
||||
|
||||
const queuedRaw = client.lists.get(keys.queueKey)?.[0];
|
||||
expect(queuedRaw).toBeTruthy();
|
||||
expect(JSON.parse(queuedRaw ?? '{}') as BattleSimJob).toMatchObject({
|
||||
jobId: response.jobId,
|
||||
requesterUserId: 'user/one',
|
||||
});
|
||||
|
||||
await transport.pushResult(response.jobId, 'user/one', {
|
||||
result: true,
|
||||
reason: 'success',
|
||||
avgWar: 3,
|
||||
});
|
||||
|
||||
await expect(transport.getSimulationResult(response.jobId, 'user/one')).resolves.toMatchObject({
|
||||
result: true,
|
||||
avgWar: 3,
|
||||
});
|
||||
await expect(transport.getSimulationResult(response.jobId, 'user/two')).resolves.toBeNull();
|
||||
expect(Array.from(client.values.keys()).some((key) => key.includes('user%2Fone'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildBattleSimEnvironment } from '../src/battleSim/environment.js';
|
||||
import { buildBattleSimQueueKeys } from '../src/battleSim/keys.js';
|
||||
import { RedisBattleSimTransport } from '../src/battleSim/redisTransport.js';
|
||||
import type { BattleSimRequestPayload } from '../src/battleSim/types.js';
|
||||
import { runBattleSimWorker } from '../src/battleSim/worker.js';
|
||||
import type { WorldStateRow } from '../src/context.js';
|
||||
|
||||
const liveDescribe = process.env.REDIS_URL ? describe : describe.skip;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
liveDescribe('battle simulator worker with live Redis', () => {
|
||||
it('consumes an isolated queue, produces a result, and stops cleanly', { timeout: 30_000 }, async () => {
|
||||
const scenario = `battle-sim-e2e-${randomUUID()}`;
|
||||
const profileName = `che:${scenario}`;
|
||||
const requesterUserId = 'worker-e2e-user';
|
||||
vi.stubEnv('PROFILE', 'che');
|
||||
vi.stubEnv('SCENARIO', scenario);
|
||||
vi.stubEnv('GAME_TOKEN_SECRET', 'battle-sim-test-only');
|
||||
|
||||
const fixturePath = path.resolve(
|
||||
process.cwd(),
|
||||
'../../tools/integration-tests/fixtures/battle/basic-infantry.json'
|
||||
);
|
||||
const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8')) as BattleSimRequestPayload & {
|
||||
startYear: number;
|
||||
};
|
||||
const { startYear, ...request } = fixture;
|
||||
const worldState: WorldStateRow = {
|
||||
id: 1,
|
||||
scenarioCode: 'default',
|
||||
currentYear: request.year,
|
||||
currentMonth: request.month,
|
||||
tickSeconds: 600,
|
||||
config: {},
|
||||
meta: { scenarioMeta: { startYear } },
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const environment = await buildBattleSimEnvironment(worldState, 'che');
|
||||
const payload = {
|
||||
...request,
|
||||
unitSet: environment.unitSet,
|
||||
config: environment.config,
|
||||
time: { year: request.year, month: request.month, startYear },
|
||||
};
|
||||
|
||||
const clientConnector = createRedisConnector(resolveRedisConfigFromEnv());
|
||||
await clientConnector.connect();
|
||||
const keys = buildBattleSimQueueKeys(profileName);
|
||||
const transport = new RedisBattleSimTransport(clientConnector.client, {
|
||||
keys,
|
||||
requestTimeoutMs: 15_000,
|
||||
resultTtlSeconds: 60,
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
const worker = runBattleSimWorker({ signal: abortController.signal });
|
||||
let jobId: string | null = null;
|
||||
|
||||
try {
|
||||
const result = await transport.simulate(payload, requesterUserId);
|
||||
jobId = result.jobId;
|
||||
expect(result.status).toBe('completed');
|
||||
if (result.status === 'completed') {
|
||||
expect(result.payload).toMatchObject({
|
||||
result: true,
|
||||
reason: 'success',
|
||||
avgWar: 1,
|
||||
});
|
||||
expect(result.payload.phase).toBeGreaterThan(0);
|
||||
}
|
||||
} finally {
|
||||
abortController.abort();
|
||||
await worker;
|
||||
if (jobId) {
|
||||
const encodedRequester = encodeURIComponent(requesterUserId);
|
||||
await clientConnector.client.del([
|
||||
keys.queueKey,
|
||||
`${keys.resultKeyPrefix}${encodedRequester}:${jobId}`,
|
||||
`${keys.notifyKeyPrefix}${encodedRequester}:${jobId}`,
|
||||
]);
|
||||
}
|
||||
await clientConnector.disconnect();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,12 @@ const integration = describe.skipIf(!databaseUrl);
|
||||
const bettingId = 990_071;
|
||||
const concurrentBettingId = 990_072;
|
||||
const generalId = 9_971;
|
||||
const otherGeneralId = 9_972;
|
||||
const nationId = 990_071;
|
||||
const otherNationId = 990_072;
|
||||
const userId = 'nation-betting-router-user';
|
||||
const otherUserId = 'nation-betting-router-other-user';
|
||||
const noGeneralUserId = 'nation-betting-router-no-general-user';
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
@@ -32,12 +36,34 @@ const auth: GameSessionTokenPayload = {
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const otherAuth: GameSessionTokenPayload = {
|
||||
...auth,
|
||||
sessionId: 'nation-betting-router-other-session',
|
||||
user: {
|
||||
...auth.user,
|
||||
id: otherUserId,
|
||||
username: 'other-bettor',
|
||||
displayName: 'Other Bettor',
|
||||
},
|
||||
};
|
||||
|
||||
const noGeneralAuth: GameSessionTokenPayload = {
|
||||
...auth,
|
||||
sessionId: 'nation-betting-router-no-general-session',
|
||||
user: {
|
||||
...auth.user,
|
||||
id: noGeneralUserId,
|
||||
username: 'no-general',
|
||||
displayName: 'No General',
|
||||
},
|
||||
};
|
||||
|
||||
integration('nation betting router', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let worldStateId: number;
|
||||
|
||||
const buildContext = (requestId: string): GameApiContext => {
|
||||
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload | null = auth): GameApiContext => {
|
||||
const redisClient = {
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
@@ -52,7 +78,7 @@ integration('nation betting router', () => {
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
auth,
|
||||
auth: actorAuth,
|
||||
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:2'),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
@@ -64,33 +90,55 @@ integration('nation betting router', () => {
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
|
||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
||||
await db.rankData.deleteMany({ where: { generalId } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId } });
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.nation.deleteMany({ where: { id: nationId } });
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
|
||||
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
||||
|
||||
await db.nation.create({
|
||||
data: {
|
||||
id: nationId,
|
||||
name: '베팅국',
|
||||
color: '#123456',
|
||||
level: 2,
|
||||
},
|
||||
await db.nation.createMany({
|
||||
data: [
|
||||
{
|
||||
id: nationId,
|
||||
name: '베팅국',
|
||||
color: '#123456',
|
||||
level: 2,
|
||||
},
|
||||
{
|
||||
id: otherNationId,
|
||||
name: '다른베팅국',
|
||||
color: '#654321',
|
||||
level: 6,
|
||||
},
|
||||
],
|
||||
});
|
||||
await db.general.create({
|
||||
data: {
|
||||
id: generalId,
|
||||
userId,
|
||||
name: '베팅장수',
|
||||
nationId,
|
||||
cityId: 1,
|
||||
npcState: 0,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: {},
|
||||
},
|
||||
await db.general.createMany({
|
||||
data: [
|
||||
{
|
||||
id: generalId,
|
||||
userId,
|
||||
name: '베팅장수',
|
||||
nationId,
|
||||
cityId: 1,
|
||||
npcState: 0,
|
||||
officerLevel: 0,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: {},
|
||||
},
|
||||
{
|
||||
id: otherGeneralId,
|
||||
userId: otherUserId,
|
||||
name: '다른국가수뇌',
|
||||
nationId: otherNationId,
|
||||
cityId: 1,
|
||||
npcState: 0,
|
||||
officerLevel: 12,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
const world = await db.worldState.create({
|
||||
data: {
|
||||
@@ -132,19 +180,22 @@ integration('nation betting router', () => {
|
||||
candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }],
|
||||
},
|
||||
});
|
||||
await db.inheritancePoint.create({
|
||||
data: { userId, key: 'previous', value: 1_000 },
|
||||
await db.inheritancePoint.createMany({
|
||||
data: [
|
||||
{ userId, key: 'previous', value: 1_000 },
|
||||
{ userId: otherUserId, key: 'previous', value: 500 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
|
||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
||||
await db.rankData.deleteMany({ where: { generalId } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId } });
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.nation.deleteMany({ where: { id: nationId } });
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
|
||||
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
||||
await db.worldState.delete({ where: { id: worldStateId } });
|
||||
await closeDb?.();
|
||||
});
|
||||
@@ -229,12 +280,107 @@ integration('nation betting router', () => {
|
||||
}),
|
||||
]);
|
||||
expect(results.map((result) => result.status).sort()).toEqual(['fulfilled', 'rejected']);
|
||||
expect(await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } }))
|
||||
.toMatchObject({ _sum: { amount: 600 } });
|
||||
expect(
|
||||
await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } })
|
||||
).toMatchObject({ _sum: { amount: 600 } });
|
||||
expect(
|
||||
await db.inheritancePoint.findUniqueOrThrow({
|
||||
where: { userId_key: { userId, key: 'previous' } },
|
||||
})
|
||||
).toMatchObject({ value: 250 });
|
||||
});
|
||||
|
||||
it('requires authentication and an owned player general for every betting operation', async () => {
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({
|
||||
req: 'bettingNation',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(buildContext('nation-betting-anonymous-detail', null))
|
||||
.betting.getDetail({ bettingId })
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-anonymous-bet', null)).betting.bet({
|
||||
bettingId,
|
||||
bettingType: [0],
|
||||
amount: 10,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-no-general-list', noGeneralAuth)).betting.getList({
|
||||
req: 'bettingNation',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(buildContext('nation-betting-no-general-detail', noGeneralAuth))
|
||||
.betting.getDetail({ bettingId })
|
||||
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-no-general-bet', noGeneralAuth)).betting.bet({
|
||||
bettingId,
|
||||
bettingType: [0],
|
||||
amount: 10,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
|
||||
});
|
||||
|
||||
it('allows generals across nation and office levels while isolating each session user bet', async () => {
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-other-list', otherAuth)).betting.getList({
|
||||
req: 'bettingNation',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
result: true,
|
||||
bettingList: {
|
||||
[bettingId]: { name: '천통국 예상' },
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-other-bet', otherAuth)).betting.bet({
|
||||
bettingId,
|
||||
bettingType: [0],
|
||||
amount: 100,
|
||||
})
|
||||
).resolves.toEqual({ result: true });
|
||||
|
||||
const [firstUserDetail, otherUserDetail] = await Promise.all([
|
||||
appRouter.createCaller(buildContext('nation-betting-first-user-detail')).betting.getDetail({ bettingId }),
|
||||
appRouter
|
||||
.createCaller(buildContext('nation-betting-other-user-detail', otherAuth))
|
||||
.betting.getDetail({ bettingId }),
|
||||
]);
|
||||
expect(firstUserDetail.myBetting).toEqual([['[0]', 150]]);
|
||||
expect(otherUserDetail.myBetting).toEqual([['[0]', 100]]);
|
||||
expect(firstUserDetail.bettingDetail).toEqual([['[0]', 250]]);
|
||||
expect(otherUserDetail.bettingDetail).toEqual([['[0]', 250]]);
|
||||
|
||||
expect(
|
||||
await db.nationBet.findUniqueOrThrow({
|
||||
where: {
|
||||
bettingId_userId_selectionKey: {
|
||||
bettingId,
|
||||
userId: otherUserId,
|
||||
selectionKey: '[0]',
|
||||
},
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
generalId: otherGeneralId,
|
||||
userId: otherUserId,
|
||||
amount: 100,
|
||||
});
|
||||
expect(
|
||||
await db.inheritancePoint.findUniqueOrThrow({
|
||||
where: { userId_key: { userId: otherUserId, key: 'previous' } },
|
||||
})
|
||||
).toMatchObject({ value: 400 });
|
||||
expect(
|
||||
await db.rankData.findUniqueOrThrow({
|
||||
where: { generalId_type: { generalId: otherGeneralId, type: 'inherit_spent_dyn' } },
|
||||
})
|
||||
).toMatchObject({ nationId: otherNationId, value: 100 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user