merge: 브라우저 전투 시뮬레이터 이관
This commit is contained in:
@@ -1,10 +1,13 @@
|
|||||||
import type { WorldStateRow } from '../context.js';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.js';
|
|
||||||
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
|
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
|
||||||
import { normalizeScenarioEffect, type ScenarioEffectKey, type WarEngineConfig } from '@sammo-ts/logic';
|
import { normalizeScenarioEffect, type ScenarioEffectKey, type WarEngineConfig } from '@sammo-ts/logic';
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import type { UnitSetDefinition } from '@sammo-ts/logic';
|
import type { UnitSetDefinition } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import type { WorldStateRow } from '../context.js';
|
||||||
|
import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.js';
|
||||||
|
|
||||||
const DEFAULT_WAR_CONFIG = {
|
const DEFAULT_WAR_CONFIG = {
|
||||||
armPerPhase: 500,
|
armPerPhase: 500,
|
||||||
maxTrainByCommand: 100,
|
maxTrainByCommand: 100,
|
||||||
@@ -133,6 +136,7 @@ export const buildBattleSimJobPayload = async (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...request,
|
...request,
|
||||||
|
seeds: request.seed ? [] : Array.from({ length: request.repeatCnt }, () => randomUUID()),
|
||||||
unitSet: environment.unitSet,
|
unitSet: environment.unitSet,
|
||||||
config: environment.config,
|
config: environment.config,
|
||||||
time: {
|
time: {
|
||||||
|
|||||||
@@ -1,507 +1,2 @@
|
|||||||
import crypto from 'node:crypto';
|
export { processBattleSimJob } from '@sammo-ts/logic';
|
||||||
|
export type { BattleSimProcessorOptions } from '@sammo-ts/logic';
|
||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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,
|
||||||
export type BattleSimAction = 'reorder' | 'battle';
|
BattleSimCityPayload,
|
||||||
|
BattleSimGeneralPayload,
|
||||||
export interface BattleSimGeneralPayload {
|
BattleSimJobPayload,
|
||||||
no: number;
|
BattleSimLogBuckets,
|
||||||
name: string;
|
BattleSimNationPayload,
|
||||||
nation: number;
|
BattleSimRequestPayload,
|
||||||
/** Current city. Older clients omit this; the surrounding city payload is authoritative then. */
|
BattleSimResultPayload,
|
||||||
city?: number;
|
} from '@sammo-ts/logic';
|
||||||
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 interface BattleSimJob {
|
export interface BattleSimJob {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ const resolveDexValue = (meta: Record<string, unknown>, key: string): number =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const battleRouter = router({
|
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 }) => {
|
simulate: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(async ({ ctx, input }) => {
|
||||||
const worldState = await ctx.db.worldState.findFirst();
|
const worldState = await ctx.db.worldState.findFirst();
|
||||||
if (!worldState) {
|
if (!worldState) {
|
||||||
@@ -176,122 +187,120 @@ export const battleRouter = router({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
getGeneralDetail: accessAuthedInputProcedure(
|
getGeneralDetail: accessAuthedInputProcedure(
|
||||||
z.object({
|
z.object({
|
||||||
generalId: z.number().int().positive(),
|
generalId: z.number().int().positive(),
|
||||||
})
|
})
|
||||||
)
|
).query(async ({ ctx, input }) => {
|
||||||
.query(async ({ ctx, input }) => {
|
const worldState = await ctx.db.worldState.findFirst();
|
||||||
const worldState = await ctx.db.worldState.findFirst();
|
if (!worldState) {
|
||||||
if (!worldState) {
|
throw new TRPCError({
|
||||||
throw new TRPCError({
|
code: 'PRECONDITION_FAILED',
|
||||||
code: 'PRECONDITION_FAILED',
|
message: 'World state is not initialized.',
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!general) {
|
const me = await getMyGeneral(ctx);
|
||||||
throw new TRPCError({
|
const general = await ctx.db.general.findUnique({
|
||||||
code: 'NOT_FOUND',
|
where: { id: input.generalId },
|
||||||
message: 'General not found.',
|
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);
|
if (!general) {
|
||||||
const defaultCrewTypeId =
|
throw new TRPCError({
|
||||||
environment.unitSet.defaultCrewTypeId ?? environment.unitSet.crewTypes?.[0]?.id ?? 0;
|
code: 'NOT_FOUND',
|
||||||
|
message: 'General not found.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const meta = asRecord(general.meta);
|
const environment = await buildBattleSimEnvironment(worldState, ctx.profile.id);
|
||||||
const isSameNation = me.nationId > 0 && me.nationId === general.nationId;
|
const defaultCrewTypeId = environment.unitSet.defaultCrewTypeId ?? environment.unitSet.crewTypes?.[0]?.id ?? 0;
|
||||||
|
|
||||||
const base = {
|
const meta = asRecord(general.meta);
|
||||||
no: general.id,
|
const isSameNation = me.nationId > 0 && me.nationId === general.nationId;
|
||||||
name: general.name,
|
|
||||||
officer_level: general.officerLevel,
|
const base = {
|
||||||
explevel: resolveExpLevel(meta, general.experience),
|
no: general.id,
|
||||||
leadership: general.leadership,
|
name: general.name,
|
||||||
horse: normalizeOptionalKey(general.horseCode),
|
officer_level: general.officerLevel,
|
||||||
strength: general.strength,
|
explevel: resolveExpLevel(meta, general.experience),
|
||||||
weapon: normalizeOptionalKey(general.weaponCode),
|
leadership: general.leadership,
|
||||||
intel: general.intel,
|
horse: normalizeOptionalKey(general.horseCode),
|
||||||
book: normalizeOptionalKey(general.bookCode),
|
strength: general.strength,
|
||||||
item: normalizeOptionalKey(general.itemCode),
|
weapon: normalizeOptionalKey(general.weaponCode),
|
||||||
injury: general.injury,
|
intel: general.intel,
|
||||||
rice: general.rice,
|
book: normalizeOptionalKey(general.bookCode),
|
||||||
personal: normalizeOptionalKey(general.personalCode),
|
item: normalizeOptionalKey(general.itemCode),
|
||||||
special: normalizeOptionalKey(general.specialCode),
|
injury: general.injury,
|
||||||
special2: normalizeOptionalKey(general.special2Code),
|
rice: general.rice,
|
||||||
crew: general.crew,
|
personal: normalizeOptionalKey(general.personalCode),
|
||||||
crewtype: general.crewTypeId,
|
special: normalizeOptionalKey(general.specialCode),
|
||||||
atmos: general.atmos,
|
special2: normalizeOptionalKey(general.special2Code),
|
||||||
train: general.train,
|
crew: general.crew,
|
||||||
dex1: resolveDexValue(meta, 'dex1'),
|
crewtype: general.crewTypeId,
|
||||||
dex2: resolveDexValue(meta, 'dex2'),
|
atmos: general.atmos,
|
||||||
dex3: resolveDexValue(meta, 'dex3'),
|
train: general.train,
|
||||||
dex4: resolveDexValue(meta, 'dex4'),
|
dex1: resolveDexValue(meta, 'dex1'),
|
||||||
dex5: resolveDexValue(meta, 'dex5'),
|
dex2: resolveDexValue(meta, 'dex2'),
|
||||||
defence_train: readNumber(meta.defenceTrain, 80),
|
dex3: resolveDexValue(meta, 'dex3'),
|
||||||
warnum: readNumber(meta.rank_warnum, 0),
|
dex4: resolveDexValue(meta, 'dex4'),
|
||||||
killnum: readNumber(meta.rank_killnum, 0),
|
dex5: resolveDexValue(meta, 'dex5'),
|
||||||
killcrew: readNumber(meta.rank_killcrew, 0),
|
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 };
|
||||||
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 };
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export const generalAccessEndpointWeights = {
|
|||||||
'npc.setNationPolicy': 0,
|
'npc.setNationPolicy': 0,
|
||||||
'npc.setNationPriority': 0,
|
'npc.setNationPriority': 0,
|
||||||
'npc.setGeneralPriority': 0,
|
'npc.setGeneralPriority': 0,
|
||||||
|
'battle.prepareSimulation': 0,
|
||||||
'battle.simulate': 0,
|
'battle.simulate': 0,
|
||||||
} as const satisfies Record<string, 0 | 1 | 2>;
|
} as const satisfies Record<string, 0 | 1 | 2>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
import type { BattleSimJobPayload } from '../src/battleSim/types.js';
|
import type { BattleSimJobPayload } from '../src/battleSim/types.js';
|
||||||
import { processBattleSimJob } from '../src/battleSim/processor.js';
|
import { processBattleSimJob } from '../src/battleSim/processor.js';
|
||||||
@@ -259,6 +260,26 @@ describe('battle sim processor', () => {
|
|||||||
expect(processBattleSimJob(legacyPayload)).toEqual(processBattleSimJob(baselinePayload));
|
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', () => {
|
it('returns the fixed defender ID order for reorder action', () => {
|
||||||
const payload = buildPayload('reorder');
|
const payload = buildPayload('reorder');
|
||||||
const result = processBattleSimJob(payload);
|
const result = processBattleSimJob(payload);
|
||||||
|
|||||||
@@ -257,6 +257,58 @@ const buildContext = (options: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('battle router orchestration', () => {
|
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 () => {
|
it('returns queued then completed results via transport', async () => {
|
||||||
const battleSim = new QueuedBattleSimTransport();
|
const battleSim = new QueuedBattleSimTransport();
|
||||||
const state: WorldStateRow = {
|
const state: WorldStateRow = {
|
||||||
@@ -351,6 +403,9 @@ describe('battle router orchestration', () => {
|
|||||||
} as unknown as DatabaseClient;
|
} as unknown as DatabaseClient;
|
||||||
|
|
||||||
const anonymous = appRouter.createCaller(buildContext({ state, battleSim, userId: null, db }));
|
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({
|
await expect(anonymous.battle.simulate(buildBattleRequest())).rejects.toMatchObject({
|
||||||
code: 'UNAUTHORIZED',
|
code: 'UNAUTHORIZED',
|
||||||
});
|
});
|
||||||
@@ -358,6 +413,10 @@ describe('battle router orchestration', () => {
|
|||||||
const noGeneralUser = appRouter.createCaller(
|
const noGeneralUser = appRouter.createCaller(
|
||||||
buildContext({ state, battleSim, userId: 'user-without-general', db })
|
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({
|
await expect(noGeneralUser.battle.simulate(buildBattleRequest())).resolves.toMatchObject({
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ const classifications = {
|
|||||||
],
|
],
|
||||||
operational: ['turnDaemon.pause', 'turnDaemon.resume', 'turnDaemon.run'],
|
operational: ['turnDaemon.pause', 'turnDaemon.resume', 'turnDaemon.run'],
|
||||||
externalUpload: ['board.uploadImage'],
|
externalUpload: ['board.uploadImage'],
|
||||||
readOnlyMutationTransport: ['battle.simulate'],
|
readOnlyMutationTransport: ['battle.prepareSimulation', 'battle.simulate'],
|
||||||
sessionOnly: ['auth.exchangeGatewayToken'],
|
sessionOnly: ['auth.exchangeGatewayToken'],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ describe('game-api direct mutation journal inventory', () => {
|
|||||||
const classified = Object.values(classifications).flat().sort();
|
const classified = Object.values(classifications).flat().sort();
|
||||||
|
|
||||||
expect(new Set(classified).size).toBe(classified.length);
|
expect(new Set(classified).size).toBe(classified.length);
|
||||||
expect(classified).toHaveLength(86);
|
expect(classified).toHaveLength(87);
|
||||||
expect(actual).toEqual(classified);
|
expect(actual).toEqual(classified);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ describe('general access tracking', () => {
|
|||||||
'npc.setNationPolicy': 0,
|
'npc.setNationPolicy': 0,
|
||||||
'npc.setNationPriority': 0,
|
'npc.setNationPriority': 0,
|
||||||
'npc.setGeneralPriority': 0,
|
'npc.setGeneralPriority': 0,
|
||||||
|
'battle.prepareSimulation': 0,
|
||||||
'battle.simulate': 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 { readFile } from 'node:fs/promises';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import {
|
||||||
|
processBattleSimJob,
|
||||||
|
type BattleSimJobPayload,
|
||||||
|
type BattleSimRequestPayload,
|
||||||
|
type BattleSimResultPayload,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||||
|
|
||||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
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 = {
|
const generalMe = {
|
||||||
general: {
|
general: {
|
||||||
id: 7,
|
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 = {
|
type Fixture = {
|
||||||
hasGeneral: boolean;
|
hasGeneral: boolean;
|
||||||
failNextSimulation?: boolean;
|
failNextSimulation?: boolean;
|
||||||
queueFirst?: boolean;
|
|
||||||
pollingCount: number;
|
|
||||||
requests: string[];
|
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) => {
|
const installImages = async (page: Page) => {
|
||||||
@@ -182,6 +234,22 @@ const installImages = async (page: Page) => {
|
|||||||
|
|
||||||
const installApi = async (page: Page, fixture: Fixture) => {
|
const installApi = async (page: Page, fixture: Fixture) => {
|
||||||
await installImages(page);
|
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) => {
|
await page.addInitScript((profile) => {
|
||||||
window.localStorage.setItem('sammo-game-token', 'ga_battle_sim_playwright');
|
window.localStorage.setItem('sammo-game-token', 'ga_battle_sim_playwright');
|
||||||
window.localStorage.setItem('sammo-game-profile', profile);
|
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.getGeneralDetail') return response(importedGeneral);
|
||||||
if (operation === 'battle.simulate') {
|
if (operation === 'battle.prepareSimulation') {
|
||||||
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 (fixture.failNextSimulation) {
|
if (fixture.failNextSimulation) {
|
||||||
fixture.failNextSimulation = false;
|
fixture.failNextSimulation = false;
|
||||||
return errorResponse(operation, '시뮬레이터 입력 오류');
|
return errorResponse(operation, '시뮬레이터 입력 오류');
|
||||||
}
|
}
|
||||||
if (fixture.queueFirst) {
|
const request = readOperationInput(
|
||||||
return response({ status: 'queued', jobId: 'job-playwright' });
|
requestBody,
|
||||||
}
|
operations.length,
|
||||||
return response({ status: 'completed', jobId: 'job-playwright', payload: simulationResult });
|
operationIndex
|
||||||
}
|
) as BattleSimRequestPayload;
|
||||||
if (operation === 'battle.getSimulation') {
|
const prepared: BattleSimJobPayload = {
|
||||||
fixture.pollingCount += 1;
|
...request,
|
||||||
if (fixture.pollingCount === 1) {
|
seeds: request.seed
|
||||||
return response({ status: 'queued', jobId: 'job-playwright' });
|
? []
|
||||||
}
|
: Array.from({ length: request.repeatCnt }, (_, index) => `playwright-repeat-${index}`),
|
||||||
return response({
|
unitSet: engineUnitSet,
|
||||||
status: 'completed',
|
config: engineConfig,
|
||||||
jobId: 'job-playwright',
|
time: { year: request.year, month: request.month, startYear: 190 },
|
||||||
payload: simulationResult,
|
scenarioEffect: null,
|
||||||
});
|
};
|
||||||
|
fixture.preparedPayloads.push(prepared);
|
||||||
|
fixture.serverResults.push(processBattleSimJob(structuredClone(prepared)));
|
||||||
|
return response(prepared);
|
||||||
}
|
}
|
||||||
return errorResponse(operation, `Unhandled battle simulator fixture operation: ${operation}`);
|
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) => {
|
const gotoSimulator = async (page: Page) => {
|
||||||
await page.goto('battle-simulator');
|
await page.goto('battle-simulator');
|
||||||
await expect(page.getByText('전역 설정')).toBeVisible();
|
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 }) => {
|
test('operates independent/game presets, imports my general, and renders battle logs', async ({ page }) => {
|
||||||
const fixture: Fixture = {
|
const fixture: Fixture = {
|
||||||
hasGeneral: true,
|
hasGeneral: true,
|
||||||
queueFirst: true,
|
|
||||||
pollingCount: 0,
|
|
||||||
requests: [],
|
requests: [],
|
||||||
simulationPayloads: [],
|
preparedPayloads: [],
|
||||||
|
serverResults: [],
|
||||||
};
|
};
|
||||||
await installApi(page, fixture);
|
await installApi(page, fixture);
|
||||||
await page.setViewportSize({ width: 1280, height: 900 });
|
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 page.getByLabel('시드').fill('playwright-fixed-seed');
|
||||||
await battleButton.click();
|
await battleButton.click();
|
||||||
|
|
||||||
await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible();
|
await expect(page.getByText('전투를 진행 중입니다.')).toHaveCount(0);
|
||||||
await expect(page.getByText('5', { exact: true })).toBeVisible();
|
expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]);
|
||||||
expect(fixture.pollingCount).toBe(2);
|
for (const [parityId, html] of [
|
||||||
expect(fixture.requests).toContain('battle.getSimulation');
|
['battle-log', fixture.serverResults[0]?.lastWarLog?.generalBattleResultLog ?? ''],
|
||||||
expect(fixture.simulationPayloads[0]).toMatchObject({
|
['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_신산' },
|
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 page.locator('.header-actions input[type="file"]').setInputFiles(downloadPath!);
|
||||||
|
|
||||||
await battleButton.click();
|
await battleButton.click();
|
||||||
await expect.poll(() => fixture.simulationPayloads.length).toBe(2);
|
await expect.poll(() => fixture.preparedPayloads.length).toBe(2);
|
||||||
expect(fixture.simulationPayloads[1]).toMatchObject({
|
expect(await readBrowserWorkerResult(page, 1)).toEqual(fixture.serverResults[1]);
|
||||||
|
expect(fixture.preparedPayloads[1]).toMatchObject({
|
||||||
attackerGeneral: { special: 'che_event_신산' },
|
attackerGeneral: { special: 'che_event_신산' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -336,9 +428,9 @@ test('keeps simulation available without a game general and preserves input afte
|
|||||||
const fixture: Fixture = {
|
const fixture: Fixture = {
|
||||||
hasGeneral: false,
|
hasGeneral: false,
|
||||||
failNextSimulation: true,
|
failNextSimulation: true,
|
||||||
pollingCount: 0,
|
|
||||||
requests: [],
|
requests: [],
|
||||||
simulationPayloads: [],
|
preparedPayloads: [],
|
||||||
|
serverResults: [],
|
||||||
};
|
};
|
||||||
await installApi(page, fixture);
|
await installApi(page, fixture);
|
||||||
await page.setViewportSize({ width: 500, height: 900 });
|
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 expect(page.getByLabel('시드')).toHaveValue('keep-this-seed');
|
||||||
|
|
||||||
await page.getByRole('button', { name: '전투', exact: true }).click();
|
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);
|
await expect(page.getByText('시뮬레이터 입력 오류')).toHaveCount(0);
|
||||||
|
expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]);
|
||||||
|
|
||||||
const notice = page.getByLabel('시뮬레이터 데이터 안내');
|
const notice = page.getByLabel('시뮬레이터 데이터 안내');
|
||||||
expect(await notice.evaluate((element) => getComputedStyle(element).position)).toBe('absolute');
|
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">
|
<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 type { BattleSimRequestPayload, BattleSimResultPayload } from '@sammo-ts/game-api';
|
||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
import PanelCard from '../components/ui/PanelCard.vue';
|
||||||
import SkeletonLines from '../components/ui/SkeletonLines.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 { trpc } from '../utils/trpc';
|
||||||
import { getNpcColor } from '../utils/npcColor';
|
import { getNpcColor } from '../utils/npcColor';
|
||||||
import type { BattleSimOptions, GeneralDraft, InheritBuff } from '../utils/battleSimulatorTypes';
|
import type { BattleSimOptions, GeneralDraft, InheritBuff } from '../utils/battleSimulatorTypes';
|
||||||
|
import { BattleSimulatorWorkerClient } from '../utils/battleSimulatorWorkerClient';
|
||||||
|
|
||||||
type GeneralExport = Omit<GeneralDraft, 'id'>;
|
type GeneralExport = Omit<GeneralDraft, 'id'>;
|
||||||
|
|
||||||
@@ -67,6 +68,11 @@ const defenders = ref<GeneralDraft[]>([]);
|
|||||||
|
|
||||||
const isSimulating = ref(false);
|
const isSimulating = ref(false);
|
||||||
const statusMessage = ref<string | null>(null);
|
const statusMessage = ref<string | null>(null);
|
||||||
|
const simulationWorker = new BattleSimulatorWorkerClient();
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
simulationWorker.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
const importOpen = ref(false);
|
const importOpen = ref(false);
|
||||||
const importTarget = ref<GeneralDraft | null>(null);
|
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']) => {
|
const runSimulation = async (action: BattleSimRequestPayload['action']) => {
|
||||||
if (!options.value) {
|
if (!options.value) {
|
||||||
return;
|
return;
|
||||||
@@ -595,11 +587,8 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = buildBattlePayload(action);
|
const payload = buildBattlePayload(action);
|
||||||
const response = await trpc.battle.simulate.mutate(payload);
|
const preparedPayload = await trpc.battle.prepareSimulation.mutate(payload);
|
||||||
const result =
|
const result = await simulationWorker.run(preparedPayload);
|
||||||
'payload' in response && response.payload
|
|
||||||
? response.payload
|
|
||||||
: await waitForSimulationResult(response.jobId);
|
|
||||||
|
|
||||||
if (!result.result) {
|
if (!result.result) {
|
||||||
error.value = result.reason || 'battle_failed';
|
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: {
|
build: {
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
},
|
},
|
||||||
|
worker: {
|
||||||
|
format: 'es',
|
||||||
|
rolldownOptions: {
|
||||||
|
output: {
|
||||||
|
codeSplitting: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(import.meta.dirname, './src'),
|
'@': path.resolve(import.meta.dirname, './src'),
|
||||||
|
|||||||
@@ -61,9 +61,11 @@ scenario parser, resource schema, PostgreSQL world loader와 battle simulator
|
|||||||
않습니다.
|
않습니다.
|
||||||
|
|
||||||
전투 시뮬레이터의 효과는 공개 request가 아니라 저장된 world config에서
|
전투 시뮬레이터의 효과는 공개 request가 아니라 저장된 world config에서
|
||||||
서버가 파생합니다. 내부 queue payload의 필드는 optional이므로 배포 전에
|
서버가 파생합니다. `battle.prepareSimulation`은 해당 효과와 전체 병종 정의,
|
||||||
생성된 payload는 효과 없음으로 처리하며, 신·구 API/worker 혼재 시에는
|
전투 상수, 시나리오 시작 연도, 반복별 seed를 권위 payload로 만들고 브라우저
|
||||||
효과 누락을 피하기 위해 queue를 비우거나 API와 worker를 함께 재시작합니다.
|
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` |
|
| Redis projection | 6 | `tournament.patchState`, `tournament.seedParticipants`, `tournament.setBettingEntries`, `tournament.setMatches`, `tournament.setParticipants`, `tournament.setState` |
|
||||||
| operational | 3 | `turnDaemon.pause`, `turnDaemon.resume`, `turnDaemon.run` |
|
| operational | 3 | `turnDaemon.pause`, `turnDaemon.resume`, `turnDaemon.run` |
|
||||||
| external upload | 1 | `board.uploadImage` |
|
| 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` |
|
| session only | 1 | `auth.exchangeGatewayToken` |
|
||||||
|
|
||||||
합계는 86개다.
|
합계는 87개다.
|
||||||
|
|
||||||
## durable journal dependency 매핑
|
## durable journal dependency 매핑
|
||||||
|
|
||||||
@@ -75,7 +75,8 @@ public dashboard event로 내보내지 않는다. browser wake-up은 정밀 enti
|
|||||||
- nation reserved turn, selection-pool reservation, possession 후보, inheritance owner
|
- nation reserved turn, selection-pool reservation, possession 후보, inheritance owner
|
||||||
확인은 각각 전용 화면/request response가 최신 상태를 소유한다.
|
확인은 각각 전용 화면/request response가 최신 상태를 소유한다.
|
||||||
- image upload는 외부 content store write이며 game PostgreSQL read model이 아니다.
|
- 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 판정
|
## 남은 업무 원자성 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 |
|
| 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 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 |
|
| 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 |
|
| 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 | `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 |
|
| 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 './actionModules/index.js';
|
||||||
export * from './auction/alias.js';
|
export * from './auction/alias.js';
|
||||||
export * from './auction/neutral.js';
|
export * from './auction/neutral.js';
|
||||||
|
export * from './battleSimulator/index.js';
|
||||||
export * from './constraints/index.js';
|
export * from './constraints/index.js';
|
||||||
export * from './crewType/index.js';
|
export * from './crewType/index.js';
|
||||||
export * from './diplomacy/index.js';
|
export * from './diplomacy/index.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user