feat(battle-sim): implement battle simulation logic and metrics tracking

- Added WarBattleMetrics interface to track attacker and defender skills during battles.
- Enhanced WarUnit class with method to log activated skills.
- Introduced environment setup for battle simulation, including unit set loading and configuration resolution.
- Developed in-memory and Redis transport layers for handling battle simulation requests and results.
- Created types and interfaces for battle simulation payloads and responses.
- Implemented battle simulation processing logic, including logging and skill tracking.
- Added tests for battle simulation processor to ensure functionality and correctness.
This commit is contained in:
2025-12-30 11:17:28 +00:00
parent 97439f64a9
commit 56e662a7db
20 changed files with 1549 additions and 7 deletions
+151
View File
@@ -0,0 +1,151 @@
import type { WorldStateRow } from '../context.js';
import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.js';
import { loadUnitSetDefinitionByName } from './unitSetLoader.js';
import type { WarEngineConfig } from '@sammo-ts/logic';
const DEFAULT_WAR_CONFIG = {
armPerPhase: 500,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
maxTrainByWar: 110,
maxAtmosByWar: 150,
};
const asRecord = (value: unknown): Record<string, unknown> => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
};
const resolveNumber = (
record: Record<string, unknown>,
keys: string[],
fallback: number
): number => {
for (const key of keys) {
const value = record[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
}
return fallback;
};
const resolveUnitSetName = (
worldState: WorldStateRow,
fallback: string
): string => {
const config = asRecord(worldState.config);
const environment = asRecord(config.environment ?? config.map);
const unitSet = environment.unitSet;
if (typeof unitSet === 'string' && unitSet.trim().length > 0) {
return unitSet;
}
return fallback;
};
const resolveStartYear = (worldState: WorldStateRow): number => {
const meta = asRecord(worldState.meta);
const scenarioMeta = asRecord(meta.scenarioMeta);
const startYear = scenarioMeta.startYear;
if (typeof startYear === 'number' && Number.isFinite(startYear)) {
return startYear;
}
return 0;
};
const resolveCastleCrewTypeId = (unitSet: {
crewTypes?: Array<{ id: number; name: string; requirements: Array<{ type: string }> }>;
defaultCrewTypeId?: number;
}): number => {
const crewTypes = unitSet.crewTypes ?? [];
const byName = crewTypes.find((crewType) => crewType.name.includes('성벽'));
if (byName) {
return byName.id;
}
const byRequirement = crewTypes.find((crewType) =>
crewType.requirements.some((requirement) => requirement.type === 'Impossible')
);
if (byRequirement) {
return byRequirement.id;
}
if (typeof unitSet.defaultCrewTypeId === 'number') {
return unitSet.defaultCrewTypeId;
}
return crewTypes[0]?.id ?? 0;
};
const resolveCastleArmType = (unitSet: {
crewTypes?: Array<{ id: number; armType: number }>;
}, castleCrewTypeId: number): number => {
const crewTypes = unitSet.crewTypes ?? [];
return crewTypes.find((crewType) => crewType.id === castleCrewTypeId)?.armType ?? 0;
};
export const buildBattleSimJobPayload = async (
worldState: WorldStateRow,
request: BattleSimRequestPayload,
profileFallback: string
): Promise<BattleSimJobPayload> => {
const unitSetName = resolveUnitSetName(worldState, profileFallback);
const unitSet = await loadUnitSetDefinitionByName(unitSetName);
const configRecord = asRecord(worldState.config);
const constValues = asRecord(configRecord.const ?? configRecord.consts);
const castleCrewTypeId = resolveNumber(
constValues,
['castleCrewTypeId'],
resolveCastleCrewTypeId(unitSet)
);
const castleArmType = resolveCastleArmType(unitSet, castleCrewTypeId);
const config: WarEngineConfig = {
armPerPhase: resolveNumber(
constValues,
['armPerPhase', 'armperphase'],
DEFAULT_WAR_CONFIG.armPerPhase
),
maxTrainByCommand: resolveNumber(
constValues,
['maxTrainByCommand'],
DEFAULT_WAR_CONFIG.maxTrainByCommand
),
maxAtmosByCommand: resolveNumber(
constValues,
['maxAtmosByCommand'],
DEFAULT_WAR_CONFIG.maxAtmosByCommand
),
maxTrainByWar: resolveNumber(
constValues,
['maxTrainByWar'],
DEFAULT_WAR_CONFIG.maxTrainByWar
),
maxAtmosByWar: resolveNumber(
constValues,
['maxAtmosByWar'],
DEFAULT_WAR_CONFIG.maxAtmosByWar
),
castleCrewTypeId,
armTypes: {
footman: 1,
archer: 2,
cavalry: 3,
wizard: 4,
siege: 5,
misc: 6,
castle: castleArmType,
},
};
return {
...request,
unitSet,
config,
time: {
year: request.year,
month: request.month,
startYear: resolveStartYear(worldState),
},
};
};
@@ -0,0 +1,19 @@
import crypto from 'node:crypto';
import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js';
import { processBattleSimJob } from './processor.js';
export class InMemoryBattleSimTransport {
private readonly results = new Map<string, BattleSimResultPayload>();
public async simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse> {
const jobId = crypto.randomUUID();
const result = processBattleSimJob(payload);
this.results.set(jobId, result);
return { status: 'completed', jobId, payload: result };
}
public async getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null> {
return this.results.get(jobId) ?? null;
}
}
+13
View File
@@ -0,0 +1,13 @@
export interface BattleSimQueueKeys {
queueKey: string;
resultKeyPrefix: string;
notifyKeyPrefix: string;
}
export const buildBattleSimQueueKeys = (
profileName: string
): BattleSimQueueKeys => ({
queueKey: `sammo:${profileName}:battle-sim:queue`,
resultKeyPrefix: `sammo:${profileName}:battle-sim:result:`,
notifyKeyPrefix: `sammo:${profileName}:battle-sim:notify:`,
});
@@ -0,0 +1,39 @@
export const convertLog = (value: string, type = 1): string => {
if (!value) {
return '';
}
let result = value;
if (type > 0) {
result = result.replaceAll('<1>', '<font size=1>');
result = result.replaceAll('<Y1>', '<font size=1 color=yellow>');
result = result.replaceAll('<R>', '<font color=red>');
result = result.replaceAll('<B>', '<font color=blue>');
result = result.replaceAll('<G>', '<font color=green>');
result = result.replaceAll('<M>', '<font color=magenta>');
result = result.replaceAll('<C>', '<font color=cyan>');
result = result.replaceAll('<L>', '<font color=limegreen>');
result = result.replaceAll('<S>', '<font color=skyblue>');
result = result.replaceAll('<O>', '<font color=orangered>');
result = result.replaceAll('<D>', '<font color=orangered>');
result = result.replaceAll('<Y>', '<font color=yellow>');
result = result.replaceAll('<W>', '<font color=white>');
result = result.replaceAll('</>', '</font>');
return result;
}
result = result.replaceAll('<1>', '');
result = result.replaceAll('<Y1>', '');
result = result.replaceAll('<R>', '');
result = result.replaceAll('<B>', '');
result = result.replaceAll('<G>', '');
result = result.replaceAll('<M>', '');
result = result.replaceAll('<C>', '');
result = result.replaceAll('<L>', '');
result = result.replaceAll('<S>', '');
result = result.replaceAll('<O>', '');
result = result.replaceAll('<D>', '');
result = result.replaceAll('<Y>', '');
result = result.replaceAll('<W>', '');
result = result.replaceAll('</>', '');
return result;
};
+426
View File
@@ -0,0 +1,426 @@
import crypto from 'node:crypto';
import {
formatLogText,
getTechCost,
LogCategory,
LogFormat,
LogScope,
resolveDefenderOrder,
resolveWarBattle,
type City,
type General,
type Nation,
type UnitSetDefinition,
type WarBattleOutcome,
} 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 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,
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']
): General => ({
id: payload.no,
name: payload.name,
nationId: payload.nation,
cityId: payload.officer_city,
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: 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: {
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,
intelExp: payload.intel_exp,
strengthExp: payload.strength_exp,
leadershipExp: payload.leadership_exp,
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) => report.type === 'city'
);
if (!cityReport) {
return 0;
}
if (cityReport.killed <= 0 && cityReport.dead <= 0) {
return 0;
}
const crewType = options.unitSet.crewTypes?.find(
(item) => 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);
const defenders = payload.defenderGenerals.map(mapGeneralPayload);
return resolveDefenderOrder({
unitSet: payload.unitSet,
config: payload.config,
time: payload.time,
seed: 'order',
attacker: {
general: attacker,
city: attackerCity,
nation: attackerNation,
},
defenders: defenders.map((general) => ({
general,
city: defenderCity,
nation: defenderNation,
})),
defenderCity,
defenderNation,
});
};
export const processBattleSimJob = (payload: BattleSimJobPayload): BattleSimResultPayload => {
if (payload.action === 'reorder') {
return {
result: true,
reason: 'success',
order: resolveDefenderOrderPayload(payload),
};
}
let repeatCnt = payload.repeatCnt;
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);
const defenderGenerals = payload.defenderGenerals.map(mapGeneralPayload);
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,
unitSet: payload.unitSet,
config: payload.config,
time: payload.time,
attacker: {
general: attackerGeneral,
city: attackerCity,
nation: attackerNation,
},
defenders: defenderGenerals.map((general) => ({
general,
city: defenderCity,
nation: defenderNation,
})),
defenderCity,
defenderNation,
});
lastBattle = outcome;
const attackerReport = outcome.reports.find(
(report) => 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)) {
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]!)) {
bucket[skillName] = (bucket[skillName] ?? 0) + value * weight;
}
}
}
if (!lastBattle) {
return {
result: false,
reason: '전투 결과를 생성하지 못했습니다.',
};
}
const logBuckets = buildLogBuckets({
logs: lastBattle.logs,
year: payload.time.year,
month: payload.time.month,
attackerId: lastBattle.attacker.id,
attackerNationId: payload.attackerNation.nation,
});
return {
result: true,
reason: 'success',
datetime: payload.attackerGeneral.turntime,
lastWarLog: logBuckets,
avgWar,
phase: avgPhase,
killed: attackerKilled,
maxKilled: attackerMaxKilled,
minKilled: attackerMinKilled === Number.POSITIVE_INFINITY ? 0 : attackerMinKilled,
dead: attackerDead,
maxDead: attackerMaxDead,
minDead: attackerMinDead === Number.POSITIVE_INFINITY ? 0 : attackerMinDead,
attackerRice: attackerAvgRice,
defenderRice: defenderAvgRice,
attackerSkills,
defendersSkills,
};
};
@@ -0,0 +1,115 @@
import crypto from 'node:crypto';
import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js';
import type { BattleSimQueueKeys } from './keys.js';
type RedisBlPopResult =
| { key: string; element: string }
| [string, string]
| null;
interface RedisClientLike {
rPush(key: string, value: string): Promise<number>;
blPop(key: string, timeout: number): Promise<RedisBlPopResult>;
set(key: string, value: string, options?: { EX?: number }): Promise<string | null>;
get(key: string): Promise<string | null>;
expire(key: string, seconds: number): Promise<number>;
}
export interface RedisBattleSimTransportOptions {
keys: BattleSimQueueKeys;
requestTimeoutMs: number;
resultTtlSeconds: number;
}
const toTimeoutSeconds = (timeoutMs: number): number =>
Math.max(1, Math.ceil(timeoutMs / 1000));
const parseBlPopValue = (result: RedisBlPopResult): string | null => {
if (!result) {
return null;
}
if (Array.isArray(result)) {
return result[1] ?? null;
}
return result.element ?? null;
};
export class RedisBattleSimTransport {
private readonly client: RedisClientLike;
private readonly keys: BattleSimQueueKeys;
private readonly requestTimeoutMs: number;
private readonly resultTtlSeconds: number;
constructor(client: RedisClientLike, options: RedisBattleSimTransportOptions) {
this.client = client;
this.keys = options.keys;
this.requestTimeoutMs = options.requestTimeoutMs;
this.resultTtlSeconds = options.resultTtlSeconds;
}
private buildResultKey(jobId: string): string {
return `${this.keys.resultKeyPrefix}${jobId}`;
}
private buildNotifyKey(jobId: string): string {
return `${this.keys.notifyKeyPrefix}${jobId}`;
}
private async readResult(jobId: string): Promise<BattleSimResultPayload | null> {
const raw = await this.client.get(this.buildResultKey(jobId));
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as BattleSimResultPayload;
} catch {
return null;
}
}
private async waitForResult(jobId: string, timeoutMs: number): Promise<BattleSimResultPayload | null> {
const existing = await this.readResult(jobId);
if (existing) {
return existing;
}
const notifyKey = this.buildNotifyKey(jobId);
const timeoutSec = toTimeoutSeconds(timeoutMs);
const signal = await this.client.blPop(notifyKey, timeoutSec);
if (!parseBlPopValue(signal)) {
return null;
}
return this.readResult(jobId);
}
public async simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse> {
const jobId = crypto.randomUUID();
const job = {
jobId,
requestedAt: new Date().toISOString(),
payload,
};
await this.client.rPush(this.keys.queueKey, JSON.stringify(job));
const result = await this.waitForResult(jobId, this.requestTimeoutMs);
if (result) {
return { status: 'completed', jobId, payload: result };
}
return { status: 'queued', jobId };
}
public async getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null> {
return this.readResult(jobId);
}
public async pushResult(jobId: string, payload: BattleSimResultPayload): Promise<void> {
const resultKey = this.buildResultKey(jobId);
const notifyKey = this.buildNotifyKey(jobId);
await this.client.set(resultKey, JSON.stringify(payload), {
EX: this.resultTtlSeconds,
});
await this.client.rPush(notifyKey, 'ready');
await this.client.expire(notifyKey, this.resultTtlSeconds);
}
}
+6
View File
@@ -0,0 +1,6 @@
import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js';
export interface BattleSimTransport {
simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse>;
getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null>;
}
+151
View File
@@ -0,0 +1,151 @@
import type { UnitSetDefinition } 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;
turntime: string;
personal: 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;
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;
}
export interface BattleSimLogBuckets {
generalHistoryLog: string;
generalActionLog: string;
generalBattleResultLog: string;
generalBattleDetailLog: string;
nationalHistoryLog: string;
globalHistoryLog: string;
globalActionLog: string;
}
export interface BattleSimResultPayload {
result: boolean;
reason: string;
datetime?: string;
lastWarLog?: BattleSimLogBuckets;
avgWar?: number;
phase?: number;
killed?: number;
maxKilled?: number;
minKilled?: number;
dead?: number;
maxDead?: number;
minDead?: number;
attackerRice?: number;
defenderRice?: number;
attackerSkills?: Record<string, number>;
defendersSkills?: Array<Record<string, number>>;
order?: number[];
}
export interface BattleSimJob {
jobId: string;
requestedAt: string;
payload: BattleSimJobPayload;
}
export interface BattleSimCompleted {
status: 'completed';
jobId: string;
payload: BattleSimResultPayload;
}
export interface BattleSimQueued {
status: 'queued';
jobId: string;
}
export type BattleSimTransportResponse = BattleSimCompleted | BattleSimQueued;
@@ -0,0 +1,53 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseUnitSetDefinition, type UnitSetDefinition } from '@sammo-ts/logic';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DEFAULT_UNIT_SET_ROOT = path.resolve(
__dirname,
'..',
'..',
'..',
'game-engine',
'resources',
'unitset'
);
export interface UnitSetLoaderOptions {
unitSetRoot?: string;
filePrefix?: string;
}
const readJsonFile = async (filePath: string): Promise<unknown> => {
const raw = await fs.readFile(filePath, 'utf8');
return JSON.parse(raw) as unknown;
};
const resolveUnitSetRoot = (options?: UnitSetLoaderOptions): string =>
options?.unitSetRoot ?? DEFAULT_UNIT_SET_ROOT;
export const resolveUnitSetDefinitionPath = (
unitSetName: string,
options?: UnitSetLoaderOptions
): string => {
const prefix = options?.filePrefix ?? 'unitset_';
return path.resolve(resolveUnitSetRoot(options), `${prefix}${unitSetName}.json`);
};
export const loadUnitSetDefinition = async (
unitSetPath: string
): Promise<UnitSetDefinition> => {
const raw = await readJsonFile(unitSetPath);
return parseUnitSetDefinition(raw);
};
export const loadUnitSetDefinitionByName = async (
unitSetName: string,
options?: UnitSetLoaderOptions
): Promise<UnitSetDefinition> => {
const unitSetPath = resolveUnitSetDefinitionPath(unitSetName, options);
return loadUnitSetDefinition(unitSetPath);
};
+68
View File
@@ -0,0 +1,68 @@
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
import { resolveGameApiConfigFromEnv } from '../config.js';
import { buildBattleSimQueueKeys } from './keys.js';
import { processBattleSimJob } from './processor.js';
import { RedisBattleSimTransport } from './redisTransport.js';
import type { BattleSimJob } from './types.js';
type RedisBlPopResult =
| { key: string; element: string }
| [string, string]
| null;
const parseBlPopValue = (result: RedisBlPopResult): string | null => {
if (!result) {
return null;
}
if (Array.isArray(result)) {
return result[1] ?? null;
}
return result.element ?? null;
};
export const runBattleSimWorker = async (): Promise<void> => {
const config = resolveGameApiConfigFromEnv();
const redis = createRedisConnector(resolveRedisConfigFromEnv());
await redis.connect();
const keys = buildBattleSimQueueKeys(config.profileName);
const transport = new RedisBattleSimTransport(redis.client, {
keys,
requestTimeoutMs: config.battleSimRequestTimeoutMs,
resultTtlSeconds: config.battleSimResultTtlSeconds,
});
const handleExit = async () => {
await redis.disconnect();
};
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
while (true) {
const item = await redis.client.blPop(keys.queueKey, 0);
const raw = parseBlPopValue(item);
if (!raw) {
continue;
}
let job: BattleSimJob | null = null;
try {
job = JSON.parse(raw) as BattleSimJob;
} catch {
continue;
}
try {
const result = processBattleSimJob(job.payload);
await transport.pushResult(job.jobId, result);
} catch (error) {
const reason =
error instanceof Error ? error.message : '전투 시뮬레이션 오류';
await transport.pushResult(job.jobId, {
result: false,
reason,
});
}
}
};
+12
View File
@@ -6,6 +6,8 @@ export interface GameApiConfig {
scenario: string;
profileName: string;
daemonRequestTimeoutMs: number;
battleSimRequestTimeoutMs: number;
battleSimResultTtlSeconds: number;
gameTokenSecret: string;
flushChannel: string;
}
@@ -45,6 +47,16 @@ export const resolveGameApiConfigFromEnv = (
5000,
'DAEMON_REQUEST_TIMEOUT_MS'
),
battleSimRequestTimeoutMs: parseNumber(
env.BATTLE_SIM_REQUEST_TIMEOUT_MS,
8000,
'BATTLE_SIM_REQUEST_TIMEOUT_MS'
),
battleSimResultTtlSeconds: parseNumber(
env.BATTLE_SIM_RESULT_TTL_SECONDS,
60,
'BATTLE_SIM_RESULT_TTL_SECONDS'
),
gameTokenSecret: secret,
flushChannel: `${gatewayPrefix}:flush`,
};
+4
View File
@@ -2,6 +2,7 @@ import type { GameSessionTokenPayload } from '@sammo-ts/common';
import type { DatabaseClient as InfraDatabaseClient } from '@sammo-ts/infra';
import type { TurnDaemonTransport } from './daemon/transport.js';
import type { BattleSimTransport } from './battleSim/transport.js';
export interface GameProfile {
id: string;
@@ -117,6 +118,7 @@ export type DatabaseClient = InfraDatabaseClient<
export interface GameApiContext {
db: DatabaseClient;
turnDaemon: TurnDaemonTransport;
battleSim: BattleSimTransport;
profile: GameProfile;
auth: GameSessionTokenPayload | null;
}
@@ -124,12 +126,14 @@ export interface GameApiContext {
export const createGameApiContext = (options: {
db: DatabaseClient;
turnDaemon: TurnDaemonTransport;
battleSim: BattleSimTransport;
profile: GameProfile;
auth: GameSessionTokenPayload | null;
}): GameApiContext => {
return {
db: options.db,
turnDaemon: options.turnDaemon,
battleSim: options.battleSim,
profile: options.profile,
auth: options.auth,
};
+11 -1
View File
@@ -2,6 +2,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runGameApiServer } from './server.js';
import { runBattleSimWorker } from './battleSim/worker.js';
export * from './config.js';
export * from './context.js';
@@ -14,6 +15,12 @@ export * from './daemon/inMemoryTransport.js';
export * from './daemon/redisTransport.js';
export * from './auth/flushStore.js';
export * from './auth/tokenVerifier.js';
export * from './battleSim/types.js';
export * from './battleSim/transport.js';
export * from './battleSim/redisTransport.js';
export * from './battleSim/inMemoryTransport.js';
export * from './battleSim/keys.js';
export * from './battleSim/worker.js';
const isMain = (): boolean => {
if (!process.argv[1]) {
@@ -23,7 +30,10 @@ const isMain = (): boolean => {
};
if (isMain()) {
runGameApiServer().catch((error) => {
const role = process.env.GAME_API_ROLE ?? 'server';
const run =
role === 'battle-sim-worker' ? runBattleSimWorker : runGameApiServer;
run().catch((error) => {
console.error('[game-api] failed to start', error);
process.exitCode = 1;
});
+126
View File
@@ -27,6 +27,8 @@ import {
insertMessage,
type MessageView,
} from './messages/store.js';
import { buildBattleSimJobPayload } from './battleSim/environment.js';
import type { BattleSimRequestPayload } from './battleSim/types.js';
const zRunReason = z.enum(['schedule', 'manual', 'poke']);
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
@@ -37,6 +39,97 @@ const zTurnRunBudget = z.object({
catchUpCap: z.number().int().positive(),
});
const zBattleSimGeneral = z.object({
no: z.number().int().positive(),
name: z.string().min(1),
nation: z.number().int().positive(),
turntime: z.string().min(1),
personal: z.string().nullable(),
special2: z.string().nullable(),
crew: z.number().int().min(0),
crewtype: z.number().int().positive(),
atmos: z.number().int().min(0),
train: z.number().int().min(0),
intel: z.number().int().min(0),
intel_exp: z.number().int().min(0),
book: z.string().nullable(),
strength: z.number().int().min(0),
strength_exp: z.number().int().min(0),
weapon: z.string().nullable(),
injury: z.number().int().min(0),
leadership: z.number().int().min(0),
leadership_exp: z.number().int().min(0),
horse: z.string().nullable(),
item: z.string().nullable(),
explevel: z.number().int().min(0),
experience: z.number().int().min(0),
dedication: z.number().int().min(0),
officer_level: z.number().int().min(1),
officer_city: z.number().int().positive(),
gold: z.number().int().min(0),
rice: z.number().int().min(0),
dex1: z.number().int().min(0),
dex2: z.number().int().min(0),
dex3: z.number().int().min(0),
dex4: z.number().int().min(0),
dex5: z.number().int().min(0),
recent_war: z.string().nullable(),
warnum: z.number().int().min(0),
killnum: z.number().int().min(0),
killcrew: z.number().int().min(0),
inheritBuff: z.union([z.record(z.string(), z.number()), z.array(z.number())]).optional(),
});
const zBattleSimCity = z.object({
city: z.number().int().positive(),
nation: z.number().int().min(0),
supply: z.number().int().min(0),
name: z.string().min(1),
pop: z.number().min(0),
agri: z.number().min(0),
comm: z.number().min(0),
secu: z.number().min(0),
def: z.number().min(0),
wall: z.number().min(0),
trust: z.number().min(0),
level: z.number().int().min(1),
pop_max: z.number().min(0),
agri_max: z.number().min(0),
comm_max: z.number().min(0),
secu_max: z.number().min(0),
def_max: z.number().min(0),
wall_max: z.number().min(0),
dead: z.number().min(0),
state: z.number().int().min(0),
conflict: z.string(),
});
const zBattleSimNation = z.object({
type: z.string().min(1),
tech: z.number().min(0),
level: z.number().int().min(1),
capital: z.number().int().min(0),
nation: z.number().int().min(0),
name: z.string().min(1),
gold: z.number().min(0),
rice: z.number().min(0),
gennum: z.number().int().min(1),
});
const zBattleSimRequest: z.ZodType<BattleSimRequestPayload> = z.object({
action: z.enum(['reorder', 'battle']),
seed: z.string().optional(),
repeatCnt: z.number().int().min(1).max(1000),
year: z.number().int().min(0),
month: z.number().int().min(1).max(12),
attackerGeneral: zBattleSimGeneral,
attackerCity: zBattleSimCity,
attackerNation: zBattleSimNation,
defenderGenerals: z.array(zBattleSimGeneral),
defenderCity: zBattleSimCity,
defenderNation: zBattleSimNation,
});
const buildShiftAmountSchema = (maxTurns: number) =>
z.number()
.int()
@@ -64,6 +157,39 @@ export const appRouter = router({
now: new Date().toISOString(),
})),
}),
battle: router({
simulate: procedure
.input(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.',
});
}
const payload = await buildBattleSimJobPayload(
worldState,
input,
ctx.profile.id
);
return ctx.battleSim.simulate(payload);
}),
getSimulation: procedure
.input(
z.object({
jobId: z.string().min(1),
})
)
.query(async ({ ctx, input }) => {
const result = await ctx.battleSim.getSimulationResult(input.jobId);
if (!result) {
return { status: 'queued', jobId: input.jobId };
}
return { status: 'completed', jobId: input.jobId, payload: result };
}),
}),
world: router({
getState: procedure.query(async ({ ctx }) => {
const state = await ctx.db.worldState.findFirst();
+8
View File
@@ -15,6 +15,8 @@ import { RedisTurnDaemonTransport } from './daemon/redisTransport.js';
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from './auth/flushStore.js';
import { createGameTokenVerifier } from './auth/tokenVerifier.js';
import { appRouter } from './router.js';
import { buildBattleSimQueueKeys } from './battleSim/keys.js';
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
const extractBearerToken = (value: string | string[] | undefined): string | null => {
if (!value) {
@@ -43,6 +45,11 @@ export const createGameApiServer = async () => {
keys: buildTurnDaemonStreamKeys(config.profileName),
requestTimeoutMs: config.daemonRequestTimeoutMs,
});
const battleSim = new RedisBattleSimTransport(redis.client, {
keys: buildBattleSimQueueKeys(config.profileName),
requestTimeoutMs: config.battleSimRequestTimeoutMs,
resultTtlSeconds: config.battleSimResultTtlSeconds,
});
const flushStore = new InMemoryFlushStore();
const flushSubscriberClient = redis.client.duplicate();
await flushSubscriberClient.connect();
@@ -77,6 +84,7 @@ export const createGameApiServer = async () => {
return createGameApiContext({
db: postgres.prisma as unknown as DatabaseClient,
turnDaemon,
battleSim,
profile: {
id: config.profile,
scenario: config.scenario,
@@ -0,0 +1,245 @@
import { describe, expect, it } from 'vitest';
import type { BattleSimJobPayload } from '../src/battleSim/types.js';
import { processBattleSimJob } from '../src/battleSim/processor.js';
const buildPayload = (action: BattleSimJobPayload['action']): BattleSimJobPayload => ({
action,
repeatCnt: 1,
year: 200,
month: 1,
seed: 'test-seed',
attackerGeneral: {
no: 1,
name: 'Attacker',
nation: 1,
turntime: '2026-01-01 00:00:00',
personal: null,
special2: null,
crew: 1000,
crewtype: 100,
atmos: 100,
train: 100,
intel: 70,
intel_exp: 0,
book: null,
strength: 70,
strength_exp: 0,
weapon: null,
injury: 0,
leadership: 70,
leadership_exp: 0,
horse: null,
item: null,
explevel: 0,
experience: 100,
dedication: 100,
officer_level: 3,
officer_city: 1,
gold: 1000,
rice: 1000,
dex1: 0,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
recent_war: null,
warnum: 0,
killnum: 0,
killcrew: 0,
},
attackerCity: {
city: 1,
nation: 1,
supply: 1,
name: 'AttackerCity',
pop: 10000,
agri: 1000,
comm: 1000,
secu: 1000,
def: 100,
wall: 100,
trust: 100,
level: 2,
pop_max: 10000,
agri_max: 1000,
comm_max: 1000,
secu_max: 1000,
def_max: 200,
wall_max: 200,
dead: 0,
state: 0,
conflict: '{}',
},
attackerNation: {
type: 'test',
tech: 1000,
level: 1,
capital: 1,
nation: 1,
name: 'AttackerNation',
gold: 1000,
rice: 1000,
gennum: 1,
},
defenderGenerals: [
{
no: 2,
name: 'Defender',
nation: 2,
turntime: '2026-01-01 00:00:00',
personal: null,
special2: null,
crew: 1000,
crewtype: 100,
atmos: 100,
train: 100,
intel: 60,
intel_exp: 0,
book: null,
strength: 60,
strength_exp: 0,
weapon: null,
injury: 0,
leadership: 60,
leadership_exp: 0,
horse: null,
item: null,
explevel: 0,
experience: 100,
dedication: 100,
officer_level: 3,
officer_city: 2,
gold: 1000,
rice: 1000,
dex1: 0,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
recent_war: null,
warnum: 0,
killnum: 0,
killcrew: 0,
},
],
defenderCity: {
city: 2,
nation: 2,
supply: 1,
name: 'DefenderCity',
pop: 10000,
agri: 1000,
comm: 1000,
secu: 1000,
def: 100,
wall: 100,
trust: 100,
level: 2,
pop_max: 10000,
agri_max: 1000,
comm_max: 1000,
secu_max: 1000,
def_max: 200,
wall_max: 200,
dead: 0,
state: 0,
conflict: '{}',
},
defenderNation: {
type: 'test',
tech: 1000,
level: 1,
capital: 2,
nation: 2,
name: 'DefenderNation',
gold: 1000,
rice: 1000,
gennum: 1,
},
unitSet: {
id: 'test',
name: 'test',
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,
},
],
},
config: {
armPerPhase: 500,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
maxTrainByWar: 110,
maxAtmosByWar: 150,
castleCrewTypeId: 999,
armTypes: {
footman: 1,
wizard: 4,
siege: 5,
misc: 6,
castle: 9,
},
},
time: {
year: 200,
month: 1,
startYear: 180,
},
});
describe('battle sim processor', () => {
it('simulates battles and returns summary', () => {
const payload = buildPayload('battle');
const result = processBattleSimJob(payload);
expect(result.result).toBe(true);
expect(result.reason).toBe('success');
expect(result.lastWarLog).toBeTruthy();
expect(result.phase).toBeTypeOf('number');
expect(result.attackerRice).toBeTypeOf('number');
});
it('returns defender order for reorder action', () => {
const payload = buildPayload('reorder');
const result = processBattleSimJob(payload);
expect(result.result).toBe(true);
expect(result.order?.length).toBe(1);
});
});
+4
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { GameApiContext, GameProfile, WorldStateRow } from '../src/context.js';
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { appRouter } from '../src/router.js';
@@ -13,8 +14,10 @@ const profile: GameProfile = {
const buildContext = (options?: {
state?: WorldStateRow | null;
transport?: InMemoryTurnDaemonTransport;
battleSim?: InMemoryBattleSimTransport;
}): GameApiContext => {
const transport = options?.transport ?? new InMemoryTurnDaemonTransport();
const battleSim = options?.battleSim ?? new InMemoryBattleSimTransport();
const db = {
worldState: {
findFirst: async () => options?.state ?? null,
@@ -42,6 +45,7 @@ const buildContext = (options?: {
return {
db,
turnDaemon: transport,
battleSim,
profile,
auth: null,
};
+80 -6
View File
@@ -143,7 +143,7 @@ const isSupplyCity = (city: City): boolean => {
return city.supplyState > 0;
};
const extractBattleOrder = (
export const computeBattleOrder = (
defender: WarUnit,
attacker: WarUnitGeneral
): number => {
@@ -300,7 +300,7 @@ export const resolveWarBattle = <
defenderLogger,
createPipeline(defender)
);
if (extractBattleOrder(unit, attackerUnit) <= 0) {
if (computeBattleOrder(unit, attackerUnit) <= 0) {
continue;
}
defenderUnits.push(unit);
@@ -309,15 +309,15 @@ export const resolveWarBattle = <
if (
defenderGenerals.length > 0 &&
extractBattleOrder(cityUnit, attackerUnit) > 0
computeBattleOrder(cityUnit, attackerUnit) > 0
) {
defenderUnits.push(cityUnit);
}
defenderUnits.sort(
(lhs, rhs) =>
extractBattleOrder(rhs, attackerUnit) -
extractBattleOrder(lhs, attackerUnit)
computeBattleOrder(rhs, attackerUnit) -
computeBattleOrder(lhs, attackerUnit)
);
const iter = defenderUnits.values();
@@ -335,7 +335,7 @@ export const resolveWarBattle = <
return null;
}
const candidate = next.value as WarUnit<TriggerState>;
if (extractBattleOrder(candidate, attackerUnit) <= 0) {
if (computeBattleOrder(candidate, attackerUnit) <= 0) {
return null;
}
return candidate;
@@ -641,5 +641,79 @@ export const resolveWarBattle = <
logs,
conquered: conquerCity,
reports,
metrics: {
attackerPhase: attackerUnit.getPhase(),
attackerActivatedSkills: attackerUnit.getActivatedSkillLog(),
defenderActivatedSkills: defenderUnits.map((unit) =>
unit.getActivatedSkillLog()
),
},
};
};
export const resolveDefenderOrder = <
TriggerState extends GeneralTriggerState = GeneralTriggerState
>(
input: WarBattleInput<TriggerState>
): number[] => {
const rng =
input.rng ??
new RandUtil(
LiteHashDRBG.build(input.seed ?? '')
);
const loggerFactory = input.loggerFactory ?? defaultLoggerFactory;
const crewTypeIndex = buildWarCrewTypeIndex(input.unitSet);
const attackerPipeline = createPipeline(input.attacker);
const attackerLogger =
input.attacker.logger ??
loggerFactory({
generalId: input.attacker.general.id,
nationId: input.attacker.general.nationId,
});
const attackerUnit = new WarUnitGeneral(
rng,
input.config,
input.attacker.general,
input.attacker.city,
input.attacker.nation,
true,
resolveCrewType(crewTypeIndex, input.attacker.general.crewTypeId),
attackerLogger,
attackerPipeline
);
const defenderUnits: WarUnitGeneral<TriggerState>[] = [];
for (const defender of input.defenders) {
const defenderLogger =
defender.logger ??
loggerFactory({
generalId: defender.general.id,
nationId: defender.general.nationId,
});
const unit = new WarUnitGeneral(
rng,
input.config,
defender.general,
input.defenderCity,
defender.nation,
false,
resolveCrewType(crewTypeIndex, defender.general.crewTypeId),
defenderLogger,
createPipeline(defender)
);
if (computeBattleOrder(unit, attackerUnit) <= 0) {
continue;
}
defenderUnits.push(unit);
}
defenderUnits.sort(
(lhs, rhs) =>
computeBattleOrder(rhs, attackerUnit) -
computeBattleOrder(lhs, attackerUnit)
);
return defenderUnits.map((unit) => unit.getGeneral().id);
};
+7
View File
@@ -73,6 +73,12 @@ export interface WarUnitReport {
dead: number;
}
export interface WarBattleMetrics {
attackerPhase: number;
attackerActivatedSkills: Record<string, number>;
defenderActivatedSkills: Array<Record<string, number>>;
}
export interface WarBattleOutcome<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
@@ -82,6 +88,7 @@ export interface WarBattleOutcome<
logs: LogEntryDraft[];
conquered: boolean;
reports: WarUnitReport[];
metrics?: WarBattleMetrics;
}
export interface WarAftermathConfig {
+11
View File
@@ -237,6 +237,17 @@ export abstract class WarUnit<
(this.hasActivatedSkill(skillName) ? 1 : 0);
}
public getActivatedSkillLog(): Record<string, number> {
const snapshot: Record<string, number> = { ...this.logActivatedSkill };
for (const [skillName, active] of Object.entries(this.activatedSkill)) {
if (!active) {
continue;
}
snapshot[skillName] = (snapshot[skillName] ?? 0) + 1;
}
return snapshot;
}
public activateSkill(...skillNames: string[]): void {
for (const skillName of skillNames) {
this.activatedSkill[skillName] = true;