From 161ec90bf84e0c1c1b2b04abfcc543bfb939c2a3 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 17 Aug 2026 01:45:41 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=84=ED=88=AC=20=EC=8B=9C=EB=AE=AC?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EB=B8=8C=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=EC=A0=80=20Worker=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버가 권위 환경과 반복 seed를 준비하고 공용 logic 프로세서를 브라우저와 기존 서버 fallback이 함께 사용하도록 변경한다. production Chromium에서 고정 seed와 1000회 Node 결과 동등성을 검증한다. --- app/game-api/src/battleSim/environment.ts | 8 +- app/game-api/src/battleSim/processor.ts | 509 +----------------- app/game-api/src/battleSim/types.ts | 147 +---- app/game-api/src/router/battle/index.ts | 231 ++++---- app/game-api/src/services/generalAccess.ts | 1 + app/game-api/test/battleSimProcessor.test.ts | 21 + app/game-api/test/battleSimRouter.test.ts | 59 ++ .../directMutationJournalInventory.test.ts | 4 +- .../test/generalAccessTracking.test.ts | 1 + app/game-frontend/e2e/battleSimulator.spec.ts | 261 ++++++--- .../src/utils/battleSimulatorWorkerClient.ts | 81 +++ .../utils/battleSimulatorWorkerProtocol.ts | 18 + .../src/views/BattleSimulatorView.vue | 29 +- .../src/workers/battleSimulator.worker.ts | 32 ++ app/game-frontend/vite.config.ts | 8 + docs/architecture/action-module-protocol.md | 8 +- .../battle-simulator-browser-worker.md | 46 ++ ...e-api-direct-mutation-journal-inventory.md | 7 +- docs/frontend-legacy-parity.md | 2 +- packages/logic/src/battleSimulator/index.ts | 2 + .../logic/src/battleSimulator/processor.ts | 498 +++++++++++++++++ packages/logic/src/battleSimulator/types.ts | 138 +++++ packages/logic/src/index.ts | 1 + 23 files changed, 1258 insertions(+), 854 deletions(-) create mode 100644 app/game-frontend/src/utils/battleSimulatorWorkerClient.ts create mode 100644 app/game-frontend/src/utils/battleSimulatorWorkerProtocol.ts create mode 100644 app/game-frontend/src/workers/battleSimulator.worker.ts create mode 100644 docs/architecture/battle-simulator-browser-worker.md create mode 100644 packages/logic/src/battleSimulator/index.ts create mode 100644 packages/logic/src/battleSimulator/processor.ts create mode 100644 packages/logic/src/battleSimulator/types.ts diff --git a/app/game-api/src/battleSim/environment.ts b/app/game-api/src/battleSim/environment.ts index e31f570f..264c45fb 100644 --- a/app/game-api/src/battleSim/environment.ts +++ b/app/game-api/src/battleSim/environment.ts @@ -1,10 +1,13 @@ -import type { WorldStateRow } from '../context.js'; -import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.js'; +import { randomUUID } from 'node:crypto'; + import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js'; import { normalizeScenarioEffect, type ScenarioEffectKey, type WarEngineConfig } from '@sammo-ts/logic'; import { asRecord } from '@sammo-ts/common'; import type { UnitSetDefinition } from '@sammo-ts/logic'; +import type { WorldStateRow } from '../context.js'; +import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.js'; + const DEFAULT_WAR_CONFIG = { armPerPhase: 500, maxTrainByCommand: 100, @@ -133,6 +136,7 @@ export const buildBattleSimJobPayload = async ( return { ...request, + seeds: request.seed ? [] : Array.from({ length: request.repeatCnt }, () => randomUUID()), unitSet: environment.unitSet, config: environment.config, time: { diff --git a/app/game-api/src/battleSim/processor.ts b/app/game-api/src/battleSim/processor.ts index 7b496a9a..84e8be49 100644 --- a/app/game-api/src/battleSim/processor.ts +++ b/app/game-api/src/battleSim/processor.ts @@ -1,507 +1,2 @@ -import crypto from 'node:crypto'; - -import type { RandUtil } from '@sammo-ts/common'; -import { - formatLogText, - getTechCost, - LogCategory, - LogFormat, - LogScope, - resolveDefenderOrder, - resolveWarBattle, - createItemActionModules, - createItemModuleRegistry, - createRefOrderedActionStack, - createScenarioEffectActionModules, - ITEM_KEYS, - loadItemModules, - createInheritBuffModules, - createTraitCatalog, - createOfficerLevelActionModules, - DOMESTIC_TRAIT_KEYS, - EVENT_DOMESTIC_TRAIT_KEYS, - loadDomesticTraitModules, - loadEventDomesticTraitModules, - loadNationTraitModules, - loadPersonalityTraitModules, - loadWarTraitModules, - NATION_TRAIT_KEYS, - PERSONALITY_TRAIT_KEYS, - TraitWarActionRouter, - WAR_TRAIT_KEYS, - compileCrewTypeCatalog, - createCrewTypeWarTriggerRegistry, - type City, - type General, - type Nation, - type RefOrderedActionStack, - type UnitSetDefinition, - type WarBattleOutcome, - type WarActionModule, - type WarUnitReport, - type WarBattleTraceEvent, - type CrewTypeDefinition, -} from '@sammo-ts/logic'; - -import { type BattleSimJobPayload, type BattleSimLogBuckets, type BattleSimResultPayload } from './types.js'; -import { convertLog } from './logFormatter.js'; - -const DEFAULT_GENERAL_AGE = 20; - -const inheritBuffModules = createInheritBuffModules(); -const itemWarModules: WarActionModule[] = createItemActionModules( - createItemModuleRegistry(await loadItemModules([...ITEM_KEYS])) -).war; -const crewTypeWarTriggerRegistry = createCrewTypeWarTriggerRegistry(); -const traitCatalog = createTraitCatalog({ - domestic: [ - ...(await loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS])), - ...(await loadEventDomesticTraitModules([...EVENT_DOMESTIC_TRAIT_KEYS])), - ], - war: await loadWarTraitModules([...WAR_TRAIT_KEYS]), - personality: await loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]), - nation: await loadNationTraitModules([...NATION_TRAIT_KEYS]), -}); -const nationWarModule = new TraitWarActionRouter('nation', traitCatalog); -const officerWarModule = createOfficerLevelActionModules().war; -const domesticWarModule = new TraitWarActionRouter('domestic', traitCatalog); -const warTraitModule = new TraitWarActionRouter('war', traitCatalog); -const personalityWarModule = new TraitWarActionRouter('personality', traitCatalog); - -const buildWarActionModules = ( - unitSet: UnitSetDefinition, - scenarioEffect?: string | null -): RefOrderedActionStack => { - const crewTypeCatalog = compileCrewTypeCatalog(unitSet, crewTypeWarTriggerRegistry); - const scenario = createScenarioEffectActionModules(scenarioEffect); - return createRefOrderedActionStack({ - nation: nationWarModule, - officer: officerWarModule, - domestic: domesticWarModule, - war: warTraitModule, - personality: personalityWarModule, - crewType: crewTypeCatalog.warActionModule, - inheritance: inheritBuffModules.war, - scenario: scenario.war, - items: itemWarModules, - }); -}; - -const normalizeItemCode = (value: string | null): string | null => (value === 'None' ? null : value); - -const mapNationPayload = (payload: BattleSimJobPayload['attackerNation']): Nation => ({ - id: payload.nation, - name: payload.name, - color: '#000000', - capitalCityId: payload.capital, - chiefGeneralId: null, - gold: payload.gold, - rice: payload.rice, - power: 0, - level: payload.level, - typeCode: payload.type, - meta: { - tech: payload.tech, - gennum: payload.gennum, - }, -}); - -const mapCityPayload = (payload: BattleSimJobPayload['attackerCity']): City => ({ - id: payload.city, - name: payload.name, - nationId: payload.nation, - level: payload.level, - state: payload.state, - population: payload.pop, - populationMax: payload.pop_max, - agriculture: payload.agri, - agricultureMax: payload.agri_max, - commerce: payload.comm, - commerceMax: payload.comm_max, - security: payload.secu, - securityMax: payload.secu_max, - supplyState: payload.supply, - frontState: payload.state, - defence: payload.def, - defenceMax: payload.def_max, - wall: payload.wall, - wallMax: payload.wall_max, - meta: { - trust: payload.trust, - dead: payload.dead, - conflict: payload.conflict, - supply: payload.supply, - }, -}); - -const mapGeneralPayload = ( - payload: BattleSimJobPayload['attackerGeneral'], - currentCityId: number -): General => ({ - id: payload.no, - name: payload.name, - nationId: payload.nation, - cityId: payload.city ?? currentCityId, - troopId: 0, - stats: { - leadership: payload.leadership, - strength: payload.strength, - intelligence: payload.intel, - }, - experience: payload.experience, - dedication: payload.dedication, - officerLevel: payload.officer_level, - role: { - personality: payload.personal, - specialDomestic: payload.special ?? null, - specialWar: payload.special2, - items: { - horse: normalizeItemCode(payload.horse), - weapon: normalizeItemCode(payload.weapon), - book: normalizeItemCode(payload.book), - item: normalizeItemCode(payload.item), - }, - }, - injury: payload.injury, - gold: payload.gold, - rice: payload.rice, - crew: payload.crew, - crewTypeId: payload.crewtype, - train: payload.train, - atmos: payload.atmos, - age: DEFAULT_GENERAL_AGE, - npcState: 0, - triggerState: { - flags: {}, - counters: {}, - modifiers: {}, - meta: payload.inheritBuff ? { inheritBuff: JSON.stringify(payload.inheritBuff) } : {}, - }, - meta: { - killturn: 24, - explevel: payload.explevel, - turnTime: payload.turntime, - recentWar: payload.recent_war ?? '', - dex1: payload.dex1, - dex2: payload.dex2, - dex3: payload.dex3, - dex4: payload.dex4, - dex5: payload.dex5, - intel_exp: payload.intel_exp, - strength_exp: payload.strength_exp, - leadership_exp: payload.leadership_exp, - defence_train: payload.defence_train, - officerCity: payload.officer_city, - officer_city: payload.officer_city, - rank_warnum: payload.warnum, - rank_killnum: payload.killnum, - rank_killcrew: payload.killcrew, - }, -}); - -const buildLogBuckets = (options: { - logs: WarBattleOutcome['logs']; - year: number; - month: number; - attackerId: number; - attackerNationId: number; -}): BattleSimLogBuckets => { - const buckets = { - generalHistoryLog: [] as string[], - generalActionLog: [] as string[], - generalBattleResultLog: [] as string[], - generalBattleDetailLog: [] as string[], - nationalHistoryLog: [] as string[], - globalHistoryLog: [] as string[], - globalActionLog: [] as string[], - }; - - for (const entry of options.logs) { - const format = entry.format ?? LogFormat.RAWTEXT; - const text = formatLogText(entry.text, format, options.year, options.month); - - if (entry.scope === LogScope.GENERAL && entry.generalId === options.attackerId) { - switch (entry.category) { - case LogCategory.HISTORY: - buckets.generalHistoryLog.push(text); - break; - case LogCategory.ACTION: - buckets.generalActionLog.push(text); - break; - case LogCategory.BATTLE_BRIEF: - buckets.generalBattleResultLog.push(text); - break; - case LogCategory.BATTLE_DETAIL: - buckets.generalBattleDetailLog.push(text); - break; - default: - break; - } - continue; - } - - if ( - entry.scope === LogScope.NATION && - entry.nationId === options.attackerNationId && - entry.category === LogCategory.HISTORY - ) { - buckets.nationalHistoryLog.push(text); - continue; - } - - if (entry.scope === LogScope.SYSTEM) { - if (entry.category === LogCategory.HISTORY) { - buckets.globalHistoryLog.push(text); - } else if (entry.category === LogCategory.SUMMARY) { - buckets.globalActionLog.push(text); - } - } - } - - return { - generalHistoryLog: convertLog(buckets.generalHistoryLog.join('
')), - generalActionLog: convertLog(buckets.generalActionLog.join('
')), - generalBattleResultLog: convertLog(buckets.generalBattleResultLog.join('
')), - generalBattleDetailLog: convertLog(buckets.generalBattleDetailLog.join('
')), - nationalHistoryLog: convertLog(buckets.nationalHistoryLog.join('
')), - globalHistoryLog: convertLog(buckets.globalHistoryLog.join('
')), - globalActionLog: convertLog(buckets.globalActionLog.join('
')), - }; -}; - -const resolveRandomSeed = (): string => crypto.randomUUID(); - -const resolveCityTrainAtmos = (year: number, startYear: number): number => - Math.min(110, Math.max(60, year - startYear + 59)); - -const resolveCityRiceConsumption = (options: { - battle: WarBattleOutcome; - defenderNation: Nation; - unitSet: UnitSetDefinition; - castleCrewTypeId: number; - year: number; - startYear: number; -}): number => { - const cityReport = options.battle.reports.find((report: WarUnitReport) => report.type === 'city'); - if (!cityReport) { - return 0; - } - if (cityReport.killed <= 0 && cityReport.dead <= 0) { - return 0; - } - - const crewType = options.unitSet.crewTypes?.find( - (item: CrewTypeDefinition) => item.id === options.castleCrewTypeId - ); - const riceCoef = crewType?.rice ?? 1; - const tech = Number(options.defenderNation.meta.tech ?? 0); - const trainAtmos = resolveCityTrainAtmos(options.year, options.startYear); - - let rice = (cityReport.killed / 100) * 0.8; - rice *= riceCoef; - rice *= getTechCost(tech); - rice *= trainAtmos / 100 - 0.2; - return Math.round(rice); -}; - -const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] => { - const attackerNation = mapNationPayload(payload.attackerNation); - const defenderNation = mapNationPayload(payload.defenderNation); - const attackerCity = mapCityPayload(payload.attackerCity); - const defenderCity = mapCityPayload(payload.defenderCity); - const attacker = mapGeneralPayload(payload.attackerGeneral, attackerCity.id); - const defenders = payload.defenderGenerals.map((general) => mapGeneralPayload(general, defenderCity.id)); - const warActionModules = buildWarActionModules(payload.unitSet, payload.scenarioEffect); - - return resolveDefenderOrder({ - unitSet: payload.unitSet, - config: payload.config, - time: payload.time, - seed: 'order', - attacker: { - general: attacker, - city: attackerCity, - nation: attackerNation, - modules: warActionModules, - }, - defenders: defenders.map((general) => ({ - general, - city: defenderCity, - nation: defenderNation, - modules: warActionModules, - })), - defenderCity, - defenderNation, - }); -}; - -export interface BattleSimProcessorOptions { - trace?: (event: WarBattleTraceEvent) => void; - rngFactory?: (seed: string) => RandUtil; -} - -export const processBattleSimJob = ( - payload: BattleSimJobPayload, - options: BattleSimProcessorOptions = {} -): BattleSimResultPayload => { - if (payload.action === 'reorder') { - return { - result: true, - reason: 'success', - order: resolveDefenderOrderPayload(payload), - }; - } - - let repeatCnt = payload.repeatCnt; - const warActionModules = buildWarActionModules(payload.unitSet, payload.scenarioEffect); - const baseSeed = payload.seed ?? ''; - if (baseSeed) { - repeatCnt = 1; - } - - let lastBattle: WarBattleOutcome | null = null; - let attackerKilled = 0; - let attackerDead = 0; - let attackerMaxKilled = 0; - let attackerMinKilled = Number.POSITIVE_INFINITY; - let attackerMaxDead = 0; - let attackerMinDead = Number.POSITIVE_INFINITY; - let attackerAvgRice = 0; - let defenderAvgRice = 0; - let avgPhase = 0; - let avgWar = 0; - const attackerSkills: Record = {}; - const defendersSkills: Array> = []; - - const weight = 1 / Math.max(1, repeatCnt); - - for (let idx = 0; idx < repeatCnt; idx += 1) { - const seed = idx === 0 ? baseSeed || resolveRandomSeed() : resolveRandomSeed(); - const attackerNation = mapNationPayload(payload.attackerNation); - const defenderNation = mapNationPayload(payload.defenderNation); - const attackerCity = mapCityPayload(payload.attackerCity); - const defenderCity = mapCityPayload(payload.defenderCity); - const attackerGeneral = mapGeneralPayload(payload.attackerGeneral, attackerCity.id); - const defenderGenerals = payload.defenderGenerals.map((general) => - mapGeneralPayload(general, defenderCity.id) - ); - - const initialRice = new Map(); - initialRice.set(attackerGeneral.id, attackerGeneral.rice); - for (const defender of defenderGenerals) { - initialRice.set(defender.id, defender.rice); - } - - const outcome = resolveWarBattle({ - seed, - rng: options.rngFactory?.(seed), - unitSet: payload.unitSet, - config: payload.config, - time: payload.time, - attacker: { - general: attackerGeneral, - city: attackerCity, - nation: attackerNation, - modules: warActionModules, - }, - defenders: defenderGenerals.map((general) => ({ - general, - city: defenderCity, - nation: defenderNation, - modules: warActionModules, - })), - defenderCity, - defenderNation, - trace: options.trace, - }); - - lastBattle = outcome; - const attackerReport = outcome.reports.find( - (report: WarUnitReport) => report.type === 'general' && report.isAttacker - ); - const killed = attackerReport?.killed ?? 0; - const dead = attackerReport?.dead ?? 0; - - attackerKilled += killed * weight; - attackerDead += dead * weight; - attackerMaxKilled = Math.max(attackerMaxKilled, killed); - attackerMinKilled = Math.min(attackerMinKilled, killed); - attackerMaxDead = Math.max(attackerMaxDead, dead); - attackerMinDead = Math.min(attackerMinDead, dead); - - const phase = outcome.metrics?.attackerPhase ?? 0; - avgPhase += phase * weight; - const defenderCount = outcome.metrics?.defenderActivatedSkills.length ?? 0; - avgWar += defenderCount * weight; - - const attackerRiceInit = initialRice.get(attackerGeneral.id) ?? attackerGeneral.rice; - attackerAvgRice += (attackerRiceInit - outcome.attacker.rice) * weight; - - let defenderRiceInit = 0; - let defenderRiceAfter = 0; - for (const defender of outcome.defenders) { - defenderRiceInit += initialRice.get(defender.id) ?? defender.rice; - defenderRiceAfter += defender.rice; - } - - const cityRice = resolveCityRiceConsumption({ - battle: outcome, - defenderNation, - unitSet: payload.unitSet, - castleCrewTypeId: payload.config.castleCrewTypeId, - year: payload.time.year, - startYear: payload.time.startYear, - }); - defenderAvgRice += (defenderRiceInit - defenderRiceAfter + cityRice) * weight; - - const attackerActivated = outcome.metrics?.attackerActivatedSkills ?? {}; - for (const [skillName, value] of Object.entries(attackerActivated) as [string, number][]) { - attackerSkills[skillName] = (attackerSkills[skillName] ?? 0) + value * weight; - } - - const defenderActivated = outcome.metrics?.defenderActivatedSkills ?? []; - for (let defIdx = 0; defIdx < defenderActivated.length; defIdx += 1) { - while (defIdx >= defendersSkills.length) { - defendersSkills.push({}); - } - const bucket = defendersSkills[defIdx]!; - for (const [skillName, value] of Object.entries(defenderActivated[defIdx]!) as [string, number][]) { - bucket[skillName] = (bucket[skillName] ?? 0) + value * weight; - } - } - } - - if (!lastBattle) { - return { - result: false, - reason: '전투 결과를 생성하지 못했습니다.', - }; - } - - const logBuckets = buildLogBuckets({ - logs: lastBattle.logs, - year: payload.time.year, - month: payload.time.month, - attackerId: lastBattle.attacker.id, - attackerNationId: payload.attackerNation.nation, - }); - - return { - result: true, - reason: 'success', - datetime: payload.attackerGeneral.turntime, - lastWarLog: logBuckets, - avgWar, - phase: avgPhase, - killed: attackerKilled, - maxKilled: attackerMaxKilled, - minKilled: attackerMinKilled === Number.POSITIVE_INFINITY ? 0 : attackerMinKilled, - dead: attackerDead, - maxDead: attackerMaxDead, - minDead: attackerMinDead === Number.POSITIVE_INFINITY ? 0 : attackerMinDead, - attackerRice: attackerAvgRice, - defenderRice: defenderAvgRice, - attackerSkills, - defendersSkills, - }; -}; +export { processBattleSimJob } from '@sammo-ts/logic'; +export type { BattleSimProcessorOptions } from '@sammo-ts/logic'; diff --git a/app/game-api/src/battleSim/types.ts b/app/game-api/src/battleSim/types.ts index 463c4c2d..56fda8f1 100644 --- a/app/game-api/src/battleSim/types.ts +++ b/app/game-api/src/battleSim/types.ts @@ -1,140 +1,15 @@ -import type { UnitSetDefinition } from '@sammo-ts/logic'; +import type { BattleSimJobPayload, BattleSimResultPayload } from '@sammo-ts/logic'; -import type { WarEngineConfig, WarTimeContext } from '@sammo-ts/logic'; - -export type BattleSimAction = 'reorder' | 'battle'; - -export interface BattleSimGeneralPayload { - no: number; - name: string; - nation: number; - /** Current city. Older clients omit this; the surrounding city payload is authoritative then. */ - city?: number; - turntime: string; - personal: string | null; - special?: string | null; - special2: string | null; - crew: number; - crewtype: number; - atmos: number; - train: number; - intel: number; - intel_exp: number; - book: string | null; - strength: number; - strength_exp: number; - weapon: string | null; - injury: number; - leadership: number; - leadership_exp: number; - horse: string | null; - item: string | null; - explevel: number; - experience: number; - dedication: number; - officer_level: number; - officer_city: number; - gold: number; - rice: number; - dex1: number; - dex2: number; - dex3: number; - dex4: number; - dex5: number; - defence_train: number; - recent_war: string | null; - warnum: number; - killnum: number; - killcrew: number; - inheritBuff?: Record | number[]; -} - -export interface BattleSimCityPayload { - city: number; - nation: number; - supply: number; - name: string; - pop: number; - agri: number; - comm: number; - secu: number; - def: number; - wall: number; - trust: number; - level: number; - pop_max: number; - agri_max: number; - comm_max: number; - secu_max: number; - def_max: number; - wall_max: number; - dead: number; - state: number; - conflict: string; -} - -export interface BattleSimNationPayload { - type: string; - tech: number; - level: number; - capital: number; - nation: number; - name: string; - gold: number; - rice: number; - gennum: number; -} - -export interface BattleSimRequestPayload { - action: BattleSimAction; - seed?: string; - repeatCnt: number; - year: number; - month: number; - attackerGeneral: BattleSimGeneralPayload; - attackerCity: BattleSimCityPayload; - attackerNation: BattleSimNationPayload; - defenderGenerals: BattleSimGeneralPayload[]; - defenderCity: BattleSimCityPayload; - defenderNation: BattleSimNationPayload; -} - -export interface BattleSimJobPayload extends BattleSimRequestPayload { - unitSet: UnitSetDefinition; - config: WarEngineConfig; - time: WarTimeContext; - scenarioEffect?: string | null; -} - -export interface BattleSimLogBuckets { - generalHistoryLog: string; - generalActionLog: string; - generalBattleResultLog: string; - generalBattleDetailLog: string; - nationalHistoryLog: string; - globalHistoryLog: string; - globalActionLog: string; -} - -export interface BattleSimResultPayload { - result: boolean; - reason: string; - datetime?: string; - lastWarLog?: BattleSimLogBuckets; - avgWar?: number; - phase?: number; - killed?: number; - maxKilled?: number; - minKilled?: number; - dead?: number; - maxDead?: number; - minDead?: number; - attackerRice?: number; - defenderRice?: number; - attackerSkills?: Record; - defendersSkills?: Array>; - order?: number[]; -} +export type { + BattleSimAction, + BattleSimCityPayload, + BattleSimGeneralPayload, + BattleSimJobPayload, + BattleSimLogBuckets, + BattleSimNationPayload, + BattleSimRequestPayload, + BattleSimResultPayload, +} from '@sammo-ts/logic'; export interface BattleSimJob { jobId: string; diff --git a/app/game-api/src/router/battle/index.ts b/app/game-api/src/router/battle/index.ts index b18c9eac..56daf92f 100644 --- a/app/game-api/src/router/battle/index.ts +++ b/app/game-api/src/router/battle/index.ts @@ -62,6 +62,17 @@ 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); + }), simulate: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(async ({ ctx, input }) => { const worldState = await ctx.db.worldState.findFirst(); if (!worldState) { @@ -176,122 +187,120 @@ export const battleRouter = router({ }; }), getGeneralDetail: accessAuthedInputProcedure( - z.object({ - generalId: z.number().int().positive(), - }) - ) - .query(async ({ ctx, input }) => { - const worldState = await ctx.db.worldState.findFirst(); - if (!worldState) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: 'World state is not initialized.', - }); - } - - const me = await getMyGeneral(ctx); - const general = await ctx.db.general.findUnique({ - where: { id: input.generalId }, - select: { - id: true, - name: true, - npcState: true, - nationId: true, - leadership: true, - strength: true, - intel: true, - officerLevel: true, - injury: true, - rice: true, - crew: true, - crewTypeId: true, - atmos: true, - train: true, - experience: true, - horseCode: true, - weaponCode: true, - bookCode: true, - itemCode: true, - personalCode: true, - specialCode: true, - special2Code: true, - meta: true, - }, + z.object({ + generalId: z.number().int().positive(), + }) + ).query(async ({ ctx, input }) => { + const worldState = await ctx.db.worldState.findFirst(); + if (!worldState) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'World state is not initialized.', }); + } - if (!general) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'General not found.', - }); - } + const me = await getMyGeneral(ctx); + const general = await ctx.db.general.findUnique({ + where: { id: input.generalId }, + select: { + id: true, + name: true, + npcState: true, + nationId: true, + leadership: true, + strength: true, + intel: true, + officerLevel: true, + injury: true, + rice: true, + crew: true, + crewTypeId: true, + atmos: true, + train: true, + experience: true, + horseCode: true, + weaponCode: true, + bookCode: true, + itemCode: true, + personalCode: true, + specialCode: true, + special2Code: true, + meta: true, + }, + }); - const environment = await buildBattleSimEnvironment(worldState, ctx.profile.id); - const defaultCrewTypeId = - environment.unitSet.defaultCrewTypeId ?? environment.unitSet.crewTypes?.[0]?.id ?? 0; + if (!general) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'General not found.', + }); + } - const meta = asRecord(general.meta); - const isSameNation = me.nationId > 0 && me.nationId === general.nationId; + const environment = await buildBattleSimEnvironment(worldState, ctx.profile.id); + const defaultCrewTypeId = environment.unitSet.defaultCrewTypeId ?? environment.unitSet.crewTypes?.[0]?.id ?? 0; - const base = { - no: general.id, - name: general.name, - officer_level: general.officerLevel, - explevel: resolveExpLevel(meta, general.experience), - leadership: general.leadership, - horse: normalizeOptionalKey(general.horseCode), - strength: general.strength, - weapon: normalizeOptionalKey(general.weaponCode), - intel: general.intel, - book: normalizeOptionalKey(general.bookCode), - item: normalizeOptionalKey(general.itemCode), - injury: general.injury, - rice: general.rice, - personal: normalizeOptionalKey(general.personalCode), - special: normalizeOptionalKey(general.specialCode), - special2: normalizeOptionalKey(general.special2Code), - crew: general.crew, - crewtype: general.crewTypeId, - atmos: general.atmos, - train: general.train, - dex1: resolveDexValue(meta, 'dex1'), - dex2: resolveDexValue(meta, 'dex2'), - dex3: resolveDexValue(meta, 'dex3'), - dex4: resolveDexValue(meta, 'dex4'), - dex5: resolveDexValue(meta, 'dex5'), - defence_train: readNumber(meta.defenceTrain, 80), - warnum: readNumber(meta.rank_warnum, 0), - killnum: readNumber(meta.rank_killnum, 0), - killcrew: readNumber(meta.rank_killcrew, 0), + const meta = asRecord(general.meta); + const isSameNation = me.nationId > 0 && me.nationId === general.nationId; + + const base = { + no: general.id, + name: general.name, + officer_level: general.officerLevel, + explevel: resolveExpLevel(meta, general.experience), + leadership: general.leadership, + horse: normalizeOptionalKey(general.horseCode), + strength: general.strength, + weapon: normalizeOptionalKey(general.weaponCode), + intel: general.intel, + book: normalizeOptionalKey(general.bookCode), + item: normalizeOptionalKey(general.itemCode), + injury: general.injury, + rice: general.rice, + personal: normalizeOptionalKey(general.personalCode), + special: normalizeOptionalKey(general.specialCode), + special2: normalizeOptionalKey(general.special2Code), + crew: general.crew, + crewtype: general.crewTypeId, + atmos: general.atmos, + train: general.train, + dex1: resolveDexValue(meta, 'dex1'), + dex2: resolveDexValue(meta, 'dex2'), + dex3: resolveDexValue(meta, 'dex3'), + dex4: resolveDexValue(meta, 'dex4'), + dex5: resolveDexValue(meta, 'dex5'), + defence_train: readNumber(meta.defenceTrain, 80), + warnum: readNumber(meta.rank_warnum, 0), + killnum: readNumber(meta.rank_killnum, 0), + killcrew: readNumber(meta.rank_killcrew, 0), + }; + + if (!isSameNation) { + return { + general: { + ...base, + officer_level: 1, + horse: null, + weapon: null, + book: null, + item: null, + crew: 0, + crewtype: defaultCrewTypeId, + rice: 10000, + train: environment.config.maxTrainByCommand, + atmos: environment.config.maxAtmosByCommand, + dex1: 0, + dex2: 0, + dex3: 0, + dex4: 0, + dex5: 0, + defence_train: 80, + warnum: 0, + killnum: 0, + killcrew: 0, + }, }; + } - if (!isSameNation) { - return { - general: { - ...base, - officer_level: 1, - horse: null, - weapon: null, - book: null, - item: null, - crew: 0, - crewtype: defaultCrewTypeId, - rice: 10000, - train: environment.config.maxTrainByCommand, - atmos: environment.config.maxAtmosByCommand, - dex1: 0, - dex2: 0, - dex3: 0, - dex4: 0, - dex5: 0, - defence_train: 80, - warnum: 0, - killnum: 0, - killcrew: 0, - }, - }; - } - - return { general: base }; - }), + return { general: base }; + }), }); diff --git a/app/game-api/src/services/generalAccess.ts b/app/game-api/src/services/generalAccess.ts index 662e721b..b0e1561f 100644 --- a/app/game-api/src/services/generalAccess.ts +++ b/app/game-api/src/services/generalAccess.ts @@ -61,6 +61,7 @@ export const generalAccessEndpointWeights = { 'npc.setNationPolicy': 0, 'npc.setNationPriority': 0, 'npc.setGeneralPriority': 0, + 'battle.prepareSimulation': 0, 'battle.simulate': 0, } as const satisfies Record; diff --git a/app/game-api/test/battleSimProcessor.test.ts b/app/game-api/test/battleSimProcessor.test.ts index b07c4524..233b3d51 100644 --- a/app/game-api/test/battleSimProcessor.test.ts +++ b/app/game-api/test/battleSimProcessor.test.ts @@ -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); diff --git a/app/game-api/test/battleSimRouter.test.ts b/app/game-api/test/battleSimRouter.test.ts index ff3a96c1..3e765fe0 100644 --- a/app/game-api/test/battleSimRouter.test.ts +++ b/app/game-api/test/battleSimRouter.test.ts @@ -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).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', }); diff --git a/app/game-api/test/directMutationJournalInventory.test.ts b/app/game-api/test/directMutationJournalInventory.test.ts index 13081d78..9789fa10 100644 --- a/app/game-api/test/directMutationJournalInventory.test.ts +++ b/app/game-api/test/directMutationJournalInventory.test.ts @@ -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); }); }); diff --git a/app/game-api/test/generalAccessTracking.test.ts b/app/game-api/test/generalAccessTracking.test.ts index 30439e66..0e701620 100644 --- a/app/game-api/test/generalAccessTracking.test.ts +++ b/app/game-api/test/generalAccessTracking.test.ts @@ -122,6 +122,7 @@ describe('general access tracking', () => { 'npc.setNationPolicy': 0, 'npc.setNationPriority': 0, 'npc.setGeneralPriority': 0, + 'battle.prepareSimulation': 0, 'battle.simulate': 0, }); }); diff --git a/app/game-frontend/e2e/battleSimulator.spec.ts b/app/game-frontend/e2e/battleSimulator.spec.ts index d3389a2b..ebd67c11 100644 --- a/app/game-frontend/e2e/battleSimulator.spec.ts +++ b/app/game-frontend/e2e/battleSimulator.spec.ts @@ -2,6 +2,12 @@ import { expect, test, type Page, type Route } from '@playwright/test'; import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + processBattleSimJob, + type BattleSimJobPayload, + type BattleSimRequestPayload, + type BattleSimResultPayload, +} from '@sammo-ts/logic'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); @@ -71,6 +77,67 @@ const simulatorOptions = { ], }; +const engineUnitSet: BattleSimJobPayload['unitSet'] = { + id: 'playwright', + name: 'playwright', + crewTypes: [ + { + id: 100, + armType: 1, + name: '보병', + attack: 100, + defence: 100, + speed: 7, + avoid: 10, + magicCoef: 0, + cost: 9, + rice: 9, + requirements: [], + attackCoef: {}, + defenceCoef: {}, + info: [], + initSkillTrigger: null, + phaseSkillTrigger: null, + iActionList: null, + }, + { + id: 999, + armType: 9, + name: '성벽', + attack: 0, + defence: 0, + speed: 1, + avoid: 0, + magicCoef: 0, + cost: 0, + rice: 9, + requirements: [], + attackCoef: {}, + defenceCoef: {}, + info: [], + initSkillTrigger: null, + phaseSkillTrigger: null, + iActionList: null, + }, + ], +}; + +const engineConfig: BattleSimJobPayload['config'] = { + armPerPhase: 500, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + maxTrainByWar: 110, + maxAtmosByWar: 150, + castleCrewTypeId: 999, + armTypes: { + footman: 1, + wizard: 4, + siege: 5, + misc: 6, + castle: 9, + }, +}; + const generalMe = { general: { id: 7, @@ -132,40 +199,25 @@ const importedGeneral = { }, }; -const simulationResult = { - result: true, - reason: 'success', - datetime: '205-08', - avgWar: 5, - phase: 13, - killed: 1234, - maxKilled: 1400, - minKilled: 1100, - dead: 432, - maxDead: 500, - minDead: 400, - attackerRice: 321, - defenderRice: 654, - attackerSkills: { 필살: 2 }, - defendersSkills: [{ 회피: 1 }], - lastWarLog: { - generalHistoryLog: '', - generalActionLog: '', - generalBattleResultLog: '유비가 모의전에서 승리했습니다.', - generalBattleDetailLog: '필살 발동, 피해 1,234', - nationalHistoryLog: '', - globalHistoryLog: '', - globalActionLog: '', - }, -}; - type Fixture = { hasGeneral: boolean; failNextSimulation?: boolean; - queueFirst?: boolean; - pollingCount: number; requests: string[]; - simulationPayloads: unknown[]; + preparedPayloads: BattleSimJobPayload[]; + serverResults: BattleSimResultPayload[]; +}; + +const readOperationInput = ( + requestBody: Record, + operationCount: number, + operationIndex: number +): unknown => { + const rawPayload = requestBody[String(operationIndex)] ?? (operationCount === 1 ? requestBody : undefined); + if (!rawPayload || typeof rawPayload !== 'object') { + return rawPayload; + } + const payload = rawPayload as { json?: unknown; input?: { json?: unknown } }; + return payload.json ?? payload.input?.json ?? rawPayload; }; const installImages = async (page: Page) => { @@ -182,6 +234,22 @@ const installImages = async (page: Page) => { const installApi = async (page: Page, fixture: Fixture) => { await installImages(page); + await page.addInitScript(() => { + const nativeWorker = window.Worker; + const testWindow = window as unknown as { + __battleWorkerResponses: unknown[]; + __battleWorkerUrls: string[]; + }; + testWindow.__battleWorkerResponses = []; + testWindow.__battleWorkerUrls = []; + window.Worker = class TrackedWorker extends nativeWorker { + constructor(scriptURL: string | URL, options?: WorkerOptions) { + super(scriptURL, options); + testWindow.__battleWorkerUrls.push(String(scriptURL)); + this.addEventListener('message', (event) => testWindow.__battleWorkerResponses.push(event.data)); + } + }; + }); await page.addInitScript((profile) => { window.localStorage.setItem('sammo-game-token', 'ga_battle_sim_playwright'); window.localStorage.setItem('sammo-game-profile', profile); @@ -212,36 +280,29 @@ const installApi = async (page: Page, fixture: Fixture) => { }); } if (operation === 'battle.getGeneralDetail') return response(importedGeneral); - if (operation === 'battle.simulate') { - const rawPayload = - requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined); - const payload = - rawPayload && typeof rawPayload === 'object' - ? (rawPayload as { - json?: unknown; - input?: { json?: unknown }; - }) - : undefined; - fixture.simulationPayloads.push(payload?.json ?? payload?.input?.json ?? rawPayload); + if (operation === 'battle.prepareSimulation') { if (fixture.failNextSimulation) { fixture.failNextSimulation = false; return errorResponse(operation, '시뮬레이터 입력 오류'); } - if (fixture.queueFirst) { - return response({ status: 'queued', jobId: 'job-playwright' }); - } - return response({ status: 'completed', jobId: 'job-playwright', payload: simulationResult }); - } - if (operation === 'battle.getSimulation') { - fixture.pollingCount += 1; - if (fixture.pollingCount === 1) { - return response({ status: 'queued', jobId: 'job-playwright' }); - } - return response({ - status: 'completed', - jobId: 'job-playwright', - payload: simulationResult, - }); + const request = readOperationInput( + requestBody, + operations.length, + operationIndex + ) as BattleSimRequestPayload; + const prepared: BattleSimJobPayload = { + ...request, + seeds: request.seed + ? [] + : Array.from({ length: request.repeatCnt }, (_, index) => `playwright-repeat-${index}`), + unitSet: engineUnitSet, + config: engineConfig, + 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 errorResponse(operation, `Unhandled battle simulator fixture operation: ${operation}`); }); @@ -253,6 +314,26 @@ const installApi = async (page: Page, fixture: Fixture) => { }); }; +const readBrowserWorkerResult = async (page: Page, resultIndex: number): Promise => { + await expect + .poll(async () => + page.evaluate((index) => { + const testWindow = window as unknown as { __battleWorkerResponses?: unknown[] }; + return Boolean(testWindow.__battleWorkerResponses?.[index]); + }, resultIndex) + ) + .toBe(true); + const response = (await page.evaluate((index) => { + const testWindow = window as unknown as { __battleWorkerResponses?: unknown[] }; + return testWindow.__battleWorkerResponses?.[index]; + }, resultIndex)) as { + ok: boolean; + result: BattleSimResultPayload; + }; + expect(response.ok).toBe(true); + return response.result; +}; + const gotoSimulator = async (page: Page) => { await page.goto('battle-simulator'); await expect(page.getByText('전역 설정')).toBeVisible(); @@ -263,10 +344,9 @@ const gotoSimulator = async (page: Page) => { test('operates independent/game presets, imports my general, and renders battle logs', async ({ page }) => { const fixture: Fixture = { hasGeneral: true, - queueFirst: true, - pollingCount: 0, requests: [], - simulationPayloads: [], + preparedPayloads: [], + serverResults: [], }; await installApi(page, fixture); await page.setViewportSize({ width: 1280, height: 900 }); @@ -295,11 +375,22 @@ test('operates independent/game presets, imports my general, and renders battle await page.getByLabel('시드').fill('playwright-fixed-seed'); await battleButton.click(); - await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible(); - await expect(page.getByText('5', { exact: true })).toBeVisible(); - expect(fixture.pollingCount).toBe(2); - expect(fixture.requests).toContain('battle.getSimulation'); - expect(fixture.simulationPayloads[0]).toMatchObject({ + await expect(page.getByText('전투를 진행 중입니다.')).toHaveCount(0); + expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]); + for (const [parityId, html] of [ + ['battle-log', fixture.serverResults[0]?.lastWarLog?.generalBattleResultLog ?? ''], + ['battle-detail-log', fixture.serverResults[0]?.lastWarLog?.generalBattleDetailLog ?? ''], + ] as const) { + const browserNormalizedHtml = await page.evaluate((rawHtml) => { + const element = document.createElement('div'); + element.innerHTML = rawHtml; + return element.innerHTML; + }, html); + expect(await page.locator(`[data-parity-id="${parityId}"]`).innerHTML()).toBe(browserNormalizedHtml); + } + expect(fixture.requests).not.toContain('battle.simulate'); + expect(fixture.requests).not.toContain('battle.getSimulation'); + expect(fixture.preparedPayloads[0]).toMatchObject({ attackerGeneral: { special: 'che_event_신산' }, }); @@ -318,8 +409,9 @@ test('operates independent/game presets, imports my general, and renders battle await page.locator('.header-actions input[type="file"]').setInputFiles(downloadPath!); await battleButton.click(); - await expect.poll(() => fixture.simulationPayloads.length).toBe(2); - expect(fixture.simulationPayloads[1]).toMatchObject({ + await expect.poll(() => fixture.preparedPayloads.length).toBe(2); + expect(await readBrowserWorkerResult(page, 1)).toEqual(fixture.serverResults[1]); + expect(fixture.preparedPayloads[1]).toMatchObject({ attackerGeneral: { special: 'che_event_신산' }, }); @@ -336,9 +428,9 @@ test('keeps simulation available without a game general and preserves input afte const fixture: Fixture = { hasGeneral: false, failNextSimulation: true, - pollingCount: 0, requests: [], - simulationPayloads: [], + preparedPayloads: [], + serverResults: [], }; await installApi(page, fixture); await page.setViewportSize({ width: 500, height: 900 }); @@ -355,8 +447,9 @@ test('keeps simulation available without a game general and preserves input afte await expect(page.getByLabel('시드')).toHaveValue('keep-this-seed'); await page.getByRole('button', { name: '전투', exact: true }).click(); - await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible(); + await expect(page.getByText('전투를 진행 중입니다.')).toHaveCount(0); await expect(page.getByText('시뮬레이터 입력 오류')).toHaveCount(0); + expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]); const notice = page.getByLabel('시뮬레이터 데이터 안내'); expect(await notice.evaluate((element) => getComputedStyle(element).position)).toBe('absolute'); @@ -370,3 +463,33 @@ test('keeps simulation available without a game general and preserves input afte }); } }); + +test('runs 1000 battles in the Chromium worker and matches the Node processor exactly', async ({ page }) => { + test.setTimeout(60_000); + const fixture: Fixture = { + hasGeneral: false, + requests: [], + preparedPayloads: [], + serverResults: [], + }; + await installApi(page, fixture); + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoSimulator(page); + + await page.getByLabel('반복 횟수').selectOption('1000'); + await page.getByLabel('시드').fill(''); + await page.getByRole('button', { name: '전투', exact: true }).click(); + await expect(page.getByText('전투를 진행 중입니다.')).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(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]); + expect(fixture.requests).not.toContain('battle.simulate'); + expect(fixture.requests).not.toContain('battle.getSimulation'); + const workerUrls = await page.evaluate(() => { + const testWindow = window as unknown as { __battleWorkerUrls?: string[] }; + return testWindow.__battleWorkerUrls ?? []; + }); + expect(workerUrls.some((url) => url.includes('battleSimulator.worker'))).toBe(true); +}); diff --git a/app/game-frontend/src/utils/battleSimulatorWorkerClient.ts b/app/game-frontend/src/utils/battleSimulatorWorkerClient.ts new file mode 100644 index 00000000..8402c4a0 --- /dev/null +++ b/app/game-frontend/src/utils/battleSimulatorWorkerClient.ts @@ -0,0 +1,81 @@ +import type { BattleSimJobPayload, BattleSimResultPayload } from '@sammo-ts/logic'; + +import type { BattleSimulatorWorkerRequest, BattleSimulatorWorkerResponse } from './battleSimulatorWorkerProtocol'; + +type PendingRequest = { + resolve: (result: BattleSimResultPayload) => void; + reject: (error: Error) => void; +}; + +export class BattleSimulatorWorkerClient { + private worker: Worker | null = null; + private nextRequestId = 1; + private readonly pending = new Map(); + + private ensureWorker(): Worker { + if (this.worker) { + return this.worker; + } + + const worker = new Worker(new URL('../workers/battleSimulator.worker.ts', import.meta.url), { + type: 'module', + name: 'sammo-battle-simulator', + }); + worker.addEventListener('message', this.handleMessage); + worker.addEventListener('error', this.handleWorkerError); + this.worker = worker; + return worker; + } + + private readonly handleMessage = (event: MessageEvent): void => { + const pending = this.pending.get(event.data.requestId); + if (!pending) { + return; + } + this.pending.delete(event.data.requestId); + if (event.data.ok) { + pending.resolve(event.data.result); + return; + } + pending.reject(new Error(event.data.error)); + }; + + private readonly handleWorkerError = (event: ErrorEvent): void => { + const error = new Error(event.message || '전투 시뮬레이션 worker 오류'); + for (const pending of this.pending.values()) { + pending.reject(error); + } + this.pending.clear(); + this.disposeWorker(); + }; + + public run(payload: BattleSimJobPayload): Promise { + const requestId = this.nextRequestId; + this.nextRequestId += 1; + const request: BattleSimulatorWorkerRequest = { requestId, payload }; + const promise = new Promise((resolve, reject) => { + this.pending.set(requestId, { resolve, reject }); + }); + this.ensureWorker().postMessage(request); + return promise; + } + + private disposeWorker(): void { + if (!this.worker) { + return; + } + this.worker.removeEventListener('message', this.handleMessage); + this.worker.removeEventListener('error', this.handleWorkerError); + this.worker.terminate(); + this.worker = null; + } + + public dispose(): void { + const error = new Error('전투 시뮬레이션이 취소되었습니다.'); + for (const pending of this.pending.values()) { + pending.reject(error); + } + this.pending.clear(); + this.disposeWorker(); + } +} diff --git a/app/game-frontend/src/utils/battleSimulatorWorkerProtocol.ts b/app/game-frontend/src/utils/battleSimulatorWorkerProtocol.ts new file mode 100644 index 00000000..88483d38 --- /dev/null +++ b/app/game-frontend/src/utils/battleSimulatorWorkerProtocol.ts @@ -0,0 +1,18 @@ +import type { BattleSimJobPayload, BattleSimResultPayload } from '@sammo-ts/logic'; + +export interface BattleSimulatorWorkerRequest { + requestId: number; + payload: BattleSimJobPayload; +} + +export type BattleSimulatorWorkerResponse = + | { + requestId: number; + ok: true; + result: BattleSimResultPayload; + } + | { + requestId: number; + ok: false; + error: string; + }; diff --git a/app/game-frontend/src/views/BattleSimulatorView.vue b/app/game-frontend/src/views/BattleSimulatorView.vue index 5559df15..3f49c0cf 100644 --- a/app/game-frontend/src/views/BattleSimulatorView.vue +++ b/app/game-frontend/src/views/BattleSimulatorView.vue @@ -1,5 +1,5 @@