feat: 전투 시뮬레이터를 브라우저 Worker로 이관
서버가 권위 환경과 반복 seed를 준비하고 공용 logic 프로세서를 브라우저와 기존 서버 fallback이 함께 사용하도록 변경한다. production Chromium에서 고정 seed와 1000회 Node 결과 동등성을 검증한다.
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import type { WorldStateRow } from '../context.js';
|
||||
import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
|
||||
import { normalizeScenarioEffect, type ScenarioEffectKey, type WarEngineConfig } from '@sammo-ts/logic';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import type { UnitSetDefinition } from '@sammo-ts/logic';
|
||||
|
||||
import type { WorldStateRow } from '../context.js';
|
||||
import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.js';
|
||||
|
||||
const DEFAULT_WAR_CONFIG = {
|
||||
armPerPhase: 500,
|
||||
maxTrainByCommand: 100,
|
||||
@@ -133,6 +136,7 @@ export const buildBattleSimJobPayload = async (
|
||||
|
||||
return {
|
||||
...request,
|
||||
seeds: request.seed ? [] : Array.from({ length: request.repeatCnt }, () => randomUUID()),
|
||||
unitSet: environment.unitSet,
|
||||
config: environment.config,
|
||||
time: {
|
||||
|
||||
@@ -1,507 +1,2 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { RandUtil } from '@sammo-ts/common';
|
||||
import {
|
||||
formatLogText,
|
||||
getTechCost,
|
||||
LogCategory,
|
||||
LogFormat,
|
||||
LogScope,
|
||||
resolveDefenderOrder,
|
||||
resolveWarBattle,
|
||||
createItemActionModules,
|
||||
createItemModuleRegistry,
|
||||
createRefOrderedActionStack,
|
||||
createScenarioEffectActionModules,
|
||||
ITEM_KEYS,
|
||||
loadItemModules,
|
||||
createInheritBuffModules,
|
||||
createTraitCatalog,
|
||||
createOfficerLevelActionModules,
|
||||
DOMESTIC_TRAIT_KEYS,
|
||||
EVENT_DOMESTIC_TRAIT_KEYS,
|
||||
loadDomesticTraitModules,
|
||||
loadEventDomesticTraitModules,
|
||||
loadNationTraitModules,
|
||||
loadPersonalityTraitModules,
|
||||
loadWarTraitModules,
|
||||
NATION_TRAIT_KEYS,
|
||||
PERSONALITY_TRAIT_KEYS,
|
||||
TraitWarActionRouter,
|
||||
WAR_TRAIT_KEYS,
|
||||
compileCrewTypeCatalog,
|
||||
createCrewTypeWarTriggerRegistry,
|
||||
type City,
|
||||
type General,
|
||||
type Nation,
|
||||
type RefOrderedActionStack,
|
||||
type UnitSetDefinition,
|
||||
type WarBattleOutcome,
|
||||
type WarActionModule,
|
||||
type WarUnitReport,
|
||||
type WarBattleTraceEvent,
|
||||
type CrewTypeDefinition,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import { type BattleSimJobPayload, type BattleSimLogBuckets, type BattleSimResultPayload } from './types.js';
|
||||
import { convertLog } from './logFormatter.js';
|
||||
|
||||
const DEFAULT_GENERAL_AGE = 20;
|
||||
|
||||
const inheritBuffModules = createInheritBuffModules();
|
||||
const itemWarModules: WarActionModule[] = createItemActionModules(
|
||||
createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]))
|
||||
).war;
|
||||
const crewTypeWarTriggerRegistry = createCrewTypeWarTriggerRegistry();
|
||||
const traitCatalog = createTraitCatalog({
|
||||
domestic: [
|
||||
...(await loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS])),
|
||||
...(await loadEventDomesticTraitModules([...EVENT_DOMESTIC_TRAIT_KEYS])),
|
||||
],
|
||||
war: await loadWarTraitModules([...WAR_TRAIT_KEYS]),
|
||||
personality: await loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]),
|
||||
nation: await loadNationTraitModules([...NATION_TRAIT_KEYS]),
|
||||
});
|
||||
const nationWarModule = new TraitWarActionRouter('nation', traitCatalog);
|
||||
const officerWarModule = createOfficerLevelActionModules().war;
|
||||
const domesticWarModule = new TraitWarActionRouter('domestic', traitCatalog);
|
||||
const warTraitModule = new TraitWarActionRouter('war', traitCatalog);
|
||||
const personalityWarModule = new TraitWarActionRouter('personality', traitCatalog);
|
||||
|
||||
const buildWarActionModules = (
|
||||
unitSet: UnitSetDefinition,
|
||||
scenarioEffect?: string | null
|
||||
): RefOrderedActionStack<WarActionModule> => {
|
||||
const crewTypeCatalog = compileCrewTypeCatalog(unitSet, crewTypeWarTriggerRegistry);
|
||||
const scenario = createScenarioEffectActionModules(scenarioEffect);
|
||||
return createRefOrderedActionStack<WarActionModule>({
|
||||
nation: nationWarModule,
|
||||
officer: officerWarModule,
|
||||
domestic: domesticWarModule,
|
||||
war: warTraitModule,
|
||||
personality: personalityWarModule,
|
||||
crewType: crewTypeCatalog.warActionModule,
|
||||
inheritance: inheritBuffModules.war,
|
||||
scenario: scenario.war,
|
||||
items: itemWarModules,
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeItemCode = (value: string | null): string | null => (value === 'None' ? null : value);
|
||||
|
||||
const mapNationPayload = (payload: BattleSimJobPayload['attackerNation']): Nation => ({
|
||||
id: payload.nation,
|
||||
name: payload.name,
|
||||
color: '#000000',
|
||||
capitalCityId: payload.capital,
|
||||
chiefGeneralId: null,
|
||||
gold: payload.gold,
|
||||
rice: payload.rice,
|
||||
power: 0,
|
||||
level: payload.level,
|
||||
typeCode: payload.type,
|
||||
meta: {
|
||||
tech: payload.tech,
|
||||
gennum: payload.gennum,
|
||||
},
|
||||
});
|
||||
|
||||
const mapCityPayload = (payload: BattleSimJobPayload['attackerCity']): City => ({
|
||||
id: payload.city,
|
||||
name: payload.name,
|
||||
nationId: payload.nation,
|
||||
level: payload.level,
|
||||
state: payload.state,
|
||||
population: payload.pop,
|
||||
populationMax: payload.pop_max,
|
||||
agriculture: payload.agri,
|
||||
agricultureMax: payload.agri_max,
|
||||
commerce: payload.comm,
|
||||
commerceMax: payload.comm_max,
|
||||
security: payload.secu,
|
||||
securityMax: payload.secu_max,
|
||||
supplyState: payload.supply,
|
||||
frontState: payload.state,
|
||||
defence: payload.def,
|
||||
defenceMax: payload.def_max,
|
||||
wall: payload.wall,
|
||||
wallMax: payload.wall_max,
|
||||
meta: {
|
||||
trust: payload.trust,
|
||||
dead: payload.dead,
|
||||
conflict: payload.conflict,
|
||||
supply: payload.supply,
|
||||
},
|
||||
});
|
||||
|
||||
const mapGeneralPayload = (
|
||||
payload: BattleSimJobPayload['attackerGeneral'],
|
||||
currentCityId: number
|
||||
): General => ({
|
||||
id: payload.no,
|
||||
name: payload.name,
|
||||
nationId: payload.nation,
|
||||
cityId: payload.city ?? currentCityId,
|
||||
troopId: 0,
|
||||
stats: {
|
||||
leadership: payload.leadership,
|
||||
strength: payload.strength,
|
||||
intelligence: payload.intel,
|
||||
},
|
||||
experience: payload.experience,
|
||||
dedication: payload.dedication,
|
||||
officerLevel: payload.officer_level,
|
||||
role: {
|
||||
personality: payload.personal,
|
||||
specialDomestic: payload.special ?? null,
|
||||
specialWar: payload.special2,
|
||||
items: {
|
||||
horse: normalizeItemCode(payload.horse),
|
||||
weapon: normalizeItemCode(payload.weapon),
|
||||
book: normalizeItemCode(payload.book),
|
||||
item: normalizeItemCode(payload.item),
|
||||
},
|
||||
},
|
||||
injury: payload.injury,
|
||||
gold: payload.gold,
|
||||
rice: payload.rice,
|
||||
crew: payload.crew,
|
||||
crewTypeId: payload.crewtype,
|
||||
train: payload.train,
|
||||
atmos: payload.atmos,
|
||||
age: DEFAULT_GENERAL_AGE,
|
||||
npcState: 0,
|
||||
triggerState: {
|
||||
flags: {},
|
||||
counters: {},
|
||||
modifiers: {},
|
||||
meta: payload.inheritBuff ? { inheritBuff: JSON.stringify(payload.inheritBuff) } : {},
|
||||
},
|
||||
meta: {
|
||||
killturn: 24,
|
||||
explevel: payload.explevel,
|
||||
turnTime: payload.turntime,
|
||||
recentWar: payload.recent_war ?? '',
|
||||
dex1: payload.dex1,
|
||||
dex2: payload.dex2,
|
||||
dex3: payload.dex3,
|
||||
dex4: payload.dex4,
|
||||
dex5: payload.dex5,
|
||||
intel_exp: payload.intel_exp,
|
||||
strength_exp: payload.strength_exp,
|
||||
leadership_exp: payload.leadership_exp,
|
||||
defence_train: payload.defence_train,
|
||||
officerCity: payload.officer_city,
|
||||
officer_city: payload.officer_city,
|
||||
rank_warnum: payload.warnum,
|
||||
rank_killnum: payload.killnum,
|
||||
rank_killcrew: payload.killcrew,
|
||||
},
|
||||
});
|
||||
|
||||
const buildLogBuckets = (options: {
|
||||
logs: WarBattleOutcome['logs'];
|
||||
year: number;
|
||||
month: number;
|
||||
attackerId: number;
|
||||
attackerNationId: number;
|
||||
}): BattleSimLogBuckets => {
|
||||
const buckets = {
|
||||
generalHistoryLog: [] as string[],
|
||||
generalActionLog: [] as string[],
|
||||
generalBattleResultLog: [] as string[],
|
||||
generalBattleDetailLog: [] as string[],
|
||||
nationalHistoryLog: [] as string[],
|
||||
globalHistoryLog: [] as string[],
|
||||
globalActionLog: [] as string[],
|
||||
};
|
||||
|
||||
for (const entry of options.logs) {
|
||||
const format = entry.format ?? LogFormat.RAWTEXT;
|
||||
const text = formatLogText(entry.text, format, options.year, options.month);
|
||||
|
||||
if (entry.scope === LogScope.GENERAL && entry.generalId === options.attackerId) {
|
||||
switch (entry.category) {
|
||||
case LogCategory.HISTORY:
|
||||
buckets.generalHistoryLog.push(text);
|
||||
break;
|
||||
case LogCategory.ACTION:
|
||||
buckets.generalActionLog.push(text);
|
||||
break;
|
||||
case LogCategory.BATTLE_BRIEF:
|
||||
buckets.generalBattleResultLog.push(text);
|
||||
break;
|
||||
case LogCategory.BATTLE_DETAIL:
|
||||
buckets.generalBattleDetailLog.push(text);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
entry.scope === LogScope.NATION &&
|
||||
entry.nationId === options.attackerNationId &&
|
||||
entry.category === LogCategory.HISTORY
|
||||
) {
|
||||
buckets.nationalHistoryLog.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.scope === LogScope.SYSTEM) {
|
||||
if (entry.category === LogCategory.HISTORY) {
|
||||
buckets.globalHistoryLog.push(text);
|
||||
} else if (entry.category === LogCategory.SUMMARY) {
|
||||
buckets.globalActionLog.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
generalHistoryLog: convertLog(buckets.generalHistoryLog.join('<br>')),
|
||||
generalActionLog: convertLog(buckets.generalActionLog.join('<br>')),
|
||||
generalBattleResultLog: convertLog(buckets.generalBattleResultLog.join('<br>')),
|
||||
generalBattleDetailLog: convertLog(buckets.generalBattleDetailLog.join('<br>')),
|
||||
nationalHistoryLog: convertLog(buckets.nationalHistoryLog.join('<br>')),
|
||||
globalHistoryLog: convertLog(buckets.globalHistoryLog.join('<br>')),
|
||||
globalActionLog: convertLog(buckets.globalActionLog.join('<br>')),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveRandomSeed = (): string => crypto.randomUUID();
|
||||
|
||||
const resolveCityTrainAtmos = (year: number, startYear: number): number =>
|
||||
Math.min(110, Math.max(60, year - startYear + 59));
|
||||
|
||||
const resolveCityRiceConsumption = (options: {
|
||||
battle: WarBattleOutcome;
|
||||
defenderNation: Nation;
|
||||
unitSet: UnitSetDefinition;
|
||||
castleCrewTypeId: number;
|
||||
year: number;
|
||||
startYear: number;
|
||||
}): number => {
|
||||
const cityReport = options.battle.reports.find((report: WarUnitReport) => report.type === 'city');
|
||||
if (!cityReport) {
|
||||
return 0;
|
||||
}
|
||||
if (cityReport.killed <= 0 && cityReport.dead <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const crewType = options.unitSet.crewTypes?.find(
|
||||
(item: CrewTypeDefinition) => item.id === options.castleCrewTypeId
|
||||
);
|
||||
const riceCoef = crewType?.rice ?? 1;
|
||||
const tech = Number(options.defenderNation.meta.tech ?? 0);
|
||||
const trainAtmos = resolveCityTrainAtmos(options.year, options.startYear);
|
||||
|
||||
let rice = (cityReport.killed / 100) * 0.8;
|
||||
rice *= riceCoef;
|
||||
rice *= getTechCost(tech);
|
||||
rice *= trainAtmos / 100 - 0.2;
|
||||
return Math.round(rice);
|
||||
};
|
||||
|
||||
const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] => {
|
||||
const attackerNation = mapNationPayload(payload.attackerNation);
|
||||
const defenderNation = mapNationPayload(payload.defenderNation);
|
||||
const attackerCity = mapCityPayload(payload.attackerCity);
|
||||
const defenderCity = mapCityPayload(payload.defenderCity);
|
||||
const attacker = mapGeneralPayload(payload.attackerGeneral, attackerCity.id);
|
||||
const defenders = payload.defenderGenerals.map((general) => mapGeneralPayload(general, defenderCity.id));
|
||||
const warActionModules = buildWarActionModules(payload.unitSet, payload.scenarioEffect);
|
||||
|
||||
return resolveDefenderOrder({
|
||||
unitSet: payload.unitSet,
|
||||
config: payload.config,
|
||||
time: payload.time,
|
||||
seed: 'order',
|
||||
attacker: {
|
||||
general: attacker,
|
||||
city: attackerCity,
|
||||
nation: attackerNation,
|
||||
modules: warActionModules,
|
||||
},
|
||||
defenders: defenders.map((general) => ({
|
||||
general,
|
||||
city: defenderCity,
|
||||
nation: defenderNation,
|
||||
modules: warActionModules,
|
||||
})),
|
||||
defenderCity,
|
||||
defenderNation,
|
||||
});
|
||||
};
|
||||
|
||||
export interface BattleSimProcessorOptions {
|
||||
trace?: (event: WarBattleTraceEvent) => void;
|
||||
rngFactory?: (seed: string) => RandUtil;
|
||||
}
|
||||
|
||||
export const processBattleSimJob = (
|
||||
payload: BattleSimJobPayload,
|
||||
options: BattleSimProcessorOptions = {}
|
||||
): BattleSimResultPayload => {
|
||||
if (payload.action === 'reorder') {
|
||||
return {
|
||||
result: true,
|
||||
reason: 'success',
|
||||
order: resolveDefenderOrderPayload(payload),
|
||||
};
|
||||
}
|
||||
|
||||
let repeatCnt = payload.repeatCnt;
|
||||
const warActionModules = buildWarActionModules(payload.unitSet, payload.scenarioEffect);
|
||||
const baseSeed = payload.seed ?? '';
|
||||
if (baseSeed) {
|
||||
repeatCnt = 1;
|
||||
}
|
||||
|
||||
let lastBattle: WarBattleOutcome | null = null;
|
||||
let attackerKilled = 0;
|
||||
let attackerDead = 0;
|
||||
let attackerMaxKilled = 0;
|
||||
let attackerMinKilled = Number.POSITIVE_INFINITY;
|
||||
let attackerMaxDead = 0;
|
||||
let attackerMinDead = Number.POSITIVE_INFINITY;
|
||||
let attackerAvgRice = 0;
|
||||
let defenderAvgRice = 0;
|
||||
let avgPhase = 0;
|
||||
let avgWar = 0;
|
||||
const attackerSkills: Record<string, number> = {};
|
||||
const defendersSkills: Array<Record<string, number>> = [];
|
||||
|
||||
const weight = 1 / Math.max(1, repeatCnt);
|
||||
|
||||
for (let idx = 0; idx < repeatCnt; idx += 1) {
|
||||
const seed = idx === 0 ? baseSeed || resolveRandomSeed() : resolveRandomSeed();
|
||||
const attackerNation = mapNationPayload(payload.attackerNation);
|
||||
const defenderNation = mapNationPayload(payload.defenderNation);
|
||||
const attackerCity = mapCityPayload(payload.attackerCity);
|
||||
const defenderCity = mapCityPayload(payload.defenderCity);
|
||||
const attackerGeneral = mapGeneralPayload(payload.attackerGeneral, attackerCity.id);
|
||||
const defenderGenerals = payload.defenderGenerals.map((general) =>
|
||||
mapGeneralPayload(general, defenderCity.id)
|
||||
);
|
||||
|
||||
const initialRice = new Map<number, number>();
|
||||
initialRice.set(attackerGeneral.id, attackerGeneral.rice);
|
||||
for (const defender of defenderGenerals) {
|
||||
initialRice.set(defender.id, defender.rice);
|
||||
}
|
||||
|
||||
const outcome = resolveWarBattle({
|
||||
seed,
|
||||
rng: options.rngFactory?.(seed),
|
||||
unitSet: payload.unitSet,
|
||||
config: payload.config,
|
||||
time: payload.time,
|
||||
attacker: {
|
||||
general: attackerGeneral,
|
||||
city: attackerCity,
|
||||
nation: attackerNation,
|
||||
modules: warActionModules,
|
||||
},
|
||||
defenders: defenderGenerals.map((general) => ({
|
||||
general,
|
||||
city: defenderCity,
|
||||
nation: defenderNation,
|
||||
modules: warActionModules,
|
||||
})),
|
||||
defenderCity,
|
||||
defenderNation,
|
||||
trace: options.trace,
|
||||
});
|
||||
|
||||
lastBattle = outcome;
|
||||
const attackerReport = outcome.reports.find(
|
||||
(report: WarUnitReport) => report.type === 'general' && report.isAttacker
|
||||
);
|
||||
const killed = attackerReport?.killed ?? 0;
|
||||
const dead = attackerReport?.dead ?? 0;
|
||||
|
||||
attackerKilled += killed * weight;
|
||||
attackerDead += dead * weight;
|
||||
attackerMaxKilled = Math.max(attackerMaxKilled, killed);
|
||||
attackerMinKilled = Math.min(attackerMinKilled, killed);
|
||||
attackerMaxDead = Math.max(attackerMaxDead, dead);
|
||||
attackerMinDead = Math.min(attackerMinDead, dead);
|
||||
|
||||
const phase = outcome.metrics?.attackerPhase ?? 0;
|
||||
avgPhase += phase * weight;
|
||||
const defenderCount = outcome.metrics?.defenderActivatedSkills.length ?? 0;
|
||||
avgWar += defenderCount * weight;
|
||||
|
||||
const attackerRiceInit = initialRice.get(attackerGeneral.id) ?? attackerGeneral.rice;
|
||||
attackerAvgRice += (attackerRiceInit - outcome.attacker.rice) * weight;
|
||||
|
||||
let defenderRiceInit = 0;
|
||||
let defenderRiceAfter = 0;
|
||||
for (const defender of outcome.defenders) {
|
||||
defenderRiceInit += initialRice.get(defender.id) ?? defender.rice;
|
||||
defenderRiceAfter += defender.rice;
|
||||
}
|
||||
|
||||
const cityRice = resolveCityRiceConsumption({
|
||||
battle: outcome,
|
||||
defenderNation,
|
||||
unitSet: payload.unitSet,
|
||||
castleCrewTypeId: payload.config.castleCrewTypeId,
|
||||
year: payload.time.year,
|
||||
startYear: payload.time.startYear,
|
||||
});
|
||||
defenderAvgRice += (defenderRiceInit - defenderRiceAfter + cityRice) * weight;
|
||||
|
||||
const attackerActivated = outcome.metrics?.attackerActivatedSkills ?? {};
|
||||
for (const [skillName, value] of Object.entries(attackerActivated) as [string, number][]) {
|
||||
attackerSkills[skillName] = (attackerSkills[skillName] ?? 0) + value * weight;
|
||||
}
|
||||
|
||||
const defenderActivated = outcome.metrics?.defenderActivatedSkills ?? [];
|
||||
for (let defIdx = 0; defIdx < defenderActivated.length; defIdx += 1) {
|
||||
while (defIdx >= defendersSkills.length) {
|
||||
defendersSkills.push({});
|
||||
}
|
||||
const bucket = defendersSkills[defIdx]!;
|
||||
for (const [skillName, value] of Object.entries(defenderActivated[defIdx]!) as [string, number][]) {
|
||||
bucket[skillName] = (bucket[skillName] ?? 0) + value * weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastBattle) {
|
||||
return {
|
||||
result: false,
|
||||
reason: '전투 결과를 생성하지 못했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const logBuckets = buildLogBuckets({
|
||||
logs: lastBattle.logs,
|
||||
year: payload.time.year,
|
||||
month: payload.time.month,
|
||||
attackerId: lastBattle.attacker.id,
|
||||
attackerNationId: payload.attackerNation.nation,
|
||||
});
|
||||
|
||||
return {
|
||||
result: true,
|
||||
reason: 'success',
|
||||
datetime: payload.attackerGeneral.turntime,
|
||||
lastWarLog: logBuckets,
|
||||
avgWar,
|
||||
phase: avgPhase,
|
||||
killed: attackerKilled,
|
||||
maxKilled: attackerMaxKilled,
|
||||
minKilled: attackerMinKilled === Number.POSITIVE_INFINITY ? 0 : attackerMinKilled,
|
||||
dead: attackerDead,
|
||||
maxDead: attackerMaxDead,
|
||||
minDead: attackerMinDead === Number.POSITIVE_INFINITY ? 0 : attackerMinDead,
|
||||
attackerRice: attackerAvgRice,
|
||||
defenderRice: defenderAvgRice,
|
||||
attackerSkills,
|
||||
defendersSkills,
|
||||
};
|
||||
};
|
||||
export { processBattleSimJob } from '@sammo-ts/logic';
|
||||
export type { BattleSimProcessorOptions } from '@sammo-ts/logic';
|
||||
|
||||
@@ -1,140 +1,15 @@
|
||||
import type { UnitSetDefinition } from '@sammo-ts/logic';
|
||||
import type { BattleSimJobPayload, BattleSimResultPayload } from '@sammo-ts/logic';
|
||||
|
||||
import type { WarEngineConfig, WarTimeContext } from '@sammo-ts/logic';
|
||||
|
||||
export type BattleSimAction = 'reorder' | 'battle';
|
||||
|
||||
export interface BattleSimGeneralPayload {
|
||||
no: number;
|
||||
name: string;
|
||||
nation: number;
|
||||
/** Current city. Older clients omit this; the surrounding city payload is authoritative then. */
|
||||
city?: number;
|
||||
turntime: string;
|
||||
personal: string | null;
|
||||
special?: string | null;
|
||||
special2: string | null;
|
||||
crew: number;
|
||||
crewtype: number;
|
||||
atmos: number;
|
||||
train: number;
|
||||
intel: number;
|
||||
intel_exp: number;
|
||||
book: string | null;
|
||||
strength: number;
|
||||
strength_exp: number;
|
||||
weapon: string | null;
|
||||
injury: number;
|
||||
leadership: number;
|
||||
leadership_exp: number;
|
||||
horse: string | null;
|
||||
item: string | null;
|
||||
explevel: number;
|
||||
experience: number;
|
||||
dedication: number;
|
||||
officer_level: number;
|
||||
officer_city: number;
|
||||
gold: number;
|
||||
rice: number;
|
||||
dex1: number;
|
||||
dex2: number;
|
||||
dex3: number;
|
||||
dex4: number;
|
||||
dex5: number;
|
||||
defence_train: number;
|
||||
recent_war: string | null;
|
||||
warnum: number;
|
||||
killnum: number;
|
||||
killcrew: number;
|
||||
inheritBuff?: Record<string, number> | number[];
|
||||
}
|
||||
|
||||
export interface BattleSimCityPayload {
|
||||
city: number;
|
||||
nation: number;
|
||||
supply: number;
|
||||
name: string;
|
||||
pop: number;
|
||||
agri: number;
|
||||
comm: number;
|
||||
secu: number;
|
||||
def: number;
|
||||
wall: number;
|
||||
trust: number;
|
||||
level: number;
|
||||
pop_max: number;
|
||||
agri_max: number;
|
||||
comm_max: number;
|
||||
secu_max: number;
|
||||
def_max: number;
|
||||
wall_max: number;
|
||||
dead: number;
|
||||
state: number;
|
||||
conflict: string;
|
||||
}
|
||||
|
||||
export interface BattleSimNationPayload {
|
||||
type: string;
|
||||
tech: number;
|
||||
level: number;
|
||||
capital: number;
|
||||
nation: number;
|
||||
name: string;
|
||||
gold: number;
|
||||
rice: number;
|
||||
gennum: number;
|
||||
}
|
||||
|
||||
export interface BattleSimRequestPayload {
|
||||
action: BattleSimAction;
|
||||
seed?: string;
|
||||
repeatCnt: number;
|
||||
year: number;
|
||||
month: number;
|
||||
attackerGeneral: BattleSimGeneralPayload;
|
||||
attackerCity: BattleSimCityPayload;
|
||||
attackerNation: BattleSimNationPayload;
|
||||
defenderGenerals: BattleSimGeneralPayload[];
|
||||
defenderCity: BattleSimCityPayload;
|
||||
defenderNation: BattleSimNationPayload;
|
||||
}
|
||||
|
||||
export interface BattleSimJobPayload extends BattleSimRequestPayload {
|
||||
unitSet: UnitSetDefinition;
|
||||
config: WarEngineConfig;
|
||||
time: WarTimeContext;
|
||||
scenarioEffect?: string | null;
|
||||
}
|
||||
|
||||
export interface BattleSimLogBuckets {
|
||||
generalHistoryLog: string;
|
||||
generalActionLog: string;
|
||||
generalBattleResultLog: string;
|
||||
generalBattleDetailLog: string;
|
||||
nationalHistoryLog: string;
|
||||
globalHistoryLog: string;
|
||||
globalActionLog: string;
|
||||
}
|
||||
|
||||
export interface BattleSimResultPayload {
|
||||
result: boolean;
|
||||
reason: string;
|
||||
datetime?: string;
|
||||
lastWarLog?: BattleSimLogBuckets;
|
||||
avgWar?: number;
|
||||
phase?: number;
|
||||
killed?: number;
|
||||
maxKilled?: number;
|
||||
minKilled?: number;
|
||||
dead?: number;
|
||||
maxDead?: number;
|
||||
minDead?: number;
|
||||
attackerRice?: number;
|
||||
defenderRice?: number;
|
||||
attackerSkills?: Record<string, number>;
|
||||
defendersSkills?: Array<Record<string, number>>;
|
||||
order?: number[];
|
||||
}
|
||||
export type {
|
||||
BattleSimAction,
|
||||
BattleSimCityPayload,
|
||||
BattleSimGeneralPayload,
|
||||
BattleSimJobPayload,
|
||||
BattleSimLogBuckets,
|
||||
BattleSimNationPayload,
|
||||
BattleSimRequestPayload,
|
||||
BattleSimResultPayload,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
export interface BattleSimJob {
|
||||
jobId: string;
|
||||
|
||||
@@ -62,6 +62,17 @@ const resolveDexValue = (meta: Record<string, unknown>, key: string): number =>
|
||||
};
|
||||
|
||||
export const battleRouter = router({
|
||||
prepareSimulation: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(async ({ ctx, input }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
|
||||
return buildBattleSimJobPayload(worldState, input, ctx.profile.id);
|
||||
}),
|
||||
simulate: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(async ({ ctx, input }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
@@ -176,122 +187,120 @@ export const battleRouter = router({
|
||||
};
|
||||
}),
|
||||
getGeneralDetail: accessAuthedInputProcedure(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
|
||||
const me = await getMyGeneral(ctx);
|
||||
const general = await ctx.db.general.findUnique({
|
||||
where: { id: input.generalId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
npcState: true,
|
||||
nationId: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
officerLevel: true,
|
||||
injury: true,
|
||||
rice: true,
|
||||
crew: true,
|
||||
crewTypeId: true,
|
||||
atmos: true,
|
||||
train: true,
|
||||
experience: true,
|
||||
horseCode: true,
|
||||
weaponCode: true,
|
||||
bookCode: true,
|
||||
itemCode: true,
|
||||
personalCode: true,
|
||||
specialCode: true,
|
||||
special2Code: true,
|
||||
meta: true,
|
||||
},
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
})
|
||||
).query(async ({ ctx, input }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!general) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found.',
|
||||
});
|
||||
}
|
||||
const me = await getMyGeneral(ctx);
|
||||
const general = await ctx.db.general.findUnique({
|
||||
where: { id: input.generalId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
npcState: true,
|
||||
nationId: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
officerLevel: true,
|
||||
injury: true,
|
||||
rice: true,
|
||||
crew: true,
|
||||
crewTypeId: true,
|
||||
atmos: true,
|
||||
train: true,
|
||||
experience: true,
|
||||
horseCode: true,
|
||||
weaponCode: true,
|
||||
bookCode: true,
|
||||
itemCode: true,
|
||||
personalCode: true,
|
||||
specialCode: true,
|
||||
special2Code: true,
|
||||
meta: true,
|
||||
},
|
||||
});
|
||||
|
||||
const environment = await buildBattleSimEnvironment(worldState, ctx.profile.id);
|
||||
const defaultCrewTypeId =
|
||||
environment.unitSet.defaultCrewTypeId ?? environment.unitSet.crewTypes?.[0]?.id ?? 0;
|
||||
if (!general) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found.',
|
||||
});
|
||||
}
|
||||
|
||||
const meta = asRecord(general.meta);
|
||||
const isSameNation = me.nationId > 0 && me.nationId === general.nationId;
|
||||
const environment = await buildBattleSimEnvironment(worldState, ctx.profile.id);
|
||||
const defaultCrewTypeId = environment.unitSet.defaultCrewTypeId ?? environment.unitSet.crewTypes?.[0]?.id ?? 0;
|
||||
|
||||
const base = {
|
||||
no: general.id,
|
||||
name: general.name,
|
||||
officer_level: general.officerLevel,
|
||||
explevel: resolveExpLevel(meta, general.experience),
|
||||
leadership: general.leadership,
|
||||
horse: normalizeOptionalKey(general.horseCode),
|
||||
strength: general.strength,
|
||||
weapon: normalizeOptionalKey(general.weaponCode),
|
||||
intel: general.intel,
|
||||
book: normalizeOptionalKey(general.bookCode),
|
||||
item: normalizeOptionalKey(general.itemCode),
|
||||
injury: general.injury,
|
||||
rice: general.rice,
|
||||
personal: normalizeOptionalKey(general.personalCode),
|
||||
special: normalizeOptionalKey(general.specialCode),
|
||||
special2: normalizeOptionalKey(general.special2Code),
|
||||
crew: general.crew,
|
||||
crewtype: general.crewTypeId,
|
||||
atmos: general.atmos,
|
||||
train: general.train,
|
||||
dex1: resolveDexValue(meta, 'dex1'),
|
||||
dex2: resolveDexValue(meta, 'dex2'),
|
||||
dex3: resolveDexValue(meta, 'dex3'),
|
||||
dex4: resolveDexValue(meta, 'dex4'),
|
||||
dex5: resolveDexValue(meta, 'dex5'),
|
||||
defence_train: readNumber(meta.defenceTrain, 80),
|
||||
warnum: readNumber(meta.rank_warnum, 0),
|
||||
killnum: readNumber(meta.rank_killnum, 0),
|
||||
killcrew: readNumber(meta.rank_killcrew, 0),
|
||||
const meta = asRecord(general.meta);
|
||||
const isSameNation = me.nationId > 0 && me.nationId === general.nationId;
|
||||
|
||||
const base = {
|
||||
no: general.id,
|
||||
name: general.name,
|
||||
officer_level: general.officerLevel,
|
||||
explevel: resolveExpLevel(meta, general.experience),
|
||||
leadership: general.leadership,
|
||||
horse: normalizeOptionalKey(general.horseCode),
|
||||
strength: general.strength,
|
||||
weapon: normalizeOptionalKey(general.weaponCode),
|
||||
intel: general.intel,
|
||||
book: normalizeOptionalKey(general.bookCode),
|
||||
item: normalizeOptionalKey(general.itemCode),
|
||||
injury: general.injury,
|
||||
rice: general.rice,
|
||||
personal: normalizeOptionalKey(general.personalCode),
|
||||
special: normalizeOptionalKey(general.specialCode),
|
||||
special2: normalizeOptionalKey(general.special2Code),
|
||||
crew: general.crew,
|
||||
crewtype: general.crewTypeId,
|
||||
atmos: general.atmos,
|
||||
train: general.train,
|
||||
dex1: resolveDexValue(meta, 'dex1'),
|
||||
dex2: resolveDexValue(meta, 'dex2'),
|
||||
dex3: resolveDexValue(meta, 'dex3'),
|
||||
dex4: resolveDexValue(meta, 'dex4'),
|
||||
dex5: resolveDexValue(meta, 'dex5'),
|
||||
defence_train: readNumber(meta.defenceTrain, 80),
|
||||
warnum: readNumber(meta.rank_warnum, 0),
|
||||
killnum: readNumber(meta.rank_killnum, 0),
|
||||
killcrew: readNumber(meta.rank_killcrew, 0),
|
||||
};
|
||||
|
||||
if (!isSameNation) {
|
||||
return {
|
||||
general: {
|
||||
...base,
|
||||
officer_level: 1,
|
||||
horse: null,
|
||||
weapon: null,
|
||||
book: null,
|
||||
item: null,
|
||||
crew: 0,
|
||||
crewtype: defaultCrewTypeId,
|
||||
rice: 10000,
|
||||
train: environment.config.maxTrainByCommand,
|
||||
atmos: environment.config.maxAtmosByCommand,
|
||||
dex1: 0,
|
||||
dex2: 0,
|
||||
dex3: 0,
|
||||
dex4: 0,
|
||||
dex5: 0,
|
||||
defence_train: 80,
|
||||
warnum: 0,
|
||||
killnum: 0,
|
||||
killcrew: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!isSameNation) {
|
||||
return {
|
||||
general: {
|
||||
...base,
|
||||
officer_level: 1,
|
||||
horse: null,
|
||||
weapon: null,
|
||||
book: null,
|
||||
item: null,
|
||||
crew: 0,
|
||||
crewtype: defaultCrewTypeId,
|
||||
rice: 10000,
|
||||
train: environment.config.maxTrainByCommand,
|
||||
atmos: environment.config.maxAtmosByCommand,
|
||||
dex1: 0,
|
||||
dex2: 0,
|
||||
dex3: 0,
|
||||
dex4: 0,
|
||||
dex5: 0,
|
||||
defence_train: 80,
|
||||
warnum: 0,
|
||||
killnum: 0,
|
||||
killcrew: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { general: base };
|
||||
}),
|
||||
return { general: base };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ export const generalAccessEndpointWeights = {
|
||||
'npc.setNationPolicy': 0,
|
||||
'npc.setNationPriority': 0,
|
||||
'npc.setGeneralPriority': 0,
|
||||
'battle.prepareSimulation': 0,
|
||||
'battle.simulate': 0,
|
||||
} as const satisfies Record<string, 0 | 1 | 2>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user