diff --git a/app/game-api/src/battleSim/environment.ts b/app/game-api/src/battleSim/environment.ts index 3cea4877..6a6056b6 100644 --- a/app/game-api/src/battleSim/environment.ts +++ b/app/game-api/src/battleSim/environment.ts @@ -90,6 +90,9 @@ export interface BattleSimEnvironment { scenarioEffect: ScenarioEffectKey | null; } +export const buildBattleSimSeedBase = (request: Pick): 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 => { 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: { diff --git a/app/game-api/src/router/battle/index.ts b/app/game-api/src/router/battle/index.ts index 56daf92f..45be9ebb 100644 --- a/app/game-api/src/router/battle/index.ts +++ b/app/game-api/src/router/battle/index.ts @@ -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, 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, diff --git a/app/game-api/test/battleSimProcessor.test.ts b/app/game-api/test/battleSimProcessor.test.ts index d569422a..8bead300 100644 --- a/app/game-api/test/battleSimProcessor.test.ts +++ b/app/game-api/test/battleSimProcessor.test.ts @@ -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); diff --git a/app/game-api/test/battleSimRouter.test.ts b/app/game-api/test/battleSimRouter.test.ts index 7d60b37c..a30b9b4d 100644 --- a/app/game-api/test/battleSimRouter.test.ts +++ b/app/game-api/test/battleSimRouter.test.ts @@ -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).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).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', diff --git a/app/game-frontend/e2e/battleSimulator.spec.ts b/app/game-frontend/e2e/battleSimulator.spec.ts index 64207b26..4e1833b2 100644 --- a/app/game-frontend/e2e/battleSimulator.spec.ts +++ b/app/game-frontend/e2e/battleSimulator.spec.ts @@ -43,7 +43,7 @@ const readImage = async (relative: string): Promise => { 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 ?? []; diff --git a/app/game-frontend/src/components/battle/BattleGeneralCard.vue b/app/game-frontend/src/components/battle/BattleGeneralCard.vue index 379f7c19..1f35164b 100644 --- a/app/game-frontend/src/components/battle/BattleGeneralCard.vue +++ b/app/game-frontend/src/components/battle/BattleGeneralCard.vue @@ -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 = [