merge: 전투 시뮬레이터 payload 축소를 main에 반영한다

This commit is contained in:
2026-08-23 13:28:26 +00:00
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',
+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"
+7 -5
View File
@@ -61,11 +61,13 @@ scenario parser, resource schema, PostgreSQL world loader와 battle simulator
않습니다.
전투 시뮬레이터의 효과는 공개 request가 아니라 저장된 world config에서
서버가 파생합니다. `battle.prepareSimulation` 해당 효과와 전체 병종 정의,
전투 상수, 시나리오 시작 연도, 반복별 seed를 권위 payload로 만들고 브라우저
Web Worker가 `@sammo-ts/logic`의 공용 프로세서를 실행합니다. 기존
`battle.simulate`와 Redis worker도 같은 processor와 payload를 사용하므로
검증·호환 fallback 경로에서 결과를 대조할 수 있습니다.
서버가 파생합니다. `battle.getSimulatorContext`는 화면을 열 때 해당 효과와 전체
병종 정의, 전투 상수, 시나리오 시작 연도를 한 번 전달합니다.
`battle.prepareSimulation`은 실행할 때 입력을 검증하고 반복 실행용 단일
`seedBase`만 발급하며, 브라우저 Web Worker가 두 자료를 합쳐
`@sammo-ts/logic`의 공용 프로세서를 실행합니다. 기존 `battle.simulate`와 Redis
worker도 같은 processor와 payload를 사용하므로 검증·호환 fallback 경로에서
결과를 대조할 수 있습니다.
## 닫힌 의미 이벤트
@@ -2,19 +2,26 @@
## 요청과 권위 데이터
`BattleSimulatorView.vue`사용자가 편집한 장수·국가·도시 입력을
`battle.prepareSimulation`에 보냅니다. 이 API는 로그인만 요구하며 게임 장수
보유 여부나 input-event transaction에는 의존하지 않습니다. 서버는 현재
`WorldState`에서 다음 값을 읽어 실행 payload에 추가합니다.
`BattleSimulatorView.vue`화면을 열 때 `battle.getSimulatorContext`에서 form
option과 브라우저 Worker의 실행 context를 한 번 함께 받습니다. 이 API는 현재
`WorldState`를 한 번 읽어 다음 서버 권위 값을 제공합니다.
- 전체 `UnitSetDefinition`
- 전투 상수와 성벽 병종/병과 ID
- 시나리오 시작 연도와 저장된 `scenarioEffect`
- 고정 seed가 없는 경우 각 반복에 사용할 UUID seed
클라이언트가 같은 이름의 `scenarioEffect`를 보내도 입력 schema가 제거하며,
저장된 효과가 유일한 기준입니다. 1000회 실행 seed를 서버에서 한 번 확정하므로
브라우저와 Node가 동일 payload를 재실행해 전체 결과를 정확히 비교할 수 있습니다.
사용자가 전투 또는 정렬을 시작하면 `battle.prepareSimulation`은 인증과 Zod 입력
검증을 유지하되 `WorldState`를 다시 조회하거나 입력·병종 정의를 response에
반복하지 않습니다. 고정 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와 부작용
- 고정 seed가 있으면 기존 계약대로 한 번만 실행합니다.
- 고정 seed가 없으면 `payload.seeds[index]`를 반복 순서대로 소비합니다.
- 구형 Redis payload처럼 seed 배열이 없을 때만 실행 runtime의
`crypto.randomUUID()`를 fallback으로 사용합니다.
- 고정 seed가 없으면 구형 `payload.seeds[index]`, 새
`payload.seedBase + 반복 번호`, runtime `crypto.randomUUID()` 순서로 seed를
결정합니다.
- 계산은 전달받은 plain object에서 새 도메인 객체를 만들며 DB, Redis, 턴 상태를
변경하지 않습니다.
## 검증
`battleSimulator.spec.ts`는 production bundle의 실제 Chromium module Worker를
실행합니다. 고정 seed와 서버 발급 seed 1000 케이스에서 Node processor 결과와
브라우저 Worker의 전체 결과 객체를 비교하고, 기본 UI 경로가
`battle.simulate`/`battle.getSimulation`을 호출하지 않는지 확인합니다.
실행합니다. 고정 seed와 단일 seed base 1000 케이스에서 Node processor 결과와
브라우저 Worker의 전체 결과 객체를 비교하고, 준비 응답이 128 byte 미만이며 기본
UI 경로가 `battle.simulate`/`battle.getSimulation`을 호출하지 않는지 확인합니다.
@@ -26,6 +26,7 @@ import {
createCrewTypeWarTriggerRegistry,
resolveDefenderOrder,
resolveWarBattle,
simpleSerialize,
type WarBattleOutcome,
type WarActionModule,
type WarUnitReport,
@@ -264,6 +265,17 @@ const resolveRandomSeed = (): string => {
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 =>
Math.min(110, Math.max(60, year - startYear + 59));
@@ -369,7 +381,7 @@ export const processBattleSimJob = (
const weight = 1 / Math.max(1, repeatCnt);
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 defenderNation = mapNationPayload(payload.defenderNation);
const attackerCity = mapCityPayload(payload.attackerCity);
+3 -1
View File
@@ -103,7 +103,9 @@ export interface BattleSimJobPayload extends BattleSimRequestPayload {
config: WarEngineConfig;
time: WarTimeContext;
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[];
}