feat: 전투 시뮬레이터를 브라우저 Worker로 이관
서버가 권위 환경과 반복 seed를 준비하고 공용 logic 프로세서를 브라우저와 기존 서버 fallback이 함께 사용하도록 변경한다. production Chromium에서 고정 seed와 1000회 Node 결과 동등성을 검증한다.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
|
||||
import type { BattleSimJobPayload } from '../src/battleSim/types.js';
|
||||
import { processBattleSimJob } from '../src/battleSim/processor.js';
|
||||
@@ -259,6 +260,26 @@ describe('battle sim processor', () => {
|
||||
expect(processBattleSimJob(legacyPayload)).toEqual(processBattleSimJob(baselinePayload));
|
||||
});
|
||||
|
||||
it('uses server-issued per-repeat seeds deterministically when no fixed seed is supplied', () => {
|
||||
const firstPayload = buildPayload('battle');
|
||||
delete firstPayload.seed;
|
||||
firstPayload.repeatCnt = 2;
|
||||
firstPayload.seeds = ['server-repeat-0', 'server-repeat-1'];
|
||||
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(observedSeeds).toEqual(['server-repeat-0', 'server-repeat-1']);
|
||||
});
|
||||
|
||||
it('returns the fixed defender ID order for reorder action', () => {
|
||||
const payload = buildPayload('reorder');
|
||||
const result = processBattleSimJob(payload);
|
||||
|
||||
@@ -257,6 +257,58 @@ const buildContext = (options: {
|
||||
};
|
||||
|
||||
describe('battle router orchestration', () => {
|
||||
it('prepares the authoritative browser-worker payload without queuing server work', 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'),
|
||||
};
|
||||
const caller = appRouter.createCaller(buildContext({ state, battleSim }));
|
||||
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(battleSim.simulateCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('does not allocate repeat seeds when the client supplies a fixed seed', 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 prepared = await caller.battle.prepareSimulation(buildBattleRequest());
|
||||
|
||||
expect(prepared.seed).toBe('test-seed');
|
||||
expect(prepared.seeds).toEqual([]);
|
||||
expect(battleSim.simulateCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('returns queued then completed results via transport', async () => {
|
||||
const battleSim = new QueuedBattleSimTransport();
|
||||
const state: WorldStateRow = {
|
||||
@@ -351,6 +403,9 @@ describe('battle router orchestration', () => {
|
||||
} as unknown as DatabaseClient;
|
||||
|
||||
const anonymous = appRouter.createCaller(buildContext({ state, battleSim, userId: null, db }));
|
||||
await expect(anonymous.battle.prepareSimulation(buildBattleRequest())).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
await expect(anonymous.battle.simulate(buildBattleRequest())).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
@@ -358,6 +413,10 @@ describe('battle router orchestration', () => {
|
||||
const noGeneralUser = appRouter.createCaller(
|
||||
buildContext({ state, battleSim, userId: 'user-without-general', db })
|
||||
);
|
||||
await expect(noGeneralUser.battle.prepareSimulation(buildBattleRequest())).resolves.toMatchObject({
|
||||
action: 'battle',
|
||||
seed: 'test-seed',
|
||||
});
|
||||
await expect(noGeneralUser.battle.simulate(buildBattleRequest())).resolves.toMatchObject({
|
||||
status: 'queued',
|
||||
});
|
||||
|
||||
@@ -96,7 +96,7 @@ const classifications = {
|
||||
],
|
||||
operational: ['turnDaemon.pause', 'turnDaemon.resume', 'turnDaemon.run'],
|
||||
externalUpload: ['board.uploadImage'],
|
||||
readOnlyMutationTransport: ['battle.simulate'],
|
||||
readOnlyMutationTransport: ['battle.prepareSimulation', 'battle.simulate'],
|
||||
sessionOnly: ['auth.exchangeGatewayToken'],
|
||||
} as const;
|
||||
|
||||
@@ -142,7 +142,7 @@ describe('game-api direct mutation journal inventory', () => {
|
||||
const classified = Object.values(classifications).flat().sort();
|
||||
|
||||
expect(new Set(classified).size).toBe(classified.length);
|
||||
expect(classified).toHaveLength(86);
|
||||
expect(classified).toHaveLength(87);
|
||||
expect(actual).toEqual(classified);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,6 +122,7 @@ describe('general access tracking', () => {
|
||||
'npc.setNationPolicy': 0,
|
||||
'npc.setNationPriority': 0,
|
||||
'npc.setGeneralPriority': 0,
|
||||
'battle.prepareSimulation': 0,
|
||||
'battle.simulate': 0,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user