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
+31 -9
View File
@@ -43,7 +43,7 @@ const readImage = async (relative: string): Promise<Buffer> => {
throw new Error(`Reference image not found: ${relative}`);
};
const simulatorOptions = {
const simulatorFormOptions = {
world: { startYear: 190, currentYear: 205, currentMonth: 8 },
config: {
maxTrainByWar: 120,
@@ -138,6 +138,16 @@ const engineConfig: BattleSimJobPayload['config'] = {
},
};
const simulatorOptions = {
...simulatorFormOptions,
config: {
...engineConfig,
...simulatorFormOptions.config,
},
unitSet: engineUnitSet,
scenarioEffect: null,
};
const generalMe = {
general: {
id: 7,
@@ -206,6 +216,7 @@ type Fixture = {
requests: string[];
preparedPayloads: BattleSimJobPayload[];
serverResults: BattleSimResultPayload[];
prepareResponseBytes?: number[];
};
const readOperationInput = (
@@ -294,26 +305,29 @@ const installApi = async (page: Page, fixture: Fixture) => {
operations.length,
operationIndex
) as BattleSimRequestPayload;
const seedBase = request.seed ? null : 'playwright-repeat-seed';
const prepared: BattleSimJobPayload = {
...request,
seeds: request.seed
? []
: Array.from({ length: request.repeatCnt }, (_, index) => `playwright-repeat-${index}`),
...(seedBase ? { seedBase } : {}),
unitSet: engineUnitSet,
config: engineConfig,
config: simulatorOptions.config,
time: { year: request.year, month: request.month, startYear: 190 },
scenarioEffect: null,
};
fixture.preparedPayloads.push(prepared);
fixture.serverResults.push(processBattleSimJob(structuredClone(prepared)));
return response(prepared);
return response({ seedBase });
}
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({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
body,
});
});
};
@@ -351,6 +365,7 @@ test('operates independent/game presets, imports my general, and renders battle
requests: [],
preparedPayloads: [],
serverResults: [],
prepareResponseBytes: [],
};
await installApi(page, fixture);
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.getSimulation');
expect(fixture.prepareResponseBytes).toEqual([expect.any(Number)]);
expect(fixture.prepareResponseBytes?.every((bytes) => bytes < 128)).toBe(true);
expect(fixture.preparedPayloads[0]).toMatchObject({
attackerGeneral: { special: 'che_event_신산' },
});
@@ -439,6 +456,7 @@ test('keeps simulation available without a game general and preserves input afte
requests: [],
preparedPayloads: [],
serverResults: [],
prepareResponseBytes: [],
};
await installApi(page, fixture);
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 expect(page.getByText('시뮬레이터 입력 오류')).toBeVisible();
await expect(page.getByLabel('시드')).toHaveValue('keep-this-seed');
await expect(page.getByTestId('game-toast').filter({ hasText: '전투를 진행 중입니다.' })).toHaveCount(0);
fixture.prepareDelayMs = 1_500;
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: [],
preparedPayloads: [],
serverResults: [],
prepareResponseBytes: [],
};
await installApi(page, fixture);
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 });
expect(fixture.preparedPayloads).toHaveLength(1);
expect(fixture.preparedPayloads[0]?.seeds).toHaveLength(1000);
expect(new Set(fixture.preparedPayloads[0]?.seeds).size).toBe(1000);
expect(fixture.preparedPayloads[0]?.seedBase).toBe('playwright-repeat-seed');
expect(fixture.preparedPayloads[0]?.seeds).toBeUndefined();
expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]);
const battleSummary = page.locator('[data-parity-id="battle-summary"]');
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.requests).not.toContain('battle.simulate');
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 testWindow = window as unknown as { __battleWorkerUrls?: string[] };
return testWindow.__battleWorkerUrls ?? [];
@@ -4,6 +4,7 @@ import type { BattleSimOptions, GeneralDraft } from '../../utils/battleSimulator
interface Props {
options: BattleSimOptions;
crewTypes: Array<{ id: number; name: string; armType: number }>;
mode: 'attacker' | 'defender';
title: string;
canImportServer: boolean;
@@ -175,7 +176,7 @@ const officerLevelOptions = [
<label class="field">
<span>병종</span>
<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 }}
</option>
</select>
@@ -1,3 +1,5 @@
import type { UnitSetDefinition, WarEngineConfig } from '@sammo-ts/logic';
export type InheritBuff = {
warAvoidRatio: number;
warCriticalRatio: number;
@@ -47,16 +49,9 @@ export type BattleSimOptions = {
currentYear: number;
currentMonth: number;
};
config: {
maxTrainByWar: number;
maxAtmosByWar: number;
maxTrainByCommand: number;
maxAtmosByCommand: number;
};
unitSet: {
defaultCrewTypeId: number;
crewTypes: Array<{ id: number; name: string; armType: number }>;
};
config: WarEngineConfig;
unitSet: UnitSetDefinition;
scenarioEffect: string | null;
nationTypes: Array<{ key: string; name: string; info: string }>;
eventDomesticTraits: Array<{ key: string; name: string; info: string }>;
warTraits: Array<{ key: string; name: string; info: string }>;
@@ -1,5 +1,5 @@
<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 PanelCard from '../components/ui/PanelCard.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 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 attackerNation = reactive({
@@ -83,6 +84,16 @@ const generalListLoading = ref(false);
const selectedGeneralId = ref<number | null>(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;
const createInheritBuff = (): InheritBuff => ({
@@ -125,7 +136,7 @@ const resolveGeneralNo = (preferred: number | null, excludeId?: string): number
};
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 = {
id: nextGeneralId(),
no: 0,
@@ -219,7 +230,7 @@ const applyGeneralExport = (target: GeneralDraft, data: GeneralExport) => {
target.killcrew = data.killcrew;
target.inheritBuff = data.inheritBuff ? { ...data.inheritBuff } : createInheritBuff();
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 {
const payload = buildBattlePayload(action);
const preparedPayload = await trpc.battle.prepareSimulation.mutate(payload);
const result = await simulationWorker.run(preparedPayload);
const preparation = await trpc.battle.prepareSimulation.mutate(payload);
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) {
error.value = result.reason || 'battle_failed';
@@ -1106,6 +1128,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
v-model:general="attackerGeneral"
data-parity-id="attacker-general"
:options="options!"
:crew-types="availableCrewTypes"
mode="attacker"
title="출병자 설정"
:can-import-server="hasGameGeneral"
@@ -1192,6 +1215,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
v-model:general="defenders[index]"
:data-parity-id="index === 0 ? 'defender-general' : `defender-general-${index + 1}`"
:options="options!"
:crew-types="availableCrewTypes"
mode="defender"
title="수비자 설정"
:can-import-server="hasGameGeneral"