merge: 브라우저 전투 시뮬레이터 이관
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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<WarActionModule> => {
|
||||
const crewTypeCatalog = compileCrewTypeCatalog(unitSet, crewTypeWarTriggerRegistry);
|
||||
const scenario = createScenarioEffectActionModules(scenarioEffect);
|
||||
return createRefOrderedActionStack<WarActionModule>({
|
||||
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('<br>')),
|
||||
generalActionLog: convertLog(buckets.generalActionLog.join('<br>')),
|
||||
generalBattleResultLog: convertLog(buckets.generalBattleResultLog.join('<br>')),
|
||||
generalBattleDetailLog: convertLog(buckets.generalBattleDetailLog.join('<br>')),
|
||||
nationalHistoryLog: convertLog(buckets.nationalHistoryLog.join('<br>')),
|
||||
globalHistoryLog: convertLog(buckets.globalHistoryLog.join('<br>')),
|
||||
globalActionLog: convertLog(buckets.globalActionLog.join('<br>')),
|
||||
};
|
||||
};
|
||||
|
||||
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<string, number> = {};
|
||||
const defendersSkills: Array<Record<string, number>> = [];
|
||||
|
||||
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<number, number>();
|
||||
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';
|
||||
|
||||
@@ -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<string, number> | 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<string, number>;
|
||||
defendersSkills?: Array<Record<string, number>>;
|
||||
order?: number[];
|
||||
}
|
||||
export type {
|
||||
BattleSimAction,
|
||||
BattleSimCityPayload,
|
||||
BattleSimGeneralPayload,
|
||||
BattleSimJobPayload,
|
||||
BattleSimLogBuckets,
|
||||
BattleSimNationPayload,
|
||||
BattleSimRequestPayload,
|
||||
BattleSimResultPayload,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
export interface BattleSimJob {
|
||||
jobId: string;
|
||||
|
||||
@@ -62,6 +62,17 @@ 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);
|
||||
}),
|
||||
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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -60,6 +60,7 @@ export const generalAccessEndpointWeights = {
|
||||
'npc.setNationPolicy': 0,
|
||||
'npc.setNationPriority': 0,
|
||||
'npc.setGeneralPriority': 0,
|
||||
'battle.prepareSimulation': 0,
|
||||
'battle.simulate': 0,
|
||||
} as const satisfies Record<string, 0 | 1 | 2>;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,6 +121,7 @@ describe('general access tracking', () => {
|
||||
'npc.setNationPolicy': 0,
|
||||
'npc.setNationPriority': 0,
|
||||
'npc.setGeneralPriority': 0,
|
||||
'battle.prepareSimulation': 0,
|
||||
'battle.simulate': 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: '<span>유비가 모의전에서 승리했습니다.</span>',
|
||||
generalBattleDetailLog: '<span>필살 발동, 피해 1,234</span>',
|
||||
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<string, unknown>,
|
||||
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<BattleSimResultPayload> => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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<number, PendingRequest>();
|
||||
|
||||
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<BattleSimulatorWorkerResponse>): 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<BattleSimResultPayload> {
|
||||
const requestId = this.nextRequestId;
|
||||
this.nextRequestId += 1;
|
||||
const request: BattleSimulatorWorkerRequest = { requestId, payload };
|
||||
const promise = new Promise<BattleSimResultPayload>((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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } 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';
|
||||
@@ -7,6 +7,7 @@ import BattleGeneralCard from '../components/battle/BattleGeneralCard.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { getNpcColor } from '../utils/npcColor';
|
||||
import type { BattleSimOptions, GeneralDraft, InheritBuff } from '../utils/battleSimulatorTypes';
|
||||
import { BattleSimulatorWorkerClient } from '../utils/battleSimulatorWorkerClient';
|
||||
|
||||
type GeneralExport = Omit<GeneralDraft, 'id'>;
|
||||
|
||||
@@ -67,6 +68,11 @@ const defenders = ref<GeneralDraft[]>([]);
|
||||
|
||||
const isSimulating = ref(false);
|
||||
const statusMessage = ref<string | null>(null);
|
||||
const simulationWorker = new BattleSimulatorWorkerClient();
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
simulationWorker.dispose();
|
||||
});
|
||||
|
||||
const importOpen = ref(false);
|
||||
const importTarget = ref<GeneralDraft | null>(null);
|
||||
@@ -567,20 +573,6 @@ const buildBattlePayload = (action: BattleSimRequestPayload['action']): BattleSi
|
||||
};
|
||||
};
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const waitForSimulationResult = async (jobId: string): Promise<BattleSimResultPayload> => {
|
||||
const deadline = Date.now() + 15000;
|
||||
while (Date.now() < deadline) {
|
||||
const response = await trpc.battle.getSimulation.query({ jobId });
|
||||
if ('payload' in response && response.payload) {
|
||||
return response.payload;
|
||||
}
|
||||
await delay(800);
|
||||
}
|
||||
throw new Error('simulation_timeout');
|
||||
};
|
||||
|
||||
const runSimulation = async (action: BattleSimRequestPayload['action']) => {
|
||||
if (!options.value) {
|
||||
return;
|
||||
@@ -595,11 +587,8 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => {
|
||||
|
||||
try {
|
||||
const payload = buildBattlePayload(action);
|
||||
const response = await trpc.battle.simulate.mutate(payload);
|
||||
const result =
|
||||
'payload' in response && response.payload
|
||||
? response.payload
|
||||
: await waitForSimulationResult(response.jobId);
|
||||
const preparedPayload = await trpc.battle.prepareSimulation.mutate(payload);
|
||||
const result = await simulationWorker.run(preparedPayload);
|
||||
|
||||
if (!result.result) {
|
||||
error.value = result.reason || 'battle_failed';
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import type {
|
||||
BattleSimulatorWorkerRequest,
|
||||
BattleSimulatorWorkerResponse,
|
||||
} from '../utils/battleSimulatorWorkerProtocol';
|
||||
|
||||
const workerScope: DedicatedWorkerGlobalScope = self as DedicatedWorkerGlobalScope;
|
||||
|
||||
const loadProcessor = () => import('@sammo-ts/logic');
|
||||
let processorPromise: ReturnType<typeof loadProcessor> | null = null;
|
||||
|
||||
workerScope.addEventListener('message', async (event: MessageEvent<BattleSimulatorWorkerRequest>) => {
|
||||
const { requestId, payload } = event.data;
|
||||
let response: BattleSimulatorWorkerResponse;
|
||||
try {
|
||||
processorPromise ??= loadProcessor();
|
||||
const { processBattleSimJob } = await processorPromise;
|
||||
response = {
|
||||
requestId,
|
||||
ok: true,
|
||||
result: processBattleSimJob(payload),
|
||||
};
|
||||
} catch (error) {
|
||||
response = {
|
||||
requestId,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : '전투 시뮬레이션 오류',
|
||||
};
|
||||
}
|
||||
workerScope.postMessage(response);
|
||||
});
|
||||
@@ -33,6 +33,14 @@ export default defineConfig(({ mode }) => {
|
||||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
worker: {
|
||||
format: 'es',
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
codeSplitting: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(import.meta.dirname, './src'),
|
||||
|
||||
@@ -61,9 +61,11 @@ scenario parser, resource schema, PostgreSQL world loader와 battle simulator
|
||||
않습니다.
|
||||
|
||||
전투 시뮬레이터의 효과는 공개 request가 아니라 저장된 world config에서
|
||||
서버가 파생합니다. 내부 queue payload의 필드는 optional이므로 배포 전에
|
||||
생성된 payload는 효과 없음으로 처리하며, 신·구 API/worker 혼재 시에는
|
||||
효과 누락을 피하기 위해 queue를 비우거나 API와 worker를 함께 재시작합니다.
|
||||
서버가 파생합니다. `battle.prepareSimulation`은 해당 효과와 전체 병종 정의,
|
||||
전투 상수, 시나리오 시작 연도, 반복별 seed를 권위 payload로 만들고 브라우저
|
||||
Web Worker가 `@sammo-ts/logic`의 공용 프로세서를 실행합니다. 기존
|
||||
`battle.simulate`와 Redis worker도 같은 processor와 payload를 사용하므로
|
||||
검증·호환 fallback 경로에서 결과를 대조할 수 있습니다.
|
||||
|
||||
## 닫힌 의미 이벤트
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# 전투 시뮬레이터 브라우저 실행 경계
|
||||
|
||||
## 요청과 권위 데이터
|
||||
|
||||
`BattleSimulatorView.vue`는 사용자가 편집한 장수·국가·도시 입력을
|
||||
`battle.prepareSimulation`에 보냅니다. 이 API는 로그인만 요구하며 게임 장수
|
||||
보유 여부나 input-event transaction에는 의존하지 않습니다. 서버는 현재
|
||||
`WorldState`에서 다음 값을 읽어 실행 payload에 추가합니다.
|
||||
|
||||
- 전체 `UnitSetDefinition`
|
||||
- 전투 상수와 성벽 병종/병과 ID
|
||||
- 시나리오 시작 연도와 저장된 `scenarioEffect`
|
||||
- 고정 seed가 없는 경우 각 반복에 사용할 UUID seed
|
||||
|
||||
클라이언트가 같은 이름의 `scenarioEffect`를 보내도 입력 schema가 제거하며,
|
||||
저장된 효과가 유일한 기준입니다. 1000회 실행 seed를 서버에서 한 번 확정하므로
|
||||
브라우저와 Node가 동일 payload를 재실행해 전체 결과를 정확히 비교할 수 있습니다.
|
||||
|
||||
## 실행 경로
|
||||
|
||||
준비된 payload는 module Web Worker에 전달됩니다. Worker는 메시지 handler를 먼저
|
||||
등록한 뒤 `@sammo-ts/logic`을 lazy-load하고 `processBattleSimJob()`을 실행합니다.
|
||||
전투 중 UI thread를 점유하지 않으며, 결과만 화면 상태로 돌려줍니다. Worker
|
||||
bundle은 동적 trait/item module을 한 파일에 묶어 첫 실행의 다수 요청을 피합니다.
|
||||
|
||||
공용 processor와 DTO는 `packages/logic/src/battleSimulator/`가 소유합니다.
|
||||
game-api의 processor/type 파일은 기존 import와 Redis worker 호환을 위한 re-export
|
||||
경계입니다. 기존 `battle.simulate` → Redis queue → battle-sim worker 경로는 당장
|
||||
삭제하지 않고 검증 및 호환 fallback으로 유지하지만 기본 화면에서는 호출하지
|
||||
않습니다.
|
||||
|
||||
## RNG와 부작용
|
||||
|
||||
- 고정 seed가 있으면 기존 계약대로 한 번만 실행합니다.
|
||||
- 고정 seed가 없으면 `payload.seeds[index]`를 반복 순서대로 소비합니다.
|
||||
- 구형 Redis payload처럼 seed 배열이 없을 때만 실행 runtime의
|
||||
`crypto.randomUUID()`를 fallback으로 사용합니다.
|
||||
- 계산은 전달받은 plain object에서 새 도메인 객체를 만들며 DB, Redis, 턴 상태를
|
||||
변경하지 않습니다.
|
||||
|
||||
## 검증
|
||||
|
||||
`battleSimulator.spec.ts`는 production bundle의 실제 Chromium module Worker를
|
||||
실행합니다. 고정 seed와 서버 발급 seed 1000개 케이스에서 Node processor 결과와
|
||||
브라우저 Worker의 전체 결과 객체를 비교하고, 기본 UI 경로가
|
||||
`battle.simulate`/`battle.getSimulation`을 호출하지 않는지 확인합니다.
|
||||
@@ -41,10 +41,10 @@ writer reconciliation을 포함한다. rolling deployment가 끝난 뒤에만
|
||||
| Redis projection | 6 | `tournament.patchState`, `tournament.seedParticipants`, `tournament.setBettingEntries`, `tournament.setMatches`, `tournament.setParticipants`, `tournament.setState` |
|
||||
| operational | 3 | `turnDaemon.pause`, `turnDaemon.resume`, `turnDaemon.run` |
|
||||
| external upload | 1 | `board.uploadImage` |
|
||||
| read-only mutation transport | 1 | `battle.simulate` |
|
||||
| read-only mutation transport | 2 | `battle.prepareSimulation`, `battle.simulate` |
|
||||
| session only | 1 | `auth.exchangeGatewayToken` |
|
||||
|
||||
합계는 86개다.
|
||||
합계는 87개다.
|
||||
|
||||
## durable journal dependency 매핑
|
||||
|
||||
@@ -75,7 +75,8 @@ public dashboard event로 내보내지 않는다. browser wake-up은 정밀 enti
|
||||
- nation reserved turn, selection-pool reservation, possession 후보, inheritance owner
|
||||
확인은 각각 전용 화면/request response가 최신 상태를 소유한다.
|
||||
- image upload는 외부 content store write이며 game PostgreSQL read model이 아니다.
|
||||
- battle simulation은 호환상 mutation transport를 쓰지만 read-only 계산이다.
|
||||
- battle simulation 준비와 서버 fallback은 호환상 mutation transport를 쓰지만
|
||||
read-only 계산이며 input event transaction을 열지 않는다.
|
||||
|
||||
## 남은 업무 원자성 gap과 coverage 판정
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ storage, route guards, and image loading.
|
||||
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
|
||||
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
|
||||
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
|
||||
| battle simulator | `hwe/battle_simulator.php` | centered 1000px desktop document, 500px responsive stacking, independent/current presets, owned-general import gating, fixed-seed result/logs, retained input after API error |
|
||||
| battle simulator | `hwe/battle_simulator.php` | centered 1000px desktop document, 500px responsive stacking, independent/current presets, owned-general import gating, browser Web Worker calculation including 1000 repeats, fixed-seed result/logs, retained input after API error |
|
||||
| NPC policy | `hwe/v_NPCControl.php` | 1000/500px form and priority-list geometry, walnut/green textures, dynamic zero hints, drag/focus/tooltip, successful save and permission failures |
|
||||
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
|
||||
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './processor.js';
|
||||
export * from './types.js';
|
||||
@@ -0,0 +1,498 @@
|
||||
import { formatLegacyLogHtml, type RandUtil } from '@sammo-ts/common';
|
||||
import {
|
||||
createRefOrderedActionStack,
|
||||
createScenarioEffectActionModules,
|
||||
createTraitCatalog,
|
||||
createOfficerLevelActionModules,
|
||||
DOMESTIC_TRAIT_KEYS,
|
||||
EVENT_DOMESTIC_TRAIT_KEYS,
|
||||
loadDomesticTraitModules,
|
||||
loadEventDomesticTraitModules,
|
||||
loadNationTraitModules,
|
||||
loadPersonalityTraitModules,
|
||||
loadWarTraitModules,
|
||||
NATION_TRAIT_KEYS,
|
||||
PERSONALITY_TRAIT_KEYS,
|
||||
TraitWarActionRouter,
|
||||
WAR_TRAIT_KEYS,
|
||||
type RefOrderedActionStack,
|
||||
} from '../actionModules/index.js';
|
||||
import { compileCrewTypeCatalog } from '../crewType/index.js';
|
||||
import type { City, General, Nation } from '../domain/entities.js';
|
||||
import { createInheritBuffModules } from '../inheritance/inheritBuff.js';
|
||||
import { createItemActionModules, createItemModuleRegistry, ITEM_KEYS, loadItemModules } from '../items/index.js';
|
||||
import { formatLogText, LogCategory, LogFormat, LogScope } from '../logging/index.js';
|
||||
import {
|
||||
createCrewTypeWarTriggerRegistry,
|
||||
resolveDefenderOrder,
|
||||
resolveWarBattle,
|
||||
type WarBattleOutcome,
|
||||
type WarActionModule,
|
||||
type WarUnitReport,
|
||||
type WarBattleTraceEvent,
|
||||
} from '../war/index.js';
|
||||
import { getTechCost, type CrewTypeDefinition, type UnitSetDefinition } from '../world/index.js';
|
||||
|
||||
import { type BattleSimJobPayload, type BattleSimLogBuckets, type BattleSimResultPayload } from './types.js';
|
||||
|
||||
const DEFAULT_GENERAL_AGE = 20;
|
||||
|
||||
const convertLog = (value: string, type = 1): string => formatLegacyLogHtml(value, { colorize: type > 0 });
|
||||
|
||||
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<WarActionModule> => {
|
||||
const crewTypeCatalog = compileCrewTypeCatalog(unitSet, crewTypeWarTriggerRegistry);
|
||||
const scenario = createScenarioEffectActionModules(scenarioEffect);
|
||||
return createRefOrderedActionStack<WarActionModule>({
|
||||
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('<br>')),
|
||||
generalActionLog: convertLog(buckets.generalActionLog.join('<br>')),
|
||||
generalBattleResultLog: convertLog(buckets.generalBattleResultLog.join('<br>')),
|
||||
generalBattleDetailLog: convertLog(buckets.generalBattleDetailLog.join('<br>')),
|
||||
nationalHistoryLog: convertLog(buckets.nationalHistoryLog.join('<br>')),
|
||||
globalHistoryLog: convertLog(buckets.globalHistoryLog.join('<br>')),
|
||||
globalActionLog: convertLog(buckets.globalActionLog.join('<br>')),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveRandomSeed = (): string => {
|
||||
if (typeof globalThis.crypto?.randomUUID !== 'function') {
|
||||
throw new Error('Secure random UUID generation is unavailable.');
|
||||
}
|
||||
return globalThis.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<string, number> = {};
|
||||
const defendersSkills: Array<Record<string, number>> = [];
|
||||
|
||||
const weight = 1 / Math.max(1, repeatCnt);
|
||||
|
||||
for (let idx = 0; idx < repeatCnt; idx += 1) {
|
||||
const seed = baseSeed || payload.seeds?.[idx] || 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<number, number>();
|
||||
initialRice.set(attackerGeneral.id, attackerGeneral.rice);
|
||||
for (const defender of defenderGenerals) {
|
||||
initialRice.set(defender.id, defender.rice);
|
||||
}
|
||||
|
||||
const outcome = resolveWarBattle({
|
||||
seed,
|
||||
...(options.rngFactory ? { 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,
|
||||
...(options.trace ? { 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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { WarEngineConfig, WarTimeContext } from '../war/types.js';
|
||||
import type { UnitSetDefinition } from '../world/types.js';
|
||||
|
||||
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<string, number> | 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;
|
||||
/** Server-issued seeds make a repeated run exactly reproducible in Node and browser runtimes. */
|
||||
seeds?: string[];
|
||||
}
|
||||
|
||||
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<string, number>;
|
||||
defendersSkills?: Array<Record<string, number>>;
|
||||
order?: number[];
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export * from './actions/index.js';
|
||||
export * from './actionModules/index.js';
|
||||
export * from './auction/alias.js';
|
||||
export * from './auction/neutral.js';
|
||||
export * from './battleSimulator/index.js';
|
||||
export * from './constraints/index.js';
|
||||
export * from './crewType/index.js';
|
||||
export * from './diplomacy/index.js';
|
||||
|
||||
Reference in New Issue
Block a user