perf: 전투 시뮬레이터 시작 payload를 줄인다

권위 실행 context를 화면 진입 때 한 번 전달하고 준비 응답은 seed base만 반환한다. 반복별 seed 배열은 결정적 파생값으로 대체하되 구형 queue seed 우선순위와 고정 seed 동작을 보존한다.
This commit is contained in:
2026-08-23 13:25:22 +00:00
parent d7971a4714
commit e04b93f91b
12 changed files with 243 additions and 95 deletions
+5 -1
View File
@@ -90,6 +90,9 @@ export interface BattleSimEnvironment {
scenarioEffect: ScenarioEffectKey | null;
}
export const buildBattleSimSeedBase = (request: Pick<BattleSimRequestPayload, 'seed'>): string | null =>
request.seed ? null : randomUUID();
export const buildBattleSimEnvironment = async (
worldState: WorldStateRow,
profileFallback: string
@@ -138,10 +141,11 @@ export const buildBattleSimJobPayload = async (
profileFallback: string
): Promise<BattleSimJobPayload> => {
const environment = await buildBattleSimEnvironment(worldState, profileFallback);
const seedBase = buildBattleSimSeedBase(request);
return {
...request,
seeds: request.seed ? [] : Array.from({ length: request.repeatCnt }, () => randomUUID()),
...(seedBase ? { seedBase } : {}),
unitSet: environment.unitSet,
config: environment.config,
time: {
+11 -30
View File
@@ -11,7 +11,11 @@ import {
readOnlyAuthedProcedure,
router,
} from '../../trpc.js';
import { buildBattleSimEnvironment, buildBattleSimJobPayload } from '../../battleSim/environment.js';
import {
buildBattleSimEnvironment,
buildBattleSimJobPayload,
buildBattleSimSeedBase,
} from '../../battleSim/environment.js';
import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js';
import {
BATTLE_SIM_CITY_LEVELS,
@@ -62,17 +66,9 @@ const resolveDexValue = (meta: Record<string, unknown>, key: string): number =>
};
export const battleRouter = router({
prepareSimulation: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(async ({ ctx, input }) => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
return buildBattleSimJobPayload(worldState, input, ctx.profile.id);
}),
prepareSimulation: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(({ input }) => ({
seedBase: buildBattleSimSeedBase(input),
})),
simulate: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(async ({ ctx, input }) => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
@@ -104,30 +100,15 @@ export const battleRouter = router({
const environment = await buildBattleSimEnvironment(worldState, ctx.profile.id);
const [traits, items] = await Promise.all([loadBattleSimTraitOptions(), loadBattleSimItemOptions()]);
const crewTypes = (environment.unitSet.crewTypes ?? [])
.filter((crewType) => crewType.armType !== environment.config.armTypes.castle)
.map((crewType) => ({
id: crewType.id,
name: crewType.name,
armType: crewType.armType,
}));
return {
world: {
startYear: environment.startYear,
currentYear: worldState.currentYear,
currentMonth: worldState.currentMonth,
},
config: {
maxTrainByWar: environment.config.maxTrainByWar,
maxAtmosByWar: environment.config.maxAtmosByWar,
maxTrainByCommand: environment.config.maxTrainByCommand,
maxAtmosByCommand: environment.config.maxAtmosByCommand,
},
unitSet: {
defaultCrewTypeId: environment.unitSet.defaultCrewTypeId ?? crewTypes[0]?.id ?? 0,
crewTypes,
},
config: environment.config,
unitSet: environment.unitSet,
scenarioEffect: environment.scenarioEffect,
nationTypes: traits.nationTypes,
eventDomesticTraits: traits.eventDomesticTraits,
warTraits: traits.warTraits,
@@ -266,6 +266,7 @@ describe('battle sim processor', () => {
const firstPayload = buildPayload('battle');
delete firstPayload.seed;
firstPayload.repeatCnt = 2;
firstPayload.seedBase = 'ignored-while-legacy-seeds-exist';
firstPayload.seeds = ['server-repeat-0', 'server-repeat-1'];
const secondPayload = structuredClone(firstPayload);
const observedSeeds: string[] = [];
@@ -283,6 +284,30 @@ describe('battle sim processor', () => {
expect(observedSeeds).toEqual(['server-repeat-0', 'server-repeat-1']);
});
it('expands one seed base deterministically without a repeated seed array', () => {
const firstPayload = buildPayload('battle');
delete firstPayload.seed;
firstPayload.repeatCnt = 2;
firstPayload.seedBase = 'server-root';
const secondPayload = structuredClone(firstPayload);
const observedSeeds: string[] = [];
const first = processBattleSimJob(firstPayload, {
rngFactory: (seed) => {
observedSeeds.push(seed);
return new RandUtil(LiteHashDRBG.build(seed));
},
});
const second = processBattleSimJob(secondPayload);
expect(first).toEqual(second);
expect(first.repeatCnt).toBe(2);
expect(observedSeeds).toEqual([
'str(11,server-root)|str(16,battle-simulator)|int(0)',
'str(11,server-root)|str(16,battle-simulator)|int(1)',
]);
});
it('returns the fixed defender ID order for reorder action', () => {
const payload = buildPayload('reorder');
const result = processBattleSimJob(payload);
+90 -17
View File
@@ -257,7 +257,7 @@ const buildContext = (options: {
};
describe('battle router orchestration', () => {
it('prepares the authoritative browser-worker payload without queuing server work', async () => {
it('returns one repeat seed base without reading world state or echoing the browser-worker payload', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
@@ -269,26 +269,29 @@ describe('battle router orchestration', () => {
meta: { scenarioMeta: { startYear: 180 } },
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const caller = appRouter.createCaller(buildContext({ state, battleSim }));
let worldStateReads = 0;
const db = {
worldState: {
findFirst: async () => {
worldStateReads += 1;
return state;
},
},
} as unknown as DatabaseClient;
const caller = appRouter.createCaller(buildContext({ state, battleSim, db }));
const request = { ...buildBattleRequest(), repeatCnt: 1000 };
delete (request as Partial<typeof request>).seed;
const prepared = await caller.battle.prepareSimulation(request);
expect(prepared).toMatchObject({
action: 'battle',
repeatCnt: 1000,
scenarioEffect: 'event_MoreEffect',
time: { year: 200, month: 1, startYear: 180 },
config: { armPerPhase: 500, maxTrainByWar: 110, maxAtmosByWar: 150 },
});
expect(prepared.unitSet.crewTypes?.length).toBeGreaterThan(0);
expect(prepared.seeds).toHaveLength(1000);
expect(new Set(prepared.seeds).size).toBe(1000);
expect(prepared.seedBase).toMatch(/^[0-9a-f-]{36}$/u);
expect(Object.keys(prepared)).toEqual(['seedBase']);
expect(Buffer.byteLength(JSON.stringify(prepared))).toBeLessThan(128);
expect(worldStateReads).toBe(0);
expect(battleSim.simulateCalls).toBe(0);
});
it('does not allocate repeat seeds when the client supplies a fixed seed', async () => {
it('does not allocate a repeat seed base when the client supplies a fixed seed', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
@@ -304,11 +307,48 @@ describe('battle router orchestration', () => {
const prepared = await caller.battle.prepareSimulation(buildBattleRequest());
expect(prepared.seed).toBe('test-seed');
expect(prepared.seeds).toEqual([]);
expect(prepared).toEqual({ seedBase: null });
expect(battleSim.simulateCalls).toBe(0);
});
it('loads the full authoritative execution context once with the simulator form options', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: { environment: { scenarioEffect: 'event_MoreEffect' } },
meta: { scenarioMeta: { startYear: 180 } },
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
let worldStateReads = 0;
const db = {
worldState: {
findFirst: async () => {
worldStateReads += 1;
return state;
},
},
} as unknown as DatabaseClient;
const caller = appRouter.createCaller(buildContext({ state, battleSim, db }));
const context = await caller.battle.getSimulatorContext();
expect(context).toMatchObject({
world: { startYear: 180, currentYear: 200, currentMonth: 1 },
scenarioEffect: 'event_MoreEffect',
config: { armPerPhase: 500, maxTrainByWar: 110, maxAtmosByWar: 150 },
});
expect(context.unitSet.crewTypes?.[0]).toMatchObject({
id: expect.any(Number),
attack: expect.any(Number),
defence: expect.any(Number),
});
expect(worldStateReads).toBe(1);
});
it('rejects the neutral storage nation type before preparing or queuing a simulation', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
@@ -360,6 +400,40 @@ describe('battle router orchestration', () => {
expect(completed.payload?.result).toBe(true);
});
it('queues one seed base instead of 1000 repeated UUID strings for the server fallback', 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 caller = appRouter.createCaller(buildContext({ state, battleSim }));
const request = { ...buildBattleRequest(), repeatCnt: 1000 };
delete (request as Partial<typeof request>).seed;
await caller.battle.simulate(request);
const payload = battleSim.lastPayload;
if (!payload) {
throw new Error('Expected the fallback transport payload.');
}
expect(payload?.seedBase).toMatch(/^[0-9a-f-]{36}$/u);
expect(payload?.seeds).toBeUndefined();
const legacyPayload = {
...payload,
seedBase: undefined,
seeds: Array.from({ length: 1000 }, (_, index) => String(index).padStart(36, '0')),
};
expect(Buffer.byteLength(JSON.stringify(payload))).toBeLessThan(
Buffer.byteLength(JSON.stringify(legacyPayload)) * 0.45
);
});
it('uses the stored scenario effect even when a client sends a same-named field', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
@@ -435,8 +509,7 @@ describe('battle router orchestration', () => {
buildContext({ state, battleSim, userId: 'user-without-general', db })
);
await expect(noGeneralUser.battle.prepareSimulation(buildBattleRequest())).resolves.toMatchObject({
action: 'battle',
seed: 'test-seed',
seedBase: null,
});
await expect(noGeneralUser.battle.simulate(buildBattleRequest())).resolves.toMatchObject({
status: 'queued',