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; scenarioEffect: ScenarioEffectKey | null;
} }
export const buildBattleSimSeedBase = (request: Pick<BattleSimRequestPayload, 'seed'>): string | null =>
request.seed ? null : randomUUID();
export const buildBattleSimEnvironment = async ( export const buildBattleSimEnvironment = async (
worldState: WorldStateRow, worldState: WorldStateRow,
profileFallback: string profileFallback: string
@@ -138,10 +141,11 @@ export const buildBattleSimJobPayload = async (
profileFallback: string profileFallback: string
): Promise<BattleSimJobPayload> => { ): Promise<BattleSimJobPayload> => {
const environment = await buildBattleSimEnvironment(worldState, profileFallback); const environment = await buildBattleSimEnvironment(worldState, profileFallback);
const seedBase = buildBattleSimSeedBase(request);
return { return {
...request, ...request,
seeds: request.seed ? [] : Array.from({ length: request.repeatCnt }, () => randomUUID()), ...(seedBase ? { seedBase } : {}),
unitSet: environment.unitSet, unitSet: environment.unitSet,
config: environment.config, config: environment.config,
time: { time: {
+11 -30
View File
@@ -11,7 +11,11 @@ import {
readOnlyAuthedProcedure, readOnlyAuthedProcedure,
router, router,
} from '../../trpc.js'; } 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 { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js';
import { import {
BATTLE_SIM_CITY_LEVELS, BATTLE_SIM_CITY_LEVELS,
@@ -62,17 +66,9 @@ const resolveDexValue = (meta: Record<string, unknown>, key: string): number =>
}; };
export const battleRouter = router({ export const battleRouter = router({
prepareSimulation: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(async ({ ctx, input }) => { prepareSimulation: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(({ input }) => ({
const worldState = await ctx.db.worldState.findFirst(); seedBase: buildBattleSimSeedBase(input),
if (!worldState) { })),
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
return buildBattleSimJobPayload(worldState, input, ctx.profile.id);
}),
simulate: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(async ({ ctx, input }) => { simulate: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(async ({ ctx, input }) => {
const worldState = await ctx.db.worldState.findFirst(); const worldState = await ctx.db.worldState.findFirst();
if (!worldState) { if (!worldState) {
@@ -104,30 +100,15 @@ export const battleRouter = router({
const environment = await buildBattleSimEnvironment(worldState, ctx.profile.id); const environment = await buildBattleSimEnvironment(worldState, ctx.profile.id);
const [traits, items] = await Promise.all([loadBattleSimTraitOptions(), loadBattleSimItemOptions()]); 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 { return {
world: { world: {
startYear: environment.startYear, startYear: environment.startYear,
currentYear: worldState.currentYear, currentYear: worldState.currentYear,
currentMonth: worldState.currentMonth, currentMonth: worldState.currentMonth,
}, },
config: { config: environment.config,
maxTrainByWar: environment.config.maxTrainByWar, unitSet: environment.unitSet,
maxAtmosByWar: environment.config.maxAtmosByWar, scenarioEffect: environment.scenarioEffect,
maxTrainByCommand: environment.config.maxTrainByCommand,
maxAtmosByCommand: environment.config.maxAtmosByCommand,
},
unitSet: {
defaultCrewTypeId: environment.unitSet.defaultCrewTypeId ?? crewTypes[0]?.id ?? 0,
crewTypes,
},
nationTypes: traits.nationTypes, nationTypes: traits.nationTypes,
eventDomesticTraits: traits.eventDomesticTraits, eventDomesticTraits: traits.eventDomesticTraits,
warTraits: traits.warTraits, warTraits: traits.warTraits,
@@ -266,6 +266,7 @@ describe('battle sim processor', () => {
const firstPayload = buildPayload('battle'); const firstPayload = buildPayload('battle');
delete firstPayload.seed; delete firstPayload.seed;
firstPayload.repeatCnt = 2; firstPayload.repeatCnt = 2;
firstPayload.seedBase = 'ignored-while-legacy-seeds-exist';
firstPayload.seeds = ['server-repeat-0', 'server-repeat-1']; firstPayload.seeds = ['server-repeat-0', 'server-repeat-1'];
const secondPayload = structuredClone(firstPayload); const secondPayload = structuredClone(firstPayload);
const observedSeeds: string[] = []; const observedSeeds: string[] = [];
@@ -283,6 +284,30 @@ describe('battle sim processor', () => {
expect(observedSeeds).toEqual(['server-repeat-0', 'server-repeat-1']); 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', () => { it('returns the fixed defender ID order for reorder action', () => {
const payload = buildPayload('reorder'); const payload = buildPayload('reorder');
const result = processBattleSimJob(payload); const result = processBattleSimJob(payload);
+90 -17
View File
@@ -257,7 +257,7 @@ const buildContext = (options: {
}; };
describe('battle router orchestration', () => { 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 battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = { const state: WorldStateRow = {
id: 1, id: 1,
@@ -269,26 +269,29 @@ describe('battle router orchestration', () => {
meta: { scenarioMeta: { startYear: 180 } }, meta: { scenarioMeta: { startYear: 180 } },
updatedAt: new Date('2026-01-01T00:00:00Z'), 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 }; const request = { ...buildBattleRequest(), repeatCnt: 1000 };
delete (request as Partial<typeof request>).seed; delete (request as Partial<typeof request>).seed;
const prepared = await caller.battle.prepareSimulation(request); const prepared = await caller.battle.prepareSimulation(request);
expect(prepared).toMatchObject({ expect(prepared.seedBase).toMatch(/^[0-9a-f-]{36}$/u);
action: 'battle', expect(Object.keys(prepared)).toEqual(['seedBase']);
repeatCnt: 1000, expect(Buffer.byteLength(JSON.stringify(prepared))).toBeLessThan(128);
scenarioEffect: 'event_MoreEffect', expect(worldStateReads).toBe(0);
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(battleSim.simulateCalls).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 battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = { const state: WorldStateRow = {
id: 1, id: 1,
@@ -304,11 +307,48 @@ describe('battle router orchestration', () => {
const prepared = await caller.battle.prepareSimulation(buildBattleRequest()); const prepared = await caller.battle.prepareSimulation(buildBattleRequest());
expect(prepared.seed).toBe('test-seed'); expect(prepared).toEqual({ seedBase: null });
expect(prepared.seeds).toEqual([]);
expect(battleSim.simulateCalls).toBe(0); 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 () => { it('rejects the neutral storage nation type before preparing or queuing a simulation', async () => {
const battleSim = new QueuedBattleSimTransport(); const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = { const state: WorldStateRow = {
@@ -360,6 +400,40 @@ describe('battle router orchestration', () => {
expect(completed.payload?.result).toBe(true); 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 () => { it('uses the stored scenario effect even when a client sends a same-named field', async () => {
const battleSim = new QueuedBattleSimTransport(); const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = { const state: WorldStateRow = {
@@ -435,8 +509,7 @@ describe('battle router orchestration', () => {
buildContext({ state, battleSim, userId: 'user-without-general', db }) buildContext({ state, battleSim, userId: 'user-without-general', db })
); );
await expect(noGeneralUser.battle.prepareSimulation(buildBattleRequest())).resolves.toMatchObject({ await expect(noGeneralUser.battle.prepareSimulation(buildBattleRequest())).resolves.toMatchObject({
action: 'battle', seedBase: null,
seed: 'test-seed',
}); });
await expect(noGeneralUser.battle.simulate(buildBattleRequest())).resolves.toMatchObject({ await expect(noGeneralUser.battle.simulate(buildBattleRequest())).resolves.toMatchObject({
status: 'queued', status: 'queued',
+31 -9
View File
@@ -43,7 +43,7 @@ const readImage = async (relative: string): Promise<Buffer> => {
throw new Error(`Reference image not found: ${relative}`); throw new Error(`Reference image not found: ${relative}`);
}; };
const simulatorOptions = { const simulatorFormOptions = {
world: { startYear: 190, currentYear: 205, currentMonth: 8 }, world: { startYear: 190, currentYear: 205, currentMonth: 8 },
config: { config: {
maxTrainByWar: 120, maxTrainByWar: 120,
@@ -138,6 +138,16 @@ const engineConfig: BattleSimJobPayload['config'] = {
}, },
}; };
const simulatorOptions = {
...simulatorFormOptions,
config: {
...engineConfig,
...simulatorFormOptions.config,
},
unitSet: engineUnitSet,
scenarioEffect: null,
};
const generalMe = { const generalMe = {
general: { general: {
id: 7, id: 7,
@@ -206,6 +216,7 @@ type Fixture = {
requests: string[]; requests: string[];
preparedPayloads: BattleSimJobPayload[]; preparedPayloads: BattleSimJobPayload[];
serverResults: BattleSimResultPayload[]; serverResults: BattleSimResultPayload[];
prepareResponseBytes?: number[];
}; };
const readOperationInput = ( const readOperationInput = (
@@ -294,26 +305,29 @@ const installApi = async (page: Page, fixture: Fixture) => {
operations.length, operations.length,
operationIndex operationIndex
) as BattleSimRequestPayload; ) as BattleSimRequestPayload;
const seedBase = request.seed ? null : 'playwright-repeat-seed';
const prepared: BattleSimJobPayload = { const prepared: BattleSimJobPayload = {
...request, ...request,
seeds: request.seed ...(seedBase ? { seedBase } : {}),
? []
: Array.from({ length: request.repeatCnt }, (_, index) => `playwright-repeat-${index}`),
unitSet: engineUnitSet, unitSet: engineUnitSet,
config: engineConfig, config: simulatorOptions.config,
time: { year: request.year, month: request.month, startYear: 190 }, time: { year: request.year, month: request.month, startYear: 190 },
scenarioEffect: null, scenarioEffect: null,
}; };
fixture.preparedPayloads.push(prepared); fixture.preparedPayloads.push(prepared);
fixture.serverResults.push(processBattleSimJob(structuredClone(prepared))); fixture.serverResults.push(processBattleSimJob(structuredClone(prepared)));
return response(prepared); return response({ seedBase });
} }
return errorResponse(operation, `Unhandled battle simulator fixture operation: ${operation}`); return errorResponse(operation, `Unhandled battle simulator fixture operation: ${operation}`);
}); });
const body = JSON.stringify(results);
if (operations.includes('battle.prepareSimulation')) {
fixture.prepareResponseBytes?.push(Buffer.byteLength(body));
}
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify(results), body,
}); });
}); });
}; };
@@ -351,6 +365,7 @@ test('operates independent/game presets, imports my general, and renders battle
requests: [], requests: [],
preparedPayloads: [], preparedPayloads: [],
serverResults: [], serverResults: [],
prepareResponseBytes: [],
}; };
await installApi(page, fixture); await installApi(page, fixture);
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
@@ -398,6 +413,8 @@ test('operates independent/game presets, imports my general, and renders battle
} }
expect(fixture.requests).not.toContain('battle.simulate'); expect(fixture.requests).not.toContain('battle.simulate');
expect(fixture.requests).not.toContain('battle.getSimulation'); expect(fixture.requests).not.toContain('battle.getSimulation');
expect(fixture.prepareResponseBytes).toEqual([expect.any(Number)]);
expect(fixture.prepareResponseBytes?.every((bytes) => bytes < 128)).toBe(true);
expect(fixture.preparedPayloads[0]).toMatchObject({ expect(fixture.preparedPayloads[0]).toMatchObject({
attackerGeneral: { special: 'che_event_신산' }, attackerGeneral: { special: 'che_event_신산' },
}); });
@@ -439,6 +456,7 @@ test('keeps simulation available without a game general and preserves input afte
requests: [], requests: [],
preparedPayloads: [], preparedPayloads: [],
serverResults: [], serverResults: [],
prepareResponseBytes: [],
}; };
await installApi(page, fixture); await installApi(page, fixture);
await page.setViewportSize({ width: 500, height: 900 }); await page.setViewportSize({ width: 500, height: 900 });
@@ -453,6 +471,7 @@ test('keeps simulation available without a game general and preserves input afte
await page.getByRole('button', { name: '전투', exact: true }).click(); await page.getByRole('button', { name: '전투', exact: true }).click();
await expect(page.getByText('시뮬레이터 입력 오류')).toBeVisible(); await expect(page.getByText('시뮬레이터 입력 오류')).toBeVisible();
await expect(page.getByLabel('시드')).toHaveValue('keep-this-seed'); await expect(page.getByLabel('시드')).toHaveValue('keep-this-seed');
await expect(page.getByTestId('game-toast').filter({ hasText: '전투를 진행 중입니다.' })).toHaveCount(0);
fixture.prepareDelayMs = 1_500; fixture.prepareDelayMs = 1_500;
await page.getByRole('button', { name: '전투', exact: true }).click(); await page.getByRole('button', { name: '전투', exact: true }).click();
@@ -493,6 +512,7 @@ test('runs 1000 battles in the Chromium worker and matches the Node processor ex
requests: [], requests: [],
preparedPayloads: [], preparedPayloads: [],
serverResults: [], serverResults: [],
prepareResponseBytes: [],
}; };
await installApi(page, fixture); await installApi(page, fixture);
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
@@ -520,8 +540,8 @@ test('runs 1000 battles in the Chromium worker and matches the Node processor ex
await expect(progressToast).toHaveCount(0, { timeout: 30_000 }); await expect(progressToast).toHaveCount(0, { timeout: 30_000 });
expect(fixture.preparedPayloads).toHaveLength(1); expect(fixture.preparedPayloads).toHaveLength(1);
expect(fixture.preparedPayloads[0]?.seeds).toHaveLength(1000); expect(fixture.preparedPayloads[0]?.seedBase).toBe('playwright-repeat-seed');
expect(new Set(fixture.preparedPayloads[0]?.seeds).size).toBe(1000); expect(fixture.preparedPayloads[0]?.seeds).toBeUndefined();
expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]); expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]);
const battleSummary = page.locator('[data-parity-id="battle-summary"]'); const battleSummary = page.locator('[data-parity-id="battle-summary"]');
await expect(battleSummary.locator('tr').filter({ hasText: '전투 횟수' }).locator('td')).toHaveText('1,000'); await expect(battleSummary.locator('tr').filter({ hasText: '전투 횟수' }).locator('td')).toHaveText('1,000');
@@ -531,6 +551,8 @@ test('runs 1000 battles in the Chromium worker and matches the Node processor ex
expect(fixture.preparedPayloads[0]?.attackerGeneral.turntime).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/u); expect(fixture.preparedPayloads[0]?.attackerGeneral.turntime).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/u);
expect(fixture.requests).not.toContain('battle.simulate'); expect(fixture.requests).not.toContain('battle.simulate');
expect(fixture.requests).not.toContain('battle.getSimulation'); expect(fixture.requests).not.toContain('battle.getSimulation');
expect(fixture.prepareResponseBytes).toEqual([expect.any(Number)]);
expect(fixture.prepareResponseBytes?.[0]).toBeLessThan(128);
const workerUrls = await page.evaluate(() => { const workerUrls = await page.evaluate(() => {
const testWindow = window as unknown as { __battleWorkerUrls?: string[] }; const testWindow = window as unknown as { __battleWorkerUrls?: string[] };
return testWindow.__battleWorkerUrls ?? []; return testWindow.__battleWorkerUrls ?? [];
@@ -4,6 +4,7 @@ import type { BattleSimOptions, GeneralDraft } from '../../utils/battleSimulator
interface Props { interface Props {
options: BattleSimOptions; options: BattleSimOptions;
crewTypes: Array<{ id: number; name: string; armType: number }>;
mode: 'attacker' | 'defender'; mode: 'attacker' | 'defender';
title: string; title: string;
canImportServer: boolean; canImportServer: boolean;
@@ -175,7 +176,7 @@ const officerLevelOptions = [
<label class="field"> <label class="field">
<span>병종</span> <span>병종</span>
<select v-model.number="general.crewtype"> <select v-model.number="general.crewtype">
<option v-for="crew in options.unitSet.crewTypes" :key="crew.id" :value="crew.id"> <option v-for="crew in crewTypes" :key="crew.id" :value="crew.id">
{{ crew.name }} {{ crew.name }}
</option> </option>
</select> </select>
@@ -1,3 +1,5 @@
import type { UnitSetDefinition, WarEngineConfig } from '@sammo-ts/logic';
export type InheritBuff = { export type InheritBuff = {
warAvoidRatio: number; warAvoidRatio: number;
warCriticalRatio: number; warCriticalRatio: number;
@@ -47,16 +49,9 @@ export type BattleSimOptions = {
currentYear: number; currentYear: number;
currentMonth: number; currentMonth: number;
}; };
config: { config: WarEngineConfig;
maxTrainByWar: number; unitSet: UnitSetDefinition;
maxAtmosByWar: number; scenarioEffect: string | null;
maxTrainByCommand: number;
maxAtmosByCommand: number;
};
unitSet: {
defaultCrewTypeId: number;
crewTypes: Array<{ id: number; name: string; armType: number }>;
};
nationTypes: Array<{ key: string; name: string; info: string }>; nationTypes: Array<{ key: string; name: string; info: string }>;
eventDomesticTraits: Array<{ key: string; name: string; info: string }>; eventDomesticTraits: Array<{ key: string; name: string; info: string }>;
warTraits: Array<{ key: string; name: string; info: string }>; warTraits: Array<{ key: string; name: string; info: string }>;
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, reactive, ref, shallowRef } from 'vue';
import type { BattleSimRequestPayload, BattleSimResultPayload } from '@sammo-ts/game-api'; import type { BattleSimRequestPayload, BattleSimResultPayload } from '@sammo-ts/game-api';
import PanelCard from '../components/ui/PanelCard.vue'; import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue'; import SkeletonLines from '../components/ui/SkeletonLines.vue';
@@ -33,7 +33,8 @@ type GeneralMeResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
const loading = ref(true); const loading = ref(true);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const options = ref<BattleSimOptions | null>(null); // Worker execution data must stay outside Vue's deep reactive proxy graph so it remains structured-cloneable.
const options = shallowRef<BattleSimOptions | null>(null);
const battleResult = ref<BattleSimResultPayload | null>(null); const battleResult = ref<BattleSimResultPayload | null>(null);
const attackerNation = reactive({ const attackerNation = reactive({
@@ -83,6 +84,16 @@ const generalListLoading = ref(false);
const selectedGeneralId = ref<number | null>(null); const selectedGeneralId = ref<number | null>(null);
const gameDefaults = ref<GeneralMeResponse>(null); const gameDefaults = ref<GeneralMeResponse>(null);
const availableCrewTypes = computed(() => {
const context = options.value;
if (!context) {
return [];
}
return (context.unitSet.crewTypes ?? [])
.filter((crewType) => crewType.armType !== context.config.armTypes.castle)
.map((crewType) => ({ id: crewType.id, name: crewType.name, armType: crewType.armType }));
});
let generalIdSeed = 0; let generalIdSeed = 0;
const createInheritBuff = (): InheritBuff => ({ const createInheritBuff = (): InheritBuff => ({
@@ -125,7 +136,7 @@ const resolveGeneralNo = (preferred: number | null, excludeId?: string): number
}; };
const createGeneralDraft = (overrides?: Partial<GeneralDraft>): GeneralDraft => { const createGeneralDraft = (overrides?: Partial<GeneralDraft>): GeneralDraft => {
const baseCrew = options.value?.unitSet.defaultCrewTypeId ?? options.value?.unitSet.crewTypes[0]?.id ?? 1; const baseCrew = options.value?.unitSet.defaultCrewTypeId ?? availableCrewTypes.value[0]?.id ?? 1;
const draft: GeneralDraft = { const draft: GeneralDraft = {
id: nextGeneralId(), id: nextGeneralId(),
no: 0, no: 0,
@@ -219,7 +230,7 @@ const applyGeneralExport = (target: GeneralDraft, data: GeneralExport) => {
target.killcrew = data.killcrew; target.killcrew = data.killcrew;
target.inheritBuff = data.inheritBuff ? { ...data.inheritBuff } : createInheritBuff(); target.inheritBuff = data.inheritBuff ? { ...data.inheritBuff } : createInheritBuff();
if (target.crewtype <= 0) { if (target.crewtype <= 0) {
target.crewtype = options.value?.unitSet.defaultCrewTypeId ?? options.value?.unitSet.crewTypes[0]?.id ?? 1; target.crewtype = options.value?.unitSet.defaultCrewTypeId ?? availableCrewTypes.value[0]?.id ?? 1;
} }
}; };
@@ -590,8 +601,19 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => {
try { try {
const payload = buildBattlePayload(action); const payload = buildBattlePayload(action);
const preparedPayload = await trpc.battle.prepareSimulation.mutate(payload); const preparation = await trpc.battle.prepareSimulation.mutate(payload);
const result = await simulationWorker.run(preparedPayload); const result = await simulationWorker.run({
...payload,
...(preparation.seedBase ? { seedBase: preparation.seedBase } : {}),
unitSet: options.value.unitSet,
config: options.value.config,
time: {
year: payload.year,
month: payload.month,
startYear: options.value.world.startYear,
},
scenarioEffect: options.value.scenarioEffect,
});
if (!result.result) { if (!result.result) {
error.value = result.reason || 'battle_failed'; error.value = result.reason || 'battle_failed';
@@ -1106,6 +1128,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
v-model:general="attackerGeneral" v-model:general="attackerGeneral"
data-parity-id="attacker-general" data-parity-id="attacker-general"
:options="options!" :options="options!"
:crew-types="availableCrewTypes"
mode="attacker" mode="attacker"
title="출병자 설정" title="출병자 설정"
:can-import-server="hasGameGeneral" :can-import-server="hasGameGeneral"
@@ -1192,6 +1215,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
v-model:general="defenders[index]" v-model:general="defenders[index]"
:data-parity-id="index === 0 ? 'defender-general' : `defender-general-${index + 1}`" :data-parity-id="index === 0 ? 'defender-general' : `defender-general-${index + 1}`"
:options="options!" :options="options!"
:crew-types="availableCrewTypes"
mode="defender" mode="defender"
title="수비자 설정" title="수비자 설정"
:can-import-server="hasGameGeneral" :can-import-server="hasGameGeneral"
+7 -5
View File
@@ -61,11 +61,13 @@ scenario parser, resource schema, PostgreSQL world loader와 battle simulator
않습니다. 않습니다.
전투 시뮬레이터의 효과는 공개 request가 아니라 저장된 world config에서 전투 시뮬레이터의 효과는 공개 request가 아니라 저장된 world config에서
서버가 파생합니다. `battle.prepareSimulation` 해당 효과와 전체 병종 정의, 서버가 파생합니다. `battle.getSimulatorContext`는 화면을 열 때 해당 효과와 전체
전투 상수, 시나리오 시작 연도, 반복별 seed를 권위 payload로 만들고 브라우저 병종 정의, 전투 상수, 시나리오 시작 연도를 한 번 전달합니다.
Web Worker가 `@sammo-ts/logic`의 공용 프로세서를 실행합니다. 기존 `battle.prepareSimulation`은 실행할 때 입력을 검증하고 반복 실행용 단일
`battle.simulate`와 Redis worker도 같은 processor와 payload를 사용하므로 `seedBase`만 발급하며, 브라우저 Web Worker가 두 자료를 합쳐
검증·호환 fallback 경로에서 결과를 대조할 수 있습니다. `@sammo-ts/logic`의 공용 프로세서를 실행합니다. 기존 `battle.simulate`와 Redis
worker도 같은 processor와 payload를 사용하므로 검증·호환 fallback 경로에서
결과를 대조할 수 있습니다.
## 닫힌 의미 이벤트 ## 닫힌 의미 이벤트
@@ -2,19 +2,26 @@
## 요청과 권위 데이터 ## 요청과 권위 데이터
`BattleSimulatorView.vue`사용자가 편집한 장수·국가·도시 입력을 `BattleSimulatorView.vue`화면을 열 때 `battle.getSimulatorContext`에서 form
`battle.prepareSimulation`에 보냅니다. 이 API는 로그인만 요구하며 게임 장수 option과 브라우저 Worker의 실행 context를 한 번 함께 받습니다. 이 API는 현재
보유 여부나 input-event transaction에는 의존하지 않습니다. 서버는 현재 `WorldState`를 한 번 읽어 다음 서버 권위 값을 제공합니다.
`WorldState`에서 다음 값을 읽어 실행 payload에 추가합니다.
- 전체 `UnitSetDefinition` - 전체 `UnitSetDefinition`
- 전투 상수와 성벽 병종/병과 ID - 전투 상수와 성벽 병종/병과 ID
- 시나리오 시작 연도와 저장된 `scenarioEffect` - 시나리오 시작 연도와 저장된 `scenarioEffect`
- 고정 seed가 없는 경우 각 반복에 사용할 UUID seed
클라이언트가 같은 이름의 `scenarioEffect`를 보내도 입력 schema가 제거하며, 사용자가 전투 또는 정렬을 시작하면 `battle.prepareSimulation`은 인증과 Zod 입력
저장된 효과가 유일한 기준입니다. 1000회 실행 seed를 서버에서 한 번 확정하므로 검증을 유지하되 `WorldState`를 다시 조회하거나 입력·병종 정의를 response에
브라우저와 Node가 동일 payload를 재실행해 전체 결과를 정확히 비교할 수 있습니다. 반복하지 않습니다. 고정 seed가 없을 때 단일 UUID `seedBase`만 반환합니다. 따라서
전투 시작당 payload 준비를 위한 `WorldState` 추가 조회는 1회에서 0회로 줄고 응답
크기는 반복 횟수와 무관합니다. weight 0 접속·최근 활동 기록 같은 공통 request
lifecycle DB 작업은 기존 호환 계약대로 유지합니다. 브라우저는 로드 시 받은 권위
context와 현재 입력을 합쳐 Worker payload를 만듭니다.
`seedBase`는 반복 번호와 함께 명시적인 직렬화 문자열로 바뀐 뒤 SHA-512 기반
`LiteHashDRBG`의 seed가 됩니다. 1000개 seed 문자열을 전송하지 않으면서도 동일한
payload를 Node와 브라우저에서 재실행하면 전체 결과가 정확히 같습니다. 배포 전에
queue에 들어간 `seeds[]` payload는 각 항목을 먼저 소비해 기존 결과를 보존합니다.
## 실행 경로 ## 실행 경로
@@ -32,15 +39,15 @@ game-api의 processor/type 파일은 기존 import와 Redis worker 호환을 위
## RNG와 부작용 ## RNG와 부작용
- 고정 seed가 있으면 기존 계약대로 한 번만 실행합니다. - 고정 seed가 있으면 기존 계약대로 한 번만 실행합니다.
- 고정 seed가 없으면 `payload.seeds[index]`를 반복 순서대로 소비합니다. - 고정 seed가 없으면 구형 `payload.seeds[index]`, 새
- 구형 Redis payload처럼 seed 배열이 없을 때만 실행 runtime의 `payload.seedBase + 반복 번호`, runtime `crypto.randomUUID()` 순서로 seed를
`crypto.randomUUID()`를 fallback으로 사용합니다. 결정합니다.
- 계산은 전달받은 plain object에서 새 도메인 객체를 만들며 DB, Redis, 턴 상태를 - 계산은 전달받은 plain object에서 새 도메인 객체를 만들며 DB, Redis, 턴 상태를
변경하지 않습니다. 변경하지 않습니다.
## 검증 ## 검증
`battleSimulator.spec.ts`는 production bundle의 실제 Chromium module Worker를 `battleSimulator.spec.ts`는 production bundle의 실제 Chromium module Worker를
실행합니다. 고정 seed와 서버 발급 seed 1000 케이스에서 Node processor 결과와 실행합니다. 고정 seed와 단일 seed base 1000 케이스에서 Node processor 결과와
브라우저 Worker의 전체 결과 객체를 비교하고, 기본 UI 경로가 브라우저 Worker의 전체 결과 객체를 비교하고, 준비 응답이 128 byte 미만이며 기본
`battle.simulate`/`battle.getSimulation`을 호출하지 않는지 확인합니다. UI 경로가 `battle.simulate`/`battle.getSimulation`을 호출하지 않는지 확인합니다.
@@ -26,6 +26,7 @@ import {
createCrewTypeWarTriggerRegistry, createCrewTypeWarTriggerRegistry,
resolveDefenderOrder, resolveDefenderOrder,
resolveWarBattle, resolveWarBattle,
simpleSerialize,
type WarBattleOutcome, type WarBattleOutcome,
type WarActionModule, type WarActionModule,
type WarUnitReport, type WarUnitReport,
@@ -264,6 +265,17 @@ const resolveRandomSeed = (): string => {
return globalThis.crypto.randomUUID(); return globalThis.crypto.randomUUID();
}; };
const resolveRepeatSeed = (payload: BattleSimJobPayload, index: number): string => {
const legacySeed = payload.seeds?.[index];
if (legacySeed) {
return legacySeed;
}
if (payload.seedBase) {
return simpleSerialize(payload.seedBase, 'battle-simulator', index);
}
return resolveRandomSeed();
};
const resolveCityTrainAtmos = (year: number, startYear: number): number => const resolveCityTrainAtmos = (year: number, startYear: number): number =>
Math.min(110, Math.max(60, year - startYear + 59)); Math.min(110, Math.max(60, year - startYear + 59));
@@ -369,7 +381,7 @@ export const processBattleSimJob = (
const weight = 1 / Math.max(1, repeatCnt); const weight = 1 / Math.max(1, repeatCnt);
for (let idx = 0; idx < repeatCnt; idx += 1) { for (let idx = 0; idx < repeatCnt; idx += 1) {
const seed = baseSeed || payload.seeds?.[idx] || resolveRandomSeed(); const seed = baseSeed || resolveRepeatSeed(payload, idx);
const attackerNation = mapNationPayload(payload.attackerNation); const attackerNation = mapNationPayload(payload.attackerNation);
const defenderNation = mapNationPayload(payload.defenderNation); const defenderNation = mapNationPayload(payload.defenderNation);
const attackerCity = mapCityPayload(payload.attackerCity); const attackerCity = mapCityPayload(payload.attackerCity);
+3 -1
View File
@@ -103,7 +103,9 @@ export interface BattleSimJobPayload extends BattleSimRequestPayload {
config: WarEngineConfig; config: WarEngineConfig;
time: WarTimeContext; time: WarTimeContext;
scenarioEffect?: string | null; scenarioEffect?: string | null;
/** Server-issued seeds make a repeated run exactly reproducible in Node and browser runtimes. */ /** One secure random value expands deterministically into every repeated battle seed. */
seedBase?: string;
/** Legacy queue payload compatibility. New jobs use seedBase instead of an O(repeatCnt) array. */
seeds?: string[]; seeds?: string[];
} }