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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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`,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user