perf: 전투 시뮬레이터 시작 payload를 줄인다
권위 실행 context를 화면 진입 때 한 번 전달하고 준비 응답은 seed base만 반환한다. 반복별 seed 배열은 결정적 파생값으로 대체하되 구형 queue seed 우선순위와 고정 seed 동작을 보존한다.
This commit is contained in:
@@ -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,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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user