merge: 브라우저 전투 시뮬레이터 이관

This commit is contained in:
2026-08-17 01:49:34 +00:00
23 changed files with 1258 additions and 854 deletions
+6 -2
View File
@@ -1,10 +1,13 @@
import type { WorldStateRow } from '../context.js';
import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.js';
import { randomUUID } from 'node:crypto';
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
import { normalizeScenarioEffect, type ScenarioEffectKey, type WarEngineConfig } from '@sammo-ts/logic';
import { asRecord } from '@sammo-ts/common';
import type { UnitSetDefinition } from '@sammo-ts/logic';
import type { WorldStateRow } from '../context.js';
import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.js';
const DEFAULT_WAR_CONFIG = {
armPerPhase: 500,
maxTrainByCommand: 100,
@@ -133,6 +136,7 @@ export const buildBattleSimJobPayload = async (
return {
...request,
seeds: request.seed ? [] : Array.from({ length: request.repeatCnt }, () => randomUUID()),
unitSet: environment.unitSet,
config: environment.config,
time: {
+2 -507
View File
@@ -1,507 +1,2 @@
import crypto from 'node:crypto';
import type { RandUtil } from '@sammo-ts/common';
import {
formatLogText,
getTechCost,
LogCategory,
LogFormat,
LogScope,
resolveDefenderOrder,
resolveWarBattle,
createItemActionModules,
createItemModuleRegistry,
createRefOrderedActionStack,
createScenarioEffectActionModules,
ITEM_KEYS,
loadItemModules,
createInheritBuffModules,
createTraitCatalog,
createOfficerLevelActionModules,
DOMESTIC_TRAIT_KEYS,
EVENT_DOMESTIC_TRAIT_KEYS,
loadDomesticTraitModules,
loadEventDomesticTraitModules,
loadNationTraitModules,
loadPersonalityTraitModules,
loadWarTraitModules,
NATION_TRAIT_KEYS,
PERSONALITY_TRAIT_KEYS,
TraitWarActionRouter,
WAR_TRAIT_KEYS,
compileCrewTypeCatalog,
createCrewTypeWarTriggerRegistry,
type City,
type General,
type Nation,
type RefOrderedActionStack,
type UnitSetDefinition,
type WarBattleOutcome,
type WarActionModule,
type WarUnitReport,
type WarBattleTraceEvent,
type CrewTypeDefinition,
} from '@sammo-ts/logic';
import { type BattleSimJobPayload, type BattleSimLogBuckets, type BattleSimResultPayload } from './types.js';
import { convertLog } from './logFormatter.js';
const DEFAULT_GENERAL_AGE = 20;
const inheritBuffModules = createInheritBuffModules();
const itemWarModules: WarActionModule[] = createItemActionModules(
createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]))
).war;
const crewTypeWarTriggerRegistry = createCrewTypeWarTriggerRegistry();
const traitCatalog = createTraitCatalog({
domestic: [
...(await loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS])),
...(await loadEventDomesticTraitModules([...EVENT_DOMESTIC_TRAIT_KEYS])),
],
war: await loadWarTraitModules([...WAR_TRAIT_KEYS]),
personality: await loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]),
nation: await loadNationTraitModules([...NATION_TRAIT_KEYS]),
});
const nationWarModule = new TraitWarActionRouter('nation', traitCatalog);
const officerWarModule = createOfficerLevelActionModules().war;
const domesticWarModule = new TraitWarActionRouter('domestic', traitCatalog);
const warTraitModule = new TraitWarActionRouter('war', traitCatalog);
const personalityWarModule = new TraitWarActionRouter('personality', traitCatalog);
const buildWarActionModules = (
unitSet: UnitSetDefinition,
scenarioEffect?: string | null
): RefOrderedActionStack<WarActionModule> => {
const crewTypeCatalog = compileCrewTypeCatalog(unitSet, crewTypeWarTriggerRegistry);
const scenario = createScenarioEffectActionModules(scenarioEffect);
return createRefOrderedActionStack<WarActionModule>({
nation: nationWarModule,
officer: officerWarModule,
domestic: domesticWarModule,
war: warTraitModule,
personality: personalityWarModule,
crewType: crewTypeCatalog.warActionModule,
inheritance: inheritBuffModules.war,
scenario: scenario.war,
items: itemWarModules,
});
};
const normalizeItemCode = (value: string | null): string | null => (value === 'None' ? null : value);
const mapNationPayload = (payload: BattleSimJobPayload['attackerNation']): Nation => ({
id: payload.nation,
name: payload.name,
color: '#000000',
capitalCityId: payload.capital,
chiefGeneralId: null,
gold: payload.gold,
rice: payload.rice,
power: 0,
level: payload.level,
typeCode: payload.type,
meta: {
tech: payload.tech,
gennum: payload.gennum,
},
});
const mapCityPayload = (payload: BattleSimJobPayload['attackerCity']): City => ({
id: payload.city,
name: payload.name,
nationId: payload.nation,
level: payload.level,
state: payload.state,
population: payload.pop,
populationMax: payload.pop_max,
agriculture: payload.agri,
agricultureMax: payload.agri_max,
commerce: payload.comm,
commerceMax: payload.comm_max,
security: payload.secu,
securityMax: payload.secu_max,
supplyState: payload.supply,
frontState: payload.state,
defence: payload.def,
defenceMax: payload.def_max,
wall: payload.wall,
wallMax: payload.wall_max,
meta: {
trust: payload.trust,
dead: payload.dead,
conflict: payload.conflict,
supply: payload.supply,
},
});
const mapGeneralPayload = (
payload: BattleSimJobPayload['attackerGeneral'],
currentCityId: number
): General => ({
id: payload.no,
name: payload.name,
nationId: payload.nation,
cityId: payload.city ?? currentCityId,
troopId: 0,
stats: {
leadership: payload.leadership,
strength: payload.strength,
intelligence: payload.intel,
},
experience: payload.experience,
dedication: payload.dedication,
officerLevel: payload.officer_level,
role: {
personality: payload.personal,
specialDomestic: payload.special ?? null,
specialWar: payload.special2,
items: {
horse: normalizeItemCode(payload.horse),
weapon: normalizeItemCode(payload.weapon),
book: normalizeItemCode(payload.book),
item: normalizeItemCode(payload.item),
},
},
injury: payload.injury,
gold: payload.gold,
rice: payload.rice,
crew: payload.crew,
crewTypeId: payload.crewtype,
train: payload.train,
atmos: payload.atmos,
age: DEFAULT_GENERAL_AGE,
npcState: 0,
triggerState: {
flags: {},
counters: {},
modifiers: {},
meta: payload.inheritBuff ? { inheritBuff: JSON.stringify(payload.inheritBuff) } : {},
},
meta: {
killturn: 24,
explevel: payload.explevel,
turnTime: payload.turntime,
recentWar: payload.recent_war ?? '',
dex1: payload.dex1,
dex2: payload.dex2,
dex3: payload.dex3,
dex4: payload.dex4,
dex5: payload.dex5,
intel_exp: payload.intel_exp,
strength_exp: payload.strength_exp,
leadership_exp: payload.leadership_exp,
defence_train: payload.defence_train,
officerCity: payload.officer_city,
officer_city: payload.officer_city,
rank_warnum: payload.warnum,
rank_killnum: payload.killnum,
rank_killcrew: payload.killcrew,
},
});
const buildLogBuckets = (options: {
logs: WarBattleOutcome['logs'];
year: number;
month: number;
attackerId: number;
attackerNationId: number;
}): BattleSimLogBuckets => {
const buckets = {
generalHistoryLog: [] as string[],
generalActionLog: [] as string[],
generalBattleResultLog: [] as string[],
generalBattleDetailLog: [] as string[],
nationalHistoryLog: [] as string[],
globalHistoryLog: [] as string[],
globalActionLog: [] as string[],
};
for (const entry of options.logs) {
const format = entry.format ?? LogFormat.RAWTEXT;
const text = formatLogText(entry.text, format, options.year, options.month);
if (entry.scope === LogScope.GENERAL && entry.generalId === options.attackerId) {
switch (entry.category) {
case LogCategory.HISTORY:
buckets.generalHistoryLog.push(text);
break;
case LogCategory.ACTION:
buckets.generalActionLog.push(text);
break;
case LogCategory.BATTLE_BRIEF:
buckets.generalBattleResultLog.push(text);
break;
case LogCategory.BATTLE_DETAIL:
buckets.generalBattleDetailLog.push(text);
break;
default:
break;
}
continue;
}
if (
entry.scope === LogScope.NATION &&
entry.nationId === options.attackerNationId &&
entry.category === LogCategory.HISTORY
) {
buckets.nationalHistoryLog.push(text);
continue;
}
if (entry.scope === LogScope.SYSTEM) {
if (entry.category === LogCategory.HISTORY) {
buckets.globalHistoryLog.push(text);
} else if (entry.category === LogCategory.SUMMARY) {
buckets.globalActionLog.push(text);
}
}
}
return {
generalHistoryLog: convertLog(buckets.generalHistoryLog.join('<br>')),
generalActionLog: convertLog(buckets.generalActionLog.join('<br>')),
generalBattleResultLog: convertLog(buckets.generalBattleResultLog.join('<br>')),
generalBattleDetailLog: convertLog(buckets.generalBattleDetailLog.join('<br>')),
nationalHistoryLog: convertLog(buckets.nationalHistoryLog.join('<br>')),
globalHistoryLog: convertLog(buckets.globalHistoryLog.join('<br>')),
globalActionLog: convertLog(buckets.globalActionLog.join('<br>')),
};
};
const resolveRandomSeed = (): string => crypto.randomUUID();
const resolveCityTrainAtmos = (year: number, startYear: number): number =>
Math.min(110, Math.max(60, year - startYear + 59));
const resolveCityRiceConsumption = (options: {
battle: WarBattleOutcome;
defenderNation: Nation;
unitSet: UnitSetDefinition;
castleCrewTypeId: number;
year: number;
startYear: number;
}): number => {
const cityReport = options.battle.reports.find((report: WarUnitReport) => report.type === 'city');
if (!cityReport) {
return 0;
}
if (cityReport.killed <= 0 && cityReport.dead <= 0) {
return 0;
}
const crewType = options.unitSet.crewTypes?.find(
(item: CrewTypeDefinition) => item.id === options.castleCrewTypeId
);
const riceCoef = crewType?.rice ?? 1;
const tech = Number(options.defenderNation.meta.tech ?? 0);
const trainAtmos = resolveCityTrainAtmos(options.year, options.startYear);
let rice = (cityReport.killed / 100) * 0.8;
rice *= riceCoef;
rice *= getTechCost(tech);
rice *= trainAtmos / 100 - 0.2;
return Math.round(rice);
};
const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] => {
const attackerNation = mapNationPayload(payload.attackerNation);
const defenderNation = mapNationPayload(payload.defenderNation);
const attackerCity = mapCityPayload(payload.attackerCity);
const defenderCity = mapCityPayload(payload.defenderCity);
const attacker = mapGeneralPayload(payload.attackerGeneral, attackerCity.id);
const defenders = payload.defenderGenerals.map((general) => mapGeneralPayload(general, defenderCity.id));
const warActionModules = buildWarActionModules(payload.unitSet, payload.scenarioEffect);
return resolveDefenderOrder({
unitSet: payload.unitSet,
config: payload.config,
time: payload.time,
seed: 'order',
attacker: {
general: attacker,
city: attackerCity,
nation: attackerNation,
modules: warActionModules,
},
defenders: defenders.map((general) => ({
general,
city: defenderCity,
nation: defenderNation,
modules: warActionModules,
})),
defenderCity,
defenderNation,
});
};
export interface BattleSimProcessorOptions {
trace?: (event: WarBattleTraceEvent) => void;
rngFactory?: (seed: string) => RandUtil;
}
export const processBattleSimJob = (
payload: BattleSimJobPayload,
options: BattleSimProcessorOptions = {}
): BattleSimResultPayload => {
if (payload.action === 'reorder') {
return {
result: true,
reason: 'success',
order: resolveDefenderOrderPayload(payload),
};
}
let repeatCnt = payload.repeatCnt;
const warActionModules = buildWarActionModules(payload.unitSet, payload.scenarioEffect);
const baseSeed = payload.seed ?? '';
if (baseSeed) {
repeatCnt = 1;
}
let lastBattle: WarBattleOutcome | null = null;
let attackerKilled = 0;
let attackerDead = 0;
let attackerMaxKilled = 0;
let attackerMinKilled = Number.POSITIVE_INFINITY;
let attackerMaxDead = 0;
let attackerMinDead = Number.POSITIVE_INFINITY;
let attackerAvgRice = 0;
let defenderAvgRice = 0;
let avgPhase = 0;
let avgWar = 0;
const attackerSkills: Record<string, number> = {};
const defendersSkills: Array<Record<string, number>> = [];
const weight = 1 / Math.max(1, repeatCnt);
for (let idx = 0; idx < repeatCnt; idx += 1) {
const seed = idx === 0 ? baseSeed || resolveRandomSeed() : resolveRandomSeed();
const attackerNation = mapNationPayload(payload.attackerNation);
const defenderNation = mapNationPayload(payload.defenderNation);
const attackerCity = mapCityPayload(payload.attackerCity);
const defenderCity = mapCityPayload(payload.defenderCity);
const attackerGeneral = mapGeneralPayload(payload.attackerGeneral, attackerCity.id);
const defenderGenerals = payload.defenderGenerals.map((general) =>
mapGeneralPayload(general, defenderCity.id)
);
const initialRice = new Map<number, number>();
initialRice.set(attackerGeneral.id, attackerGeneral.rice);
for (const defender of defenderGenerals) {
initialRice.set(defender.id, defender.rice);
}
const outcome = resolveWarBattle({
seed,
rng: options.rngFactory?.(seed),
unitSet: payload.unitSet,
config: payload.config,
time: payload.time,
attacker: {
general: attackerGeneral,
city: attackerCity,
nation: attackerNation,
modules: warActionModules,
},
defenders: defenderGenerals.map((general) => ({
general,
city: defenderCity,
nation: defenderNation,
modules: warActionModules,
})),
defenderCity,
defenderNation,
trace: options.trace,
});
lastBattle = outcome;
const attackerReport = outcome.reports.find(
(report: WarUnitReport) => report.type === 'general' && report.isAttacker
);
const killed = attackerReport?.killed ?? 0;
const dead = attackerReport?.dead ?? 0;
attackerKilled += killed * weight;
attackerDead += dead * weight;
attackerMaxKilled = Math.max(attackerMaxKilled, killed);
attackerMinKilled = Math.min(attackerMinKilled, killed);
attackerMaxDead = Math.max(attackerMaxDead, dead);
attackerMinDead = Math.min(attackerMinDead, dead);
const phase = outcome.metrics?.attackerPhase ?? 0;
avgPhase += phase * weight;
const defenderCount = outcome.metrics?.defenderActivatedSkills.length ?? 0;
avgWar += defenderCount * weight;
const attackerRiceInit = initialRice.get(attackerGeneral.id) ?? attackerGeneral.rice;
attackerAvgRice += (attackerRiceInit - outcome.attacker.rice) * weight;
let defenderRiceInit = 0;
let defenderRiceAfter = 0;
for (const defender of outcome.defenders) {
defenderRiceInit += initialRice.get(defender.id) ?? defender.rice;
defenderRiceAfter += defender.rice;
}
const cityRice = resolveCityRiceConsumption({
battle: outcome,
defenderNation,
unitSet: payload.unitSet,
castleCrewTypeId: payload.config.castleCrewTypeId,
year: payload.time.year,
startYear: payload.time.startYear,
});
defenderAvgRice += (defenderRiceInit - defenderRiceAfter + cityRice) * weight;
const attackerActivated = outcome.metrics?.attackerActivatedSkills ?? {};
for (const [skillName, value] of Object.entries(attackerActivated) as [string, number][]) {
attackerSkills[skillName] = (attackerSkills[skillName] ?? 0) + value * weight;
}
const defenderActivated = outcome.metrics?.defenderActivatedSkills ?? [];
for (let defIdx = 0; defIdx < defenderActivated.length; defIdx += 1) {
while (defIdx >= defendersSkills.length) {
defendersSkills.push({});
}
const bucket = defendersSkills[defIdx]!;
for (const [skillName, value] of Object.entries(defenderActivated[defIdx]!) as [string, number][]) {
bucket[skillName] = (bucket[skillName] ?? 0) + value * weight;
}
}
}
if (!lastBattle) {
return {
result: false,
reason: '전투 결과를 생성하지 못했습니다.',
};
}
const logBuckets = buildLogBuckets({
logs: lastBattle.logs,
year: payload.time.year,
month: payload.time.month,
attackerId: lastBattle.attacker.id,
attackerNationId: payload.attackerNation.nation,
});
return {
result: true,
reason: 'success',
datetime: payload.attackerGeneral.turntime,
lastWarLog: logBuckets,
avgWar,
phase: avgPhase,
killed: attackerKilled,
maxKilled: attackerMaxKilled,
minKilled: attackerMinKilled === Number.POSITIVE_INFINITY ? 0 : attackerMinKilled,
dead: attackerDead,
maxDead: attackerMaxDead,
minDead: attackerMinDead === Number.POSITIVE_INFINITY ? 0 : attackerMinDead,
attackerRice: attackerAvgRice,
defenderRice: defenderAvgRice,
attackerSkills,
defendersSkills,
};
};
export { processBattleSimJob } from '@sammo-ts/logic';
export type { BattleSimProcessorOptions } from '@sammo-ts/logic';
+11 -136
View File
@@ -1,140 +1,15 @@
import type { UnitSetDefinition } from '@sammo-ts/logic';
import type { BattleSimJobPayload, BattleSimResultPayload } from '@sammo-ts/logic';
import type { WarEngineConfig, WarTimeContext } from '@sammo-ts/logic';
export type BattleSimAction = 'reorder' | 'battle';
export interface BattleSimGeneralPayload {
no: number;
name: string;
nation: number;
/** Current city. Older clients omit this; the surrounding city payload is authoritative then. */
city?: number;
turntime: string;
personal: string | null;
special?: string | null;
special2: string | null;
crew: number;
crewtype: number;
atmos: number;
train: number;
intel: number;
intel_exp: number;
book: string | null;
strength: number;
strength_exp: number;
weapon: string | null;
injury: number;
leadership: number;
leadership_exp: number;
horse: string | null;
item: string | null;
explevel: number;
experience: number;
dedication: number;
officer_level: number;
officer_city: number;
gold: number;
rice: number;
dex1: number;
dex2: number;
dex3: number;
dex4: number;
dex5: number;
defence_train: number;
recent_war: string | null;
warnum: number;
killnum: number;
killcrew: number;
inheritBuff?: Record<string, number> | number[];
}
export interface BattleSimCityPayload {
city: number;
nation: number;
supply: number;
name: string;
pop: number;
agri: number;
comm: number;
secu: number;
def: number;
wall: number;
trust: number;
level: number;
pop_max: number;
agri_max: number;
comm_max: number;
secu_max: number;
def_max: number;
wall_max: number;
dead: number;
state: number;
conflict: string;
}
export interface BattleSimNationPayload {
type: string;
tech: number;
level: number;
capital: number;
nation: number;
name: string;
gold: number;
rice: number;
gennum: number;
}
export interface BattleSimRequestPayload {
action: BattleSimAction;
seed?: string;
repeatCnt: number;
year: number;
month: number;
attackerGeneral: BattleSimGeneralPayload;
attackerCity: BattleSimCityPayload;
attackerNation: BattleSimNationPayload;
defenderGenerals: BattleSimGeneralPayload[];
defenderCity: BattleSimCityPayload;
defenderNation: BattleSimNationPayload;
}
export interface BattleSimJobPayload extends BattleSimRequestPayload {
unitSet: UnitSetDefinition;
config: WarEngineConfig;
time: WarTimeContext;
scenarioEffect?: string | null;
}
export interface BattleSimLogBuckets {
generalHistoryLog: string;
generalActionLog: string;
generalBattleResultLog: string;
generalBattleDetailLog: string;
nationalHistoryLog: string;
globalHistoryLog: string;
globalActionLog: string;
}
export interface BattleSimResultPayload {
result: boolean;
reason: string;
datetime?: string;
lastWarLog?: BattleSimLogBuckets;
avgWar?: number;
phase?: number;
killed?: number;
maxKilled?: number;
minKilled?: number;
dead?: number;
maxDead?: number;
minDead?: number;
attackerRice?: number;
defenderRice?: number;
attackerSkills?: Record<string, number>;
defendersSkills?: Array<Record<string, number>>;
order?: number[];
}
export type {
BattleSimAction,
BattleSimCityPayload,
BattleSimGeneralPayload,
BattleSimJobPayload,
BattleSimLogBuckets,
BattleSimNationPayload,
BattleSimRequestPayload,
BattleSimResultPayload,
} from '@sammo-ts/logic';
export interface BattleSimJob {
jobId: string;
+120 -111
View File
@@ -62,6 +62,17 @@ const resolveDexValue = (meta: Record<string, unknown>, key: string): number =>
};
export const battleRouter = router({
prepareSimulation: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(async ({ ctx, input }) => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
return buildBattleSimJobPayload(worldState, input, ctx.profile.id);
}),
simulate: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(async ({ ctx, input }) => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
@@ -176,122 +187,120 @@ export const battleRouter = router({
};
}),
getGeneralDetail: accessAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
})
)
.query(async ({ ctx, input }) => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
const me = await getMyGeneral(ctx);
const general = await ctx.db.general.findUnique({
where: { id: input.generalId },
select: {
id: true,
name: true,
npcState: true,
nationId: true,
leadership: true,
strength: true,
intel: true,
officerLevel: true,
injury: true,
rice: true,
crew: true,
crewTypeId: true,
atmos: true,
train: true,
experience: true,
horseCode: true,
weaponCode: true,
bookCode: true,
itemCode: true,
personalCode: true,
specialCode: true,
special2Code: true,
meta: true,
},
z.object({
generalId: z.number().int().positive(),
})
).query(async ({ ctx, input }) => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
if (!general) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'General not found.',
});
}
const me = await getMyGeneral(ctx);
const general = await ctx.db.general.findUnique({
where: { id: input.generalId },
select: {
id: true,
name: true,
npcState: true,
nationId: true,
leadership: true,
strength: true,
intel: true,
officerLevel: true,
injury: true,
rice: true,
crew: true,
crewTypeId: true,
atmos: true,
train: true,
experience: true,
horseCode: true,
weaponCode: true,
bookCode: true,
itemCode: true,
personalCode: true,
specialCode: true,
special2Code: true,
meta: true,
},
});
const environment = await buildBattleSimEnvironment(worldState, ctx.profile.id);
const defaultCrewTypeId =
environment.unitSet.defaultCrewTypeId ?? environment.unitSet.crewTypes?.[0]?.id ?? 0;
if (!general) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'General not found.',
});
}
const meta = asRecord(general.meta);
const isSameNation = me.nationId > 0 && me.nationId === general.nationId;
const environment = await buildBattleSimEnvironment(worldState, ctx.profile.id);
const defaultCrewTypeId = environment.unitSet.defaultCrewTypeId ?? environment.unitSet.crewTypes?.[0]?.id ?? 0;
const base = {
no: general.id,
name: general.name,
officer_level: general.officerLevel,
explevel: resolveExpLevel(meta, general.experience),
leadership: general.leadership,
horse: normalizeOptionalKey(general.horseCode),
strength: general.strength,
weapon: normalizeOptionalKey(general.weaponCode),
intel: general.intel,
book: normalizeOptionalKey(general.bookCode),
item: normalizeOptionalKey(general.itemCode),
injury: general.injury,
rice: general.rice,
personal: normalizeOptionalKey(general.personalCode),
special: normalizeOptionalKey(general.specialCode),
special2: normalizeOptionalKey(general.special2Code),
crew: general.crew,
crewtype: general.crewTypeId,
atmos: general.atmos,
train: general.train,
dex1: resolveDexValue(meta, 'dex1'),
dex2: resolveDexValue(meta, 'dex2'),
dex3: resolveDexValue(meta, 'dex3'),
dex4: resolveDexValue(meta, 'dex4'),
dex5: resolveDexValue(meta, 'dex5'),
defence_train: readNumber(meta.defenceTrain, 80),
warnum: readNumber(meta.rank_warnum, 0),
killnum: readNumber(meta.rank_killnum, 0),
killcrew: readNumber(meta.rank_killcrew, 0),
const meta = asRecord(general.meta);
const isSameNation = me.nationId > 0 && me.nationId === general.nationId;
const base = {
no: general.id,
name: general.name,
officer_level: general.officerLevel,
explevel: resolveExpLevel(meta, general.experience),
leadership: general.leadership,
horse: normalizeOptionalKey(general.horseCode),
strength: general.strength,
weapon: normalizeOptionalKey(general.weaponCode),
intel: general.intel,
book: normalizeOptionalKey(general.bookCode),
item: normalizeOptionalKey(general.itemCode),
injury: general.injury,
rice: general.rice,
personal: normalizeOptionalKey(general.personalCode),
special: normalizeOptionalKey(general.specialCode),
special2: normalizeOptionalKey(general.special2Code),
crew: general.crew,
crewtype: general.crewTypeId,
atmos: general.atmos,
train: general.train,
dex1: resolveDexValue(meta, 'dex1'),
dex2: resolveDexValue(meta, 'dex2'),
dex3: resolveDexValue(meta, 'dex3'),
dex4: resolveDexValue(meta, 'dex4'),
dex5: resolveDexValue(meta, 'dex5'),
defence_train: readNumber(meta.defenceTrain, 80),
warnum: readNumber(meta.rank_warnum, 0),
killnum: readNumber(meta.rank_killnum, 0),
killcrew: readNumber(meta.rank_killcrew, 0),
};
if (!isSameNation) {
return {
general: {
...base,
officer_level: 1,
horse: null,
weapon: null,
book: null,
item: null,
crew: 0,
crewtype: defaultCrewTypeId,
rice: 10000,
train: environment.config.maxTrainByCommand,
atmos: environment.config.maxAtmosByCommand,
dex1: 0,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
defence_train: 80,
warnum: 0,
killnum: 0,
killcrew: 0,
},
};
}
if (!isSameNation) {
return {
general: {
...base,
officer_level: 1,
horse: null,
weapon: null,
book: null,
item: null,
crew: 0,
crewtype: defaultCrewTypeId,
rice: 10000,
train: environment.config.maxTrainByCommand,
atmos: environment.config.maxAtmosByCommand,
dex1: 0,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
defence_train: 80,
warnum: 0,
killnum: 0,
killcrew: 0,
},
};
}
return { general: base };
}),
return { general: base };
}),
});
@@ -60,6 +60,7 @@ export const generalAccessEndpointWeights = {
'npc.setNationPolicy': 0,
'npc.setNationPriority': 0,
'npc.setGeneralPriority': 0,
'battle.prepareSimulation': 0,
'battle.simulate': 0,
} as const satisfies Record<string, 0 | 1 | 2>;
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import type { BattleSimJobPayload } from '../src/battleSim/types.js';
import { processBattleSimJob } from '../src/battleSim/processor.js';
@@ -259,6 +260,26 @@ describe('battle sim processor', () => {
expect(processBattleSimJob(legacyPayload)).toEqual(processBattleSimJob(baselinePayload));
});
it('uses server-issued per-repeat seeds deterministically when no fixed seed is supplied', () => {
const firstPayload = buildPayload('battle');
delete firstPayload.seed;
firstPayload.repeatCnt = 2;
firstPayload.seeds = ['server-repeat-0', 'server-repeat-1'];
const secondPayload = structuredClone(firstPayload);
const observedSeeds: string[] = [];
const first = processBattleSimJob(firstPayload, {
rngFactory: (seed) => {
observedSeeds.push(seed);
return new RandUtil(LiteHashDRBG.build(seed));
},
});
const second = processBattleSimJob(secondPayload);
expect(first).toEqual(second);
expect(observedSeeds).toEqual(['server-repeat-0', 'server-repeat-1']);
});
it('returns the fixed defender ID order for reorder action', () => {
const payload = buildPayload('reorder');
const result = processBattleSimJob(payload);
+59
View File
@@ -257,6 +257,58 @@ const buildContext = (options: {
};
describe('battle router orchestration', () => {
it('prepares the authoritative browser-worker payload without queuing server work', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: { environment: { scenarioEffect: 'event_MoreEffect' } },
meta: { scenarioMeta: { startYear: 180 } },
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const caller = appRouter.createCaller(buildContext({ state, battleSim }));
const request = { ...buildBattleRequest(), repeatCnt: 1000 };
delete (request as Partial<typeof request>).seed;
const prepared = await caller.battle.prepareSimulation(request);
expect(prepared).toMatchObject({
action: 'battle',
repeatCnt: 1000,
scenarioEffect: 'event_MoreEffect',
time: { year: 200, month: 1, startYear: 180 },
config: { armPerPhase: 500, maxTrainByWar: 110, maxAtmosByWar: 150 },
});
expect(prepared.unitSet.crewTypes?.length).toBeGreaterThan(0);
expect(prepared.seeds).toHaveLength(1000);
expect(new Set(prepared.seeds).size).toBe(1000);
expect(battleSim.simulateCalls).toBe(0);
});
it('does not allocate repeat seeds when the client supplies a fixed seed', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const caller = appRouter.createCaller(buildContext({ state, battleSim }));
const prepared = await caller.battle.prepareSimulation(buildBattleRequest());
expect(prepared.seed).toBe('test-seed');
expect(prepared.seeds).toEqual([]);
expect(battleSim.simulateCalls).toBe(0);
});
it('returns queued then completed results via transport', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
@@ -351,6 +403,9 @@ describe('battle router orchestration', () => {
} as unknown as DatabaseClient;
const anonymous = appRouter.createCaller(buildContext({ state, battleSim, userId: null, db }));
await expect(anonymous.battle.prepareSimulation(buildBattleRequest())).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
await expect(anonymous.battle.simulate(buildBattleRequest())).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
@@ -358,6 +413,10 @@ describe('battle router orchestration', () => {
const noGeneralUser = appRouter.createCaller(
buildContext({ state, battleSim, userId: 'user-without-general', db })
);
await expect(noGeneralUser.battle.prepareSimulation(buildBattleRequest())).resolves.toMatchObject({
action: 'battle',
seed: 'test-seed',
});
await expect(noGeneralUser.battle.simulate(buildBattleRequest())).resolves.toMatchObject({
status: 'queued',
});
@@ -96,7 +96,7 @@ const classifications = {
],
operational: ['turnDaemon.pause', 'turnDaemon.resume', 'turnDaemon.run'],
externalUpload: ['board.uploadImage'],
readOnlyMutationTransport: ['battle.simulate'],
readOnlyMutationTransport: ['battle.prepareSimulation', 'battle.simulate'],
sessionOnly: ['auth.exchangeGatewayToken'],
} as const;
@@ -142,7 +142,7 @@ describe('game-api direct mutation journal inventory', () => {
const classified = Object.values(classifications).flat().sort();
expect(new Set(classified).size).toBe(classified.length);
expect(classified).toHaveLength(86);
expect(classified).toHaveLength(87);
expect(actual).toEqual(classified);
});
});
@@ -121,6 +121,7 @@ describe('general access tracking', () => {
'npc.setNationPolicy': 0,
'npc.setNationPriority': 0,
'npc.setGeneralPriority': 0,
'battle.prepareSimulation': 0,
'battle.simulate': 0,
});
});
+192 -69
View File
@@ -2,6 +2,12 @@ import { expect, test, type Page, type Route } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
processBattleSimJob,
type BattleSimJobPayload,
type BattleSimRequestPayload,
type BattleSimResultPayload,
} from '@sammo-ts/logic';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
@@ -71,6 +77,67 @@ const simulatorOptions = {
],
};
const engineUnitSet: BattleSimJobPayload['unitSet'] = {
id: 'playwright',
name: 'playwright',
crewTypes: [
{
id: 100,
armType: 1,
name: '보병',
attack: 100,
defence: 100,
speed: 7,
avoid: 10,
magicCoef: 0,
cost: 9,
rice: 9,
requirements: [],
attackCoef: {},
defenceCoef: {},
info: [],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
},
{
id: 999,
armType: 9,
name: '성벽',
attack: 0,
defence: 0,
speed: 1,
avoid: 0,
magicCoef: 0,
cost: 0,
rice: 9,
requirements: [],
attackCoef: {},
defenceCoef: {},
info: [],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
},
],
};
const engineConfig: BattleSimJobPayload['config'] = {
armPerPhase: 500,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
maxTrainByWar: 110,
maxAtmosByWar: 150,
castleCrewTypeId: 999,
armTypes: {
footman: 1,
wizard: 4,
siege: 5,
misc: 6,
castle: 9,
},
};
const generalMe = {
general: {
id: 7,
@@ -132,40 +199,25 @@ const importedGeneral = {
},
};
const simulationResult = {
result: true,
reason: 'success',
datetime: '205-08',
avgWar: 5,
phase: 13,
killed: 1234,
maxKilled: 1400,
minKilled: 1100,
dead: 432,
maxDead: 500,
minDead: 400,
attackerRice: 321,
defenderRice: 654,
attackerSkills: { 필살: 2 },
defendersSkills: [{ 회피: 1 }],
lastWarLog: {
generalHistoryLog: '',
generalActionLog: '',
generalBattleResultLog: '<span>유비가 모의전에서 승리했습니다.</span>',
generalBattleDetailLog: '<span>필살 발동, 피해 1,234</span>',
nationalHistoryLog: '',
globalHistoryLog: '',
globalActionLog: '',
},
};
type Fixture = {
hasGeneral: boolean;
failNextSimulation?: boolean;
queueFirst?: boolean;
pollingCount: number;
requests: string[];
simulationPayloads: unknown[];
preparedPayloads: BattleSimJobPayload[];
serverResults: BattleSimResultPayload[];
};
const readOperationInput = (
requestBody: Record<string, unknown>,
operationCount: number,
operationIndex: number
): unknown => {
const rawPayload = requestBody[String(operationIndex)] ?? (operationCount === 1 ? requestBody : undefined);
if (!rawPayload || typeof rawPayload !== 'object') {
return rawPayload;
}
const payload = rawPayload as { json?: unknown; input?: { json?: unknown } };
return payload.json ?? payload.input?.json ?? rawPayload;
};
const installImages = async (page: Page) => {
@@ -182,6 +234,22 @@ const installImages = async (page: Page) => {
const installApi = async (page: Page, fixture: Fixture) => {
await installImages(page);
await page.addInitScript(() => {
const nativeWorker = window.Worker;
const testWindow = window as unknown as {
__battleWorkerResponses: unknown[];
__battleWorkerUrls: string[];
};
testWindow.__battleWorkerResponses = [];
testWindow.__battleWorkerUrls = [];
window.Worker = class TrackedWorker extends nativeWorker {
constructor(scriptURL: string | URL, options?: WorkerOptions) {
super(scriptURL, options);
testWindow.__battleWorkerUrls.push(String(scriptURL));
this.addEventListener('message', (event) => testWindow.__battleWorkerResponses.push(event.data));
}
};
});
await page.addInitScript((profile) => {
window.localStorage.setItem('sammo-game-token', 'ga_battle_sim_playwright');
window.localStorage.setItem('sammo-game-profile', profile);
@@ -212,36 +280,29 @@ const installApi = async (page: Page, fixture: Fixture) => {
});
}
if (operation === 'battle.getGeneralDetail') return response(importedGeneral);
if (operation === 'battle.simulate') {
const rawPayload =
requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined);
const payload =
rawPayload && typeof rawPayload === 'object'
? (rawPayload as {
json?: unknown;
input?: { json?: unknown };
})
: undefined;
fixture.simulationPayloads.push(payload?.json ?? payload?.input?.json ?? rawPayload);
if (operation === 'battle.prepareSimulation') {
if (fixture.failNextSimulation) {
fixture.failNextSimulation = false;
return errorResponse(operation, '시뮬레이터 입력 오류');
}
if (fixture.queueFirst) {
return response({ status: 'queued', jobId: 'job-playwright' });
}
return response({ status: 'completed', jobId: 'job-playwright', payload: simulationResult });
}
if (operation === 'battle.getSimulation') {
fixture.pollingCount += 1;
if (fixture.pollingCount === 1) {
return response({ status: 'queued', jobId: 'job-playwright' });
}
return response({
status: 'completed',
jobId: 'job-playwright',
payload: simulationResult,
});
const request = readOperationInput(
requestBody,
operations.length,
operationIndex
) as BattleSimRequestPayload;
const prepared: BattleSimJobPayload = {
...request,
seeds: request.seed
? []
: Array.from({ length: request.repeatCnt }, (_, index) => `playwright-repeat-${index}`),
unitSet: engineUnitSet,
config: engineConfig,
time: { year: request.year, month: request.month, startYear: 190 },
scenarioEffect: null,
};
fixture.preparedPayloads.push(prepared);
fixture.serverResults.push(processBattleSimJob(structuredClone(prepared)));
return response(prepared);
}
return errorResponse(operation, `Unhandled battle simulator fixture operation: ${operation}`);
});
@@ -253,6 +314,26 @@ const installApi = async (page: Page, fixture: Fixture) => {
});
};
const readBrowserWorkerResult = async (page: Page, resultIndex: number): Promise<BattleSimResultPayload> => {
await expect
.poll(async () =>
page.evaluate((index) => {
const testWindow = window as unknown as { __battleWorkerResponses?: unknown[] };
return Boolean(testWindow.__battleWorkerResponses?.[index]);
}, resultIndex)
)
.toBe(true);
const response = (await page.evaluate((index) => {
const testWindow = window as unknown as { __battleWorkerResponses?: unknown[] };
return testWindow.__battleWorkerResponses?.[index];
}, resultIndex)) as {
ok: boolean;
result: BattleSimResultPayload;
};
expect(response.ok).toBe(true);
return response.result;
};
const gotoSimulator = async (page: Page) => {
await page.goto('battle-simulator');
await expect(page.getByText('전역 설정')).toBeVisible();
@@ -263,10 +344,9 @@ const gotoSimulator = async (page: Page) => {
test('operates independent/game presets, imports my general, and renders battle logs', async ({ page }) => {
const fixture: Fixture = {
hasGeneral: true,
queueFirst: true,
pollingCount: 0,
requests: [],
simulationPayloads: [],
preparedPayloads: [],
serverResults: [],
};
await installApi(page, fixture);
await page.setViewportSize({ width: 1280, height: 900 });
@@ -295,11 +375,22 @@ test('operates independent/game presets, imports my general, and renders battle
await page.getByLabel('시드').fill('playwright-fixed-seed');
await battleButton.click();
await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible();
await expect(page.getByText('5', { exact: true })).toBeVisible();
expect(fixture.pollingCount).toBe(2);
expect(fixture.requests).toContain('battle.getSimulation');
expect(fixture.simulationPayloads[0]).toMatchObject({
await expect(page.getByText('전투를 진행 중입니다.')).toHaveCount(0);
expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]);
for (const [parityId, html] of [
['battle-log', fixture.serverResults[0]?.lastWarLog?.generalBattleResultLog ?? ''],
['battle-detail-log', fixture.serverResults[0]?.lastWarLog?.generalBattleDetailLog ?? ''],
] as const) {
const browserNormalizedHtml = await page.evaluate((rawHtml) => {
const element = document.createElement('div');
element.innerHTML = rawHtml;
return element.innerHTML;
}, html);
expect(await page.locator(`[data-parity-id="${parityId}"]`).innerHTML()).toBe(browserNormalizedHtml);
}
expect(fixture.requests).not.toContain('battle.simulate');
expect(fixture.requests).not.toContain('battle.getSimulation');
expect(fixture.preparedPayloads[0]).toMatchObject({
attackerGeneral: { special: 'che_event_신산' },
});
@@ -318,8 +409,9 @@ test('operates independent/game presets, imports my general, and renders battle
await page.locator('.header-actions input[type="file"]').setInputFiles(downloadPath!);
await battleButton.click();
await expect.poll(() => fixture.simulationPayloads.length).toBe(2);
expect(fixture.simulationPayloads[1]).toMatchObject({
await expect.poll(() => fixture.preparedPayloads.length).toBe(2);
expect(await readBrowserWorkerResult(page, 1)).toEqual(fixture.serverResults[1]);
expect(fixture.preparedPayloads[1]).toMatchObject({
attackerGeneral: { special: 'che_event_신산' },
});
@@ -336,9 +428,9 @@ test('keeps simulation available without a game general and preserves input afte
const fixture: Fixture = {
hasGeneral: false,
failNextSimulation: true,
pollingCount: 0,
requests: [],
simulationPayloads: [],
preparedPayloads: [],
serverResults: [],
};
await installApi(page, fixture);
await page.setViewportSize({ width: 500, height: 900 });
@@ -355,8 +447,9 @@ test('keeps simulation available without a game general and preserves input afte
await expect(page.getByLabel('시드')).toHaveValue('keep-this-seed');
await page.getByRole('button', { name: '전투', exact: true }).click();
await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible();
await expect(page.getByText('전투를 진행 중입니다.')).toHaveCount(0);
await expect(page.getByText('시뮬레이터 입력 오류')).toHaveCount(0);
expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]);
const notice = page.getByLabel('시뮬레이터 데이터 안내');
expect(await notice.evaluate((element) => getComputedStyle(element).position)).toBe('absolute');
@@ -370,3 +463,33 @@ test('keeps simulation available without a game general and preserves input afte
});
}
});
test('runs 1000 battles in the Chromium worker and matches the Node processor exactly', async ({ page }) => {
test.setTimeout(60_000);
const fixture: Fixture = {
hasGeneral: false,
requests: [],
preparedPayloads: [],
serverResults: [],
};
await installApi(page, fixture);
await page.setViewportSize({ width: 1280, height: 900 });
await gotoSimulator(page);
await page.getByLabel('반복 횟수').selectOption('1000');
await page.getByLabel('시드').fill('');
await page.getByRole('button', { name: '전투', exact: true }).click();
await expect(page.getByText('전투를 진행 중입니다.')).toHaveCount(0, { timeout: 30_000 });
expect(fixture.preparedPayloads).toHaveLength(1);
expect(fixture.preparedPayloads[0]?.seeds).toHaveLength(1000);
expect(new Set(fixture.preparedPayloads[0]?.seeds).size).toBe(1000);
expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]);
expect(fixture.requests).not.toContain('battle.simulate');
expect(fixture.requests).not.toContain('battle.getSimulation');
const workerUrls = await page.evaluate(() => {
const testWindow = window as unknown as { __battleWorkerUrls?: string[] };
return testWindow.__battleWorkerUrls ?? [];
});
expect(workerUrls.some((url) => url.includes('battleSimulator.worker'))).toBe(true);
});
@@ -0,0 +1,81 @@
import type { BattleSimJobPayload, BattleSimResultPayload } from '@sammo-ts/logic';
import type { BattleSimulatorWorkerRequest, BattleSimulatorWorkerResponse } from './battleSimulatorWorkerProtocol';
type PendingRequest = {
resolve: (result: BattleSimResultPayload) => void;
reject: (error: Error) => void;
};
export class BattleSimulatorWorkerClient {
private worker: Worker | null = null;
private nextRequestId = 1;
private readonly pending = new Map<number, PendingRequest>();
private ensureWorker(): Worker {
if (this.worker) {
return this.worker;
}
const worker = new Worker(new URL('../workers/battleSimulator.worker.ts', import.meta.url), {
type: 'module',
name: 'sammo-battle-simulator',
});
worker.addEventListener('message', this.handleMessage);
worker.addEventListener('error', this.handleWorkerError);
this.worker = worker;
return worker;
}
private readonly handleMessage = (event: MessageEvent<BattleSimulatorWorkerResponse>): void => {
const pending = this.pending.get(event.data.requestId);
if (!pending) {
return;
}
this.pending.delete(event.data.requestId);
if (event.data.ok) {
pending.resolve(event.data.result);
return;
}
pending.reject(new Error(event.data.error));
};
private readonly handleWorkerError = (event: ErrorEvent): void => {
const error = new Error(event.message || '전투 시뮬레이션 worker 오류');
for (const pending of this.pending.values()) {
pending.reject(error);
}
this.pending.clear();
this.disposeWorker();
};
public run(payload: BattleSimJobPayload): Promise<BattleSimResultPayload> {
const requestId = this.nextRequestId;
this.nextRequestId += 1;
const request: BattleSimulatorWorkerRequest = { requestId, payload };
const promise = new Promise<BattleSimResultPayload>((resolve, reject) => {
this.pending.set(requestId, { resolve, reject });
});
this.ensureWorker().postMessage(request);
return promise;
}
private disposeWorker(): void {
if (!this.worker) {
return;
}
this.worker.removeEventListener('message', this.handleMessage);
this.worker.removeEventListener('error', this.handleWorkerError);
this.worker.terminate();
this.worker = null;
}
public dispose(): void {
const error = new Error('전투 시뮬레이션이 취소되었습니다.');
for (const pending of this.pending.values()) {
pending.reject(error);
}
this.pending.clear();
this.disposeWorker();
}
}
@@ -0,0 +1,18 @@
import type { BattleSimJobPayload, BattleSimResultPayload } from '@sammo-ts/logic';
export interface BattleSimulatorWorkerRequest {
requestId: number;
payload: BattleSimJobPayload;
}
export type BattleSimulatorWorkerResponse =
| {
requestId: number;
ok: true;
result: BattleSimResultPayload;
}
| {
requestId: number;
ok: false;
error: string;
};
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import type { BattleSimRequestPayload, BattleSimResultPayload } from '@sammo-ts/game-api';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
@@ -7,6 +7,7 @@ import BattleGeneralCard from '../components/battle/BattleGeneralCard.vue';
import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor';
import type { BattleSimOptions, GeneralDraft, InheritBuff } from '../utils/battleSimulatorTypes';
import { BattleSimulatorWorkerClient } from '../utils/battleSimulatorWorkerClient';
type GeneralExport = Omit<GeneralDraft, 'id'>;
@@ -67,6 +68,11 @@ const defenders = ref<GeneralDraft[]>([]);
const isSimulating = ref(false);
const statusMessage = ref<string | null>(null);
const simulationWorker = new BattleSimulatorWorkerClient();
onBeforeUnmount(() => {
simulationWorker.dispose();
});
const importOpen = ref(false);
const importTarget = ref<GeneralDraft | null>(null);
@@ -567,20 +573,6 @@ const buildBattlePayload = (action: BattleSimRequestPayload['action']): BattleSi
};
};
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const waitForSimulationResult = async (jobId: string): Promise<BattleSimResultPayload> => {
const deadline = Date.now() + 15000;
while (Date.now() < deadline) {
const response = await trpc.battle.getSimulation.query({ jobId });
if ('payload' in response && response.payload) {
return response.payload;
}
await delay(800);
}
throw new Error('simulation_timeout');
};
const runSimulation = async (action: BattleSimRequestPayload['action']) => {
if (!options.value) {
return;
@@ -595,11 +587,8 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => {
try {
const payload = buildBattlePayload(action);
const response = await trpc.battle.simulate.mutate(payload);
const result =
'payload' in response && response.payload
? response.payload
: await waitForSimulationResult(response.jobId);
const preparedPayload = await trpc.battle.prepareSimulation.mutate(payload);
const result = await simulationWorker.run(preparedPayload);
if (!result.result) {
error.value = result.reason || 'battle_failed';
@@ -0,0 +1,32 @@
/// <reference lib="webworker" />
import type {
BattleSimulatorWorkerRequest,
BattleSimulatorWorkerResponse,
} from '../utils/battleSimulatorWorkerProtocol';
const workerScope: DedicatedWorkerGlobalScope = self as DedicatedWorkerGlobalScope;
const loadProcessor = () => import('@sammo-ts/logic');
let processorPromise: ReturnType<typeof loadProcessor> | null = null;
workerScope.addEventListener('message', async (event: MessageEvent<BattleSimulatorWorkerRequest>) => {
const { requestId, payload } = event.data;
let response: BattleSimulatorWorkerResponse;
try {
processorPromise ??= loadProcessor();
const { processBattleSimJob } = await processorPromise;
response = {
requestId,
ok: true,
result: processBattleSimJob(payload),
};
} catch (error) {
response = {
requestId,
ok: false,
error: error instanceof Error ? error.message : '전투 시뮬레이션 오류',
};
}
workerScope.postMessage(response);
});
+8
View File
@@ -33,6 +33,14 @@ export default defineConfig(({ mode }) => {
build: {
sourcemap: true,
},
worker: {
format: 'es',
rolldownOptions: {
output: {
codeSplitting: false,
},
},
},
resolve: {
alias: {
'@': path.resolve(import.meta.dirname, './src'),
+5 -3
View File
@@ -61,9 +61,11 @@ scenario parser, resource schema, PostgreSQL world loader와 battle simulator
않습니다.
전투 시뮬레이터의 효과는 공개 request가 아니라 저장된 world config에서
서버가 파생합니다. 내부 queue payload의 필드는 optional이므로 배포 전에
생성된 payload는 효과 없음으로 처리하며, 신·구 API/worker 혼재 시에는
효과 누락을 피하기 위해 queue를 비우거나 API와 worker를 함께 재시작합니다.
서버가 파생합니다. `battle.prepareSimulation`은 해당 효과와 전체 병종 정의,
전투 상수, 시나리오 시작 연도, 반복별 seed를 권위 payload로 만들고 브라우저
Web Worker가 `@sammo-ts/logic`의 공용 프로세서를 실행합니다. 기존
`battle.simulate`와 Redis worker도 같은 processor와 payload를 사용하므로
검증·호환 fallback 경로에서 결과를 대조할 수 있습니다.
## 닫힌 의미 이벤트
@@ -0,0 +1,46 @@
# 전투 시뮬레이터 브라우저 실행 경계
## 요청과 권위 데이터
`BattleSimulatorView.vue`는 사용자가 편집한 장수·국가·도시 입력을
`battle.prepareSimulation`에 보냅니다. 이 API는 로그인만 요구하며 게임 장수
보유 여부나 input-event transaction에는 의존하지 않습니다. 서버는 현재
`WorldState`에서 다음 값을 읽어 실행 payload에 추가합니다.
- 전체 `UnitSetDefinition`
- 전투 상수와 성벽 병종/병과 ID
- 시나리오 시작 연도와 저장된 `scenarioEffect`
- 고정 seed가 없는 경우 각 반복에 사용할 UUID seed
클라이언트가 같은 이름의 `scenarioEffect`를 보내도 입력 schema가 제거하며,
저장된 효과가 유일한 기준입니다. 1000회 실행 seed를 서버에서 한 번 확정하므로
브라우저와 Node가 동일 payload를 재실행해 전체 결과를 정확히 비교할 수 있습니다.
## 실행 경로
준비된 payload는 module Web Worker에 전달됩니다. Worker는 메시지 handler를 먼저
등록한 뒤 `@sammo-ts/logic`을 lazy-load하고 `processBattleSimJob()`을 실행합니다.
전투 중 UI thread를 점유하지 않으며, 결과만 화면 상태로 돌려줍니다. Worker
bundle은 동적 trait/item module을 한 파일에 묶어 첫 실행의 다수 요청을 피합니다.
공용 processor와 DTO는 `packages/logic/src/battleSimulator/`가 소유합니다.
game-api의 processor/type 파일은 기존 import와 Redis worker 호환을 위한 re-export
경계입니다. 기존 `battle.simulate` → Redis queue → battle-sim worker 경로는 당장
삭제하지 않고 검증 및 호환 fallback으로 유지하지만 기본 화면에서는 호출하지
않습니다.
## RNG와 부작용
- 고정 seed가 있으면 기존 계약대로 한 번만 실행합니다.
- 고정 seed가 없으면 `payload.seeds[index]`를 반복 순서대로 소비합니다.
- 구형 Redis payload처럼 seed 배열이 없을 때만 실행 runtime의
`crypto.randomUUID()`를 fallback으로 사용합니다.
- 계산은 전달받은 plain object에서 새 도메인 객체를 만들며 DB, Redis, 턴 상태를
변경하지 않습니다.
## 검증
`battleSimulator.spec.ts`는 production bundle의 실제 Chromium module Worker를
실행합니다. 고정 seed와 서버 발급 seed 1000개 케이스에서 Node processor 결과와
브라우저 Worker의 전체 결과 객체를 비교하고, 기본 UI 경로가
`battle.simulate`/`battle.getSimulation`을 호출하지 않는지 확인합니다.
@@ -41,10 +41,10 @@ writer reconciliation을 포함한다. rolling deployment가 끝난 뒤에만
| Redis projection | 6 | `tournament.patchState`, `tournament.seedParticipants`, `tournament.setBettingEntries`, `tournament.setMatches`, `tournament.setParticipants`, `tournament.setState` |
| operational | 3 | `turnDaemon.pause`, `turnDaemon.resume`, `turnDaemon.run` |
| external upload | 1 | `board.uploadImage` |
| read-only mutation transport | 1 | `battle.simulate` |
| read-only mutation transport | 2 | `battle.prepareSimulation`, `battle.simulate` |
| session only | 1 | `auth.exchangeGatewayToken` |
합계는 86개다.
합계는 87개다.
## durable journal dependency 매핑
@@ -75,7 +75,8 @@ public dashboard event로 내보내지 않는다. browser wake-up은 정밀 enti
- nation reserved turn, selection-pool reservation, possession 후보, inheritance owner
확인은 각각 전용 화면/request response가 최신 상태를 소유한다.
- image upload는 외부 content store write이며 game PostgreSQL read model이 아니다.
- battle simulation은 호환상 mutation transport를 쓰지만 read-only 계산이다.
- battle simulation 준비와 서버 fallback은 호환상 mutation transport를 쓰지만
read-only 계산이며 input event transaction을 열지 않는다.
## 남은 업무 원자성 gap과 coverage 판정
+1 -1
View File
@@ -92,7 +92,7 @@ storage, route guards, and image loading.
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
| battle simulator | `hwe/battle_simulator.php` | centered 1000px desktop document, 500px responsive stacking, independent/current presets, owned-general import gating, fixed-seed result/logs, retained input after API error |
| battle simulator | `hwe/battle_simulator.php` | centered 1000px desktop document, 500px responsive stacking, independent/current presets, owned-general import gating, browser Web Worker calculation including 1000 repeats, fixed-seed result/logs, retained input after API error |
| NPC policy | `hwe/v_NPCControl.php` | 1000/500px form and priority-list geometry, walnut/green textures, dynamic zero hints, drag/focus/tooltip, successful save and permission failures |
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
@@ -0,0 +1,2 @@
export * from './processor.js';
export * from './types.js';
@@ -0,0 +1,498 @@
import { formatLegacyLogHtml, type RandUtil } from '@sammo-ts/common';
import {
createRefOrderedActionStack,
createScenarioEffectActionModules,
createTraitCatalog,
createOfficerLevelActionModules,
DOMESTIC_TRAIT_KEYS,
EVENT_DOMESTIC_TRAIT_KEYS,
loadDomesticTraitModules,
loadEventDomesticTraitModules,
loadNationTraitModules,
loadPersonalityTraitModules,
loadWarTraitModules,
NATION_TRAIT_KEYS,
PERSONALITY_TRAIT_KEYS,
TraitWarActionRouter,
WAR_TRAIT_KEYS,
type RefOrderedActionStack,
} from '../actionModules/index.js';
import { compileCrewTypeCatalog } from '../crewType/index.js';
import type { City, General, Nation } from '../domain/entities.js';
import { createInheritBuffModules } from '../inheritance/inheritBuff.js';
import { createItemActionModules, createItemModuleRegistry, ITEM_KEYS, loadItemModules } from '../items/index.js';
import { formatLogText, LogCategory, LogFormat, LogScope } from '../logging/index.js';
import {
createCrewTypeWarTriggerRegistry,
resolveDefenderOrder,
resolveWarBattle,
type WarBattleOutcome,
type WarActionModule,
type WarUnitReport,
type WarBattleTraceEvent,
} from '../war/index.js';
import { getTechCost, type CrewTypeDefinition, type UnitSetDefinition } from '../world/index.js';
import { type BattleSimJobPayload, type BattleSimLogBuckets, type BattleSimResultPayload } from './types.js';
const DEFAULT_GENERAL_AGE = 20;
const convertLog = (value: string, type = 1): string => formatLegacyLogHtml(value, { colorize: type > 0 });
const inheritBuffModules = createInheritBuffModules();
const itemWarModules: WarActionModule[] = createItemActionModules(
createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]))
).war;
const crewTypeWarTriggerRegistry = createCrewTypeWarTriggerRegistry();
const traitCatalog = createTraitCatalog({
domestic: [
...(await loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS])),
...(await loadEventDomesticTraitModules([...EVENT_DOMESTIC_TRAIT_KEYS])),
],
war: await loadWarTraitModules([...WAR_TRAIT_KEYS]),
personality: await loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]),
nation: await loadNationTraitModules([...NATION_TRAIT_KEYS]),
});
const nationWarModule = new TraitWarActionRouter('nation', traitCatalog);
const officerWarModule = createOfficerLevelActionModules().war;
const domesticWarModule = new TraitWarActionRouter('domestic', traitCatalog);
const warTraitModule = new TraitWarActionRouter('war', traitCatalog);
const personalityWarModule = new TraitWarActionRouter('personality', traitCatalog);
const buildWarActionModules = (
unitSet: UnitSetDefinition,
scenarioEffect?: string | null
): RefOrderedActionStack<WarActionModule> => {
const crewTypeCatalog = compileCrewTypeCatalog(unitSet, crewTypeWarTriggerRegistry);
const scenario = createScenarioEffectActionModules(scenarioEffect);
return createRefOrderedActionStack<WarActionModule>({
nation: nationWarModule,
officer: officerWarModule,
domestic: domesticWarModule,
war: warTraitModule,
personality: personalityWarModule,
crewType: crewTypeCatalog.warActionModule,
inheritance: inheritBuffModules.war,
scenario: scenario.war,
items: itemWarModules,
});
};
const normalizeItemCode = (value: string | null): string | null => (value === 'None' ? null : value);
const mapNationPayload = (payload: BattleSimJobPayload['attackerNation']): Nation => ({
id: payload.nation,
name: payload.name,
color: '#000000',
capitalCityId: payload.capital,
chiefGeneralId: null,
gold: payload.gold,
rice: payload.rice,
power: 0,
level: payload.level,
typeCode: payload.type,
meta: {
tech: payload.tech,
gennum: payload.gennum,
},
});
const mapCityPayload = (payload: BattleSimJobPayload['attackerCity']): City => ({
id: payload.city,
name: payload.name,
nationId: payload.nation,
level: payload.level,
state: payload.state,
population: payload.pop,
populationMax: payload.pop_max,
agriculture: payload.agri,
agricultureMax: payload.agri_max,
commerce: payload.comm,
commerceMax: payload.comm_max,
security: payload.secu,
securityMax: payload.secu_max,
supplyState: payload.supply,
frontState: payload.state,
defence: payload.def,
defenceMax: payload.def_max,
wall: payload.wall,
wallMax: payload.wall_max,
meta: {
trust: payload.trust,
dead: payload.dead,
conflict: payload.conflict,
supply: payload.supply,
},
});
const mapGeneralPayload = (payload: BattleSimJobPayload['attackerGeneral'], currentCityId: number): General => ({
id: payload.no,
name: payload.name,
nationId: payload.nation,
cityId: payload.city ?? currentCityId,
troopId: 0,
stats: {
leadership: payload.leadership,
strength: payload.strength,
intelligence: payload.intel,
},
experience: payload.experience,
dedication: payload.dedication,
officerLevel: payload.officer_level,
role: {
personality: payload.personal,
specialDomestic: payload.special ?? null,
specialWar: payload.special2,
items: {
horse: normalizeItemCode(payload.horse),
weapon: normalizeItemCode(payload.weapon),
book: normalizeItemCode(payload.book),
item: normalizeItemCode(payload.item),
},
},
injury: payload.injury,
gold: payload.gold,
rice: payload.rice,
crew: payload.crew,
crewTypeId: payload.crewtype,
train: payload.train,
atmos: payload.atmos,
age: DEFAULT_GENERAL_AGE,
npcState: 0,
triggerState: {
flags: {},
counters: {},
modifiers: {},
meta: payload.inheritBuff ? { inheritBuff: JSON.stringify(payload.inheritBuff) } : {},
},
meta: {
killturn: 24,
explevel: payload.explevel,
turnTime: payload.turntime,
recentWar: payload.recent_war ?? '',
dex1: payload.dex1,
dex2: payload.dex2,
dex3: payload.dex3,
dex4: payload.dex4,
dex5: payload.dex5,
intel_exp: payload.intel_exp,
strength_exp: payload.strength_exp,
leadership_exp: payload.leadership_exp,
defence_train: payload.defence_train,
officerCity: payload.officer_city,
officer_city: payload.officer_city,
rank_warnum: payload.warnum,
rank_killnum: payload.killnum,
rank_killcrew: payload.killcrew,
},
});
const buildLogBuckets = (options: {
logs: WarBattleOutcome['logs'];
year: number;
month: number;
attackerId: number;
attackerNationId: number;
}): BattleSimLogBuckets => {
const buckets = {
generalHistoryLog: [] as string[],
generalActionLog: [] as string[],
generalBattleResultLog: [] as string[],
generalBattleDetailLog: [] as string[],
nationalHistoryLog: [] as string[],
globalHistoryLog: [] as string[],
globalActionLog: [] as string[],
};
for (const entry of options.logs) {
const format = entry.format ?? LogFormat.RAWTEXT;
const text = formatLogText(entry.text, format, options.year, options.month);
if (entry.scope === LogScope.GENERAL && entry.generalId === options.attackerId) {
switch (entry.category) {
case LogCategory.HISTORY:
buckets.generalHistoryLog.push(text);
break;
case LogCategory.ACTION:
buckets.generalActionLog.push(text);
break;
case LogCategory.BATTLE_BRIEF:
buckets.generalBattleResultLog.push(text);
break;
case LogCategory.BATTLE_DETAIL:
buckets.generalBattleDetailLog.push(text);
break;
default:
break;
}
continue;
}
if (
entry.scope === LogScope.NATION &&
entry.nationId === options.attackerNationId &&
entry.category === LogCategory.HISTORY
) {
buckets.nationalHistoryLog.push(text);
continue;
}
if (entry.scope === LogScope.SYSTEM) {
if (entry.category === LogCategory.HISTORY) {
buckets.globalHistoryLog.push(text);
} else if (entry.category === LogCategory.SUMMARY) {
buckets.globalActionLog.push(text);
}
}
}
return {
generalHistoryLog: convertLog(buckets.generalHistoryLog.join('<br>')),
generalActionLog: convertLog(buckets.generalActionLog.join('<br>')),
generalBattleResultLog: convertLog(buckets.generalBattleResultLog.join('<br>')),
generalBattleDetailLog: convertLog(buckets.generalBattleDetailLog.join('<br>')),
nationalHistoryLog: convertLog(buckets.nationalHistoryLog.join('<br>')),
globalHistoryLog: convertLog(buckets.globalHistoryLog.join('<br>')),
globalActionLog: convertLog(buckets.globalActionLog.join('<br>')),
};
};
const resolveRandomSeed = (): string => {
if (typeof globalThis.crypto?.randomUUID !== 'function') {
throw new Error('Secure random UUID generation is unavailable.');
}
return globalThis.crypto.randomUUID();
};
const resolveCityTrainAtmos = (year: number, startYear: number): number =>
Math.min(110, Math.max(60, year - startYear + 59));
const resolveCityRiceConsumption = (options: {
battle: WarBattleOutcome;
defenderNation: Nation;
unitSet: UnitSetDefinition;
castleCrewTypeId: number;
year: number;
startYear: number;
}): number => {
const cityReport = options.battle.reports.find((report: WarUnitReport) => report.type === 'city');
if (!cityReport) {
return 0;
}
if (cityReport.killed <= 0 && cityReport.dead <= 0) {
return 0;
}
const crewType = options.unitSet.crewTypes?.find(
(item: CrewTypeDefinition) => item.id === options.castleCrewTypeId
);
const riceCoef = crewType?.rice ?? 1;
const tech = Number(options.defenderNation.meta.tech ?? 0);
const trainAtmos = resolveCityTrainAtmos(options.year, options.startYear);
let rice = (cityReport.killed / 100) * 0.8;
rice *= riceCoef;
rice *= getTechCost(tech);
rice *= trainAtmos / 100 - 0.2;
return Math.round(rice);
};
const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] => {
const attackerNation = mapNationPayload(payload.attackerNation);
const defenderNation = mapNationPayload(payload.defenderNation);
const attackerCity = mapCityPayload(payload.attackerCity);
const defenderCity = mapCityPayload(payload.defenderCity);
const attacker = mapGeneralPayload(payload.attackerGeneral, attackerCity.id);
const defenders = payload.defenderGenerals.map((general) => mapGeneralPayload(general, defenderCity.id));
const warActionModules = buildWarActionModules(payload.unitSet, payload.scenarioEffect);
return resolveDefenderOrder({
unitSet: payload.unitSet,
config: payload.config,
time: payload.time,
seed: 'order',
attacker: {
general: attacker,
city: attackerCity,
nation: attackerNation,
modules: warActionModules,
},
defenders: defenders.map((general) => ({
general,
city: defenderCity,
nation: defenderNation,
modules: warActionModules,
})),
defenderCity,
defenderNation,
});
};
export interface BattleSimProcessorOptions {
trace?: (event: WarBattleTraceEvent) => void;
rngFactory?: (seed: string) => RandUtil;
}
export const processBattleSimJob = (
payload: BattleSimJobPayload,
options: BattleSimProcessorOptions = {}
): BattleSimResultPayload => {
if (payload.action === 'reorder') {
return {
result: true,
reason: 'success',
order: resolveDefenderOrderPayload(payload),
};
}
let repeatCnt = payload.repeatCnt;
const warActionModules = buildWarActionModules(payload.unitSet, payload.scenarioEffect);
const baseSeed = payload.seed ?? '';
if (baseSeed) {
repeatCnt = 1;
}
let lastBattle: WarBattleOutcome | null = null;
let attackerKilled = 0;
let attackerDead = 0;
let attackerMaxKilled = 0;
let attackerMinKilled = Number.POSITIVE_INFINITY;
let attackerMaxDead = 0;
let attackerMinDead = Number.POSITIVE_INFINITY;
let attackerAvgRice = 0;
let defenderAvgRice = 0;
let avgPhase = 0;
let avgWar = 0;
const attackerSkills: Record<string, number> = {};
const defendersSkills: Array<Record<string, number>> = [];
const weight = 1 / Math.max(1, repeatCnt);
for (let idx = 0; idx < repeatCnt; idx += 1) {
const seed = baseSeed || payload.seeds?.[idx] || resolveRandomSeed();
const attackerNation = mapNationPayload(payload.attackerNation);
const defenderNation = mapNationPayload(payload.defenderNation);
const attackerCity = mapCityPayload(payload.attackerCity);
const defenderCity = mapCityPayload(payload.defenderCity);
const attackerGeneral = mapGeneralPayload(payload.attackerGeneral, attackerCity.id);
const defenderGenerals = payload.defenderGenerals.map((general) => mapGeneralPayload(general, defenderCity.id));
const initialRice = new Map<number, number>();
initialRice.set(attackerGeneral.id, attackerGeneral.rice);
for (const defender of defenderGenerals) {
initialRice.set(defender.id, defender.rice);
}
const outcome = resolveWarBattle({
seed,
...(options.rngFactory ? { rng: options.rngFactory(seed) } : {}),
unitSet: payload.unitSet,
config: payload.config,
time: payload.time,
attacker: {
general: attackerGeneral,
city: attackerCity,
nation: attackerNation,
modules: warActionModules,
},
defenders: defenderGenerals.map((general) => ({
general,
city: defenderCity,
nation: defenderNation,
modules: warActionModules,
})),
defenderCity,
defenderNation,
...(options.trace ? { trace: options.trace } : {}),
});
lastBattle = outcome;
const attackerReport = outcome.reports.find(
(report: WarUnitReport) => report.type === 'general' && report.isAttacker
);
const killed = attackerReport?.killed ?? 0;
const dead = attackerReport?.dead ?? 0;
attackerKilled += killed * weight;
attackerDead += dead * weight;
attackerMaxKilled = Math.max(attackerMaxKilled, killed);
attackerMinKilled = Math.min(attackerMinKilled, killed);
attackerMaxDead = Math.max(attackerMaxDead, dead);
attackerMinDead = Math.min(attackerMinDead, dead);
const phase = outcome.metrics?.attackerPhase ?? 0;
avgPhase += phase * weight;
const defenderCount = outcome.metrics?.defenderActivatedSkills.length ?? 0;
avgWar += defenderCount * weight;
const attackerRiceInit = initialRice.get(attackerGeneral.id) ?? attackerGeneral.rice;
attackerAvgRice += (attackerRiceInit - outcome.attacker.rice) * weight;
let defenderRiceInit = 0;
let defenderRiceAfter = 0;
for (const defender of outcome.defenders) {
defenderRiceInit += initialRice.get(defender.id) ?? defender.rice;
defenderRiceAfter += defender.rice;
}
const cityRice = resolveCityRiceConsumption({
battle: outcome,
defenderNation,
unitSet: payload.unitSet,
castleCrewTypeId: payload.config.castleCrewTypeId,
year: payload.time.year,
startYear: payload.time.startYear,
});
defenderAvgRice += (defenderRiceInit - defenderRiceAfter + cityRice) * weight;
const attackerActivated = outcome.metrics?.attackerActivatedSkills ?? {};
for (const [skillName, value] of Object.entries(attackerActivated) as [string, number][]) {
attackerSkills[skillName] = (attackerSkills[skillName] ?? 0) + value * weight;
}
const defenderActivated = outcome.metrics?.defenderActivatedSkills ?? [];
for (let defIdx = 0; defIdx < defenderActivated.length; defIdx += 1) {
while (defIdx >= defendersSkills.length) {
defendersSkills.push({});
}
const bucket = defendersSkills[defIdx]!;
for (const [skillName, value] of Object.entries(defenderActivated[defIdx]!) as [string, number][]) {
bucket[skillName] = (bucket[skillName] ?? 0) + value * weight;
}
}
}
if (!lastBattle) {
return {
result: false,
reason: '전투 결과를 생성하지 못했습니다.',
};
}
const logBuckets = buildLogBuckets({
logs: lastBattle.logs,
year: payload.time.year,
month: payload.time.month,
attackerId: lastBattle.attacker.id,
attackerNationId: payload.attackerNation.nation,
});
return {
result: true,
reason: 'success',
datetime: payload.attackerGeneral.turntime,
lastWarLog: logBuckets,
avgWar,
phase: avgPhase,
killed: attackerKilled,
maxKilled: attackerMaxKilled,
minKilled: attackerMinKilled === Number.POSITIVE_INFINITY ? 0 : attackerMinKilled,
dead: attackerDead,
maxDead: attackerMaxDead,
minDead: attackerMinDead === Number.POSITIVE_INFINITY ? 0 : attackerMinDead,
attackerRice: attackerAvgRice,
defenderRice: defenderAvgRice,
attackerSkills,
defendersSkills,
};
};
+138
View File
@@ -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[];
}
+1
View File
@@ -4,6 +4,7 @@ export * from './actions/index.js';
export * from './actionModules/index.js';
export * from './auction/alias.js';
export * from './auction/neutral.js';
export * from './battleSimulator/index.js';
export * from './constraints/index.js';
export * from './crewType/index.js';
export * from './diplomacy/index.js';