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;
|
scenario: string;
|
||||||
profileName: string;
|
profileName: string;
|
||||||
daemonRequestTimeoutMs: number;
|
daemonRequestTimeoutMs: number;
|
||||||
|
battleSimRequestTimeoutMs: number;
|
||||||
|
battleSimResultTtlSeconds: number;
|
||||||
gameTokenSecret: string;
|
gameTokenSecret: string;
|
||||||
flushChannel: string;
|
flushChannel: string;
|
||||||
}
|
}
|
||||||
@@ -45,6 +47,16 @@ export const resolveGameApiConfigFromEnv = (
|
|||||||
5000,
|
5000,
|
||||||
'DAEMON_REQUEST_TIMEOUT_MS'
|
'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,
|
gameTokenSecret: secret,
|
||||||
flushChannel: `${gatewayPrefix}:flush`,
|
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 { DatabaseClient as InfraDatabaseClient } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import type { TurnDaemonTransport } from './daemon/transport.js';
|
import type { TurnDaemonTransport } from './daemon/transport.js';
|
||||||
|
import type { BattleSimTransport } from './battleSim/transport.js';
|
||||||
|
|
||||||
export interface GameProfile {
|
export interface GameProfile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -117,6 +118,7 @@ export type DatabaseClient = InfraDatabaseClient<
|
|||||||
export interface GameApiContext {
|
export interface GameApiContext {
|
||||||
db: DatabaseClient;
|
db: DatabaseClient;
|
||||||
turnDaemon: TurnDaemonTransport;
|
turnDaemon: TurnDaemonTransport;
|
||||||
|
battleSim: BattleSimTransport;
|
||||||
profile: GameProfile;
|
profile: GameProfile;
|
||||||
auth: GameSessionTokenPayload | null;
|
auth: GameSessionTokenPayload | null;
|
||||||
}
|
}
|
||||||
@@ -124,12 +126,14 @@ export interface GameApiContext {
|
|||||||
export const createGameApiContext = (options: {
|
export const createGameApiContext = (options: {
|
||||||
db: DatabaseClient;
|
db: DatabaseClient;
|
||||||
turnDaemon: TurnDaemonTransport;
|
turnDaemon: TurnDaemonTransport;
|
||||||
|
battleSim: BattleSimTransport;
|
||||||
profile: GameProfile;
|
profile: GameProfile;
|
||||||
auth: GameSessionTokenPayload | null;
|
auth: GameSessionTokenPayload | null;
|
||||||
}): GameApiContext => {
|
}): GameApiContext => {
|
||||||
return {
|
return {
|
||||||
db: options.db,
|
db: options.db,
|
||||||
turnDaemon: options.turnDaemon,
|
turnDaemon: options.turnDaemon,
|
||||||
|
battleSim: options.battleSim,
|
||||||
profile: options.profile,
|
profile: options.profile,
|
||||||
auth: options.auth,
|
auth: options.auth,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import { runGameApiServer } from './server.js';
|
import { runGameApiServer } from './server.js';
|
||||||
|
import { runBattleSimWorker } from './battleSim/worker.js';
|
||||||
|
|
||||||
export * from './config.js';
|
export * from './config.js';
|
||||||
export * from './context.js';
|
export * from './context.js';
|
||||||
@@ -14,6 +15,12 @@ export * from './daemon/inMemoryTransport.js';
|
|||||||
export * from './daemon/redisTransport.js';
|
export * from './daemon/redisTransport.js';
|
||||||
export * from './auth/flushStore.js';
|
export * from './auth/flushStore.js';
|
||||||
export * from './auth/tokenVerifier.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 => {
|
const isMain = (): boolean => {
|
||||||
if (!process.argv[1]) {
|
if (!process.argv[1]) {
|
||||||
@@ -23,7 +30,10 @@ const isMain = (): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isMain()) {
|
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);
|
console.error('[game-api] failed to start', error);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
insertMessage,
|
insertMessage,
|
||||||
type MessageView,
|
type MessageView,
|
||||||
} from './messages/store.js';
|
} 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 zRunReason = z.enum(['schedule', 'manual', 'poke']);
|
||||||
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
||||||
@@ -37,6 +39,97 @@ const zTurnRunBudget = z.object({
|
|||||||
catchUpCap: z.number().int().positive(),
|
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) =>
|
const buildShiftAmountSchema = (maxTurns: number) =>
|
||||||
z.number()
|
z.number()
|
||||||
.int()
|
.int()
|
||||||
@@ -64,6 +157,39 @@ export const appRouter = router({
|
|||||||
now: new Date().toISOString(),
|
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({
|
world: router({
|
||||||
getState: procedure.query(async ({ ctx }) => {
|
getState: procedure.query(async ({ ctx }) => {
|
||||||
const state = await ctx.db.worldState.findFirst();
|
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 { InMemoryFlushStore, RedisGatewayFlushSubscriber } from './auth/flushStore.js';
|
||||||
import { createGameTokenVerifier } from './auth/tokenVerifier.js';
|
import { createGameTokenVerifier } from './auth/tokenVerifier.js';
|
||||||
import { appRouter } from './router.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 => {
|
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -43,6 +45,11 @@ export const createGameApiServer = async () => {
|
|||||||
keys: buildTurnDaemonStreamKeys(config.profileName),
|
keys: buildTurnDaemonStreamKeys(config.profileName),
|
||||||
requestTimeoutMs: config.daemonRequestTimeoutMs,
|
requestTimeoutMs: config.daemonRequestTimeoutMs,
|
||||||
});
|
});
|
||||||
|
const battleSim = new RedisBattleSimTransport(redis.client, {
|
||||||
|
keys: buildBattleSimQueueKeys(config.profileName),
|
||||||
|
requestTimeoutMs: config.battleSimRequestTimeoutMs,
|
||||||
|
resultTtlSeconds: config.battleSimResultTtlSeconds,
|
||||||
|
});
|
||||||
const flushStore = new InMemoryFlushStore();
|
const flushStore = new InMemoryFlushStore();
|
||||||
const flushSubscriberClient = redis.client.duplicate();
|
const flushSubscriberClient = redis.client.duplicate();
|
||||||
await flushSubscriberClient.connect();
|
await flushSubscriberClient.connect();
|
||||||
@@ -77,6 +84,7 @@ export const createGameApiServer = async () => {
|
|||||||
return createGameApiContext({
|
return createGameApiContext({
|
||||||
db: postgres.prisma as unknown as DatabaseClient,
|
db: postgres.prisma as unknown as DatabaseClient,
|
||||||
turnDaemon,
|
turnDaemon,
|
||||||
|
battleSim,
|
||||||
profile: {
|
profile: {
|
||||||
id: config.profile,
|
id: config.profile,
|
||||||
scenario: config.scenario,
|
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 { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { GameApiContext, GameProfile, WorldStateRow } from '../src/context.js';
|
import type { GameApiContext, GameProfile, WorldStateRow } from '../src/context.js';
|
||||||
|
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||||
import { appRouter } from '../src/router.js';
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
@@ -13,8 +14,10 @@ const profile: GameProfile = {
|
|||||||
const buildContext = (options?: {
|
const buildContext = (options?: {
|
||||||
state?: WorldStateRow | null;
|
state?: WorldStateRow | null;
|
||||||
transport?: InMemoryTurnDaemonTransport;
|
transport?: InMemoryTurnDaemonTransport;
|
||||||
|
battleSim?: InMemoryBattleSimTransport;
|
||||||
}): GameApiContext => {
|
}): GameApiContext => {
|
||||||
const transport = options?.transport ?? new InMemoryTurnDaemonTransport();
|
const transport = options?.transport ?? new InMemoryTurnDaemonTransport();
|
||||||
|
const battleSim = options?.battleSim ?? new InMemoryBattleSimTransport();
|
||||||
const db = {
|
const db = {
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst: async () => options?.state ?? null,
|
findFirst: async () => options?.state ?? null,
|
||||||
@@ -42,6 +45,7 @@ const buildContext = (options?: {
|
|||||||
return {
|
return {
|
||||||
db,
|
db,
|
||||||
turnDaemon: transport,
|
turnDaemon: transport,
|
||||||
|
battleSim,
|
||||||
profile,
|
profile,
|
||||||
auth: null,
|
auth: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ const isSupplyCity = (city: City): boolean => {
|
|||||||
return city.supplyState > 0;
|
return city.supplyState > 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractBattleOrder = (
|
export const computeBattleOrder = (
|
||||||
defender: WarUnit,
|
defender: WarUnit,
|
||||||
attacker: WarUnitGeneral
|
attacker: WarUnitGeneral
|
||||||
): number => {
|
): number => {
|
||||||
@@ -300,7 +300,7 @@ export const resolveWarBattle = <
|
|||||||
defenderLogger,
|
defenderLogger,
|
||||||
createPipeline(defender)
|
createPipeline(defender)
|
||||||
);
|
);
|
||||||
if (extractBattleOrder(unit, attackerUnit) <= 0) {
|
if (computeBattleOrder(unit, attackerUnit) <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
defenderUnits.push(unit);
|
defenderUnits.push(unit);
|
||||||
@@ -309,15 +309,15 @@ export const resolveWarBattle = <
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
defenderGenerals.length > 0 &&
|
defenderGenerals.length > 0 &&
|
||||||
extractBattleOrder(cityUnit, attackerUnit) > 0
|
computeBattleOrder(cityUnit, attackerUnit) > 0
|
||||||
) {
|
) {
|
||||||
defenderUnits.push(cityUnit);
|
defenderUnits.push(cityUnit);
|
||||||
}
|
}
|
||||||
|
|
||||||
defenderUnits.sort(
|
defenderUnits.sort(
|
||||||
(lhs, rhs) =>
|
(lhs, rhs) =>
|
||||||
extractBattleOrder(rhs, attackerUnit) -
|
computeBattleOrder(rhs, attackerUnit) -
|
||||||
extractBattleOrder(lhs, attackerUnit)
|
computeBattleOrder(lhs, attackerUnit)
|
||||||
);
|
);
|
||||||
|
|
||||||
const iter = defenderUnits.values();
|
const iter = defenderUnits.values();
|
||||||
@@ -335,7 +335,7 @@ export const resolveWarBattle = <
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const candidate = next.value as WarUnit<TriggerState>;
|
const candidate = next.value as WarUnit<TriggerState>;
|
||||||
if (extractBattleOrder(candidate, attackerUnit) <= 0) {
|
if (computeBattleOrder(candidate, attackerUnit) <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return candidate;
|
return candidate;
|
||||||
@@ -641,5 +641,79 @@ export const resolveWarBattle = <
|
|||||||
logs,
|
logs,
|
||||||
conquered: conquerCity,
|
conquered: conquerCity,
|
||||||
reports,
|
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;
|
dead: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WarBattleMetrics {
|
||||||
|
attackerPhase: number;
|
||||||
|
attackerActivatedSkills: Record<string, number>;
|
||||||
|
defenderActivatedSkills: Array<Record<string, number>>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WarBattleOutcome<
|
export interface WarBattleOutcome<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||||
> {
|
> {
|
||||||
@@ -82,6 +88,7 @@ export interface WarBattleOutcome<
|
|||||||
logs: LogEntryDraft[];
|
logs: LogEntryDraft[];
|
||||||
conquered: boolean;
|
conquered: boolean;
|
||||||
reports: WarUnitReport[];
|
reports: WarUnitReport[];
|
||||||
|
metrics?: WarBattleMetrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WarAftermathConfig {
|
export interface WarAftermathConfig {
|
||||||
|
|||||||
@@ -237,6 +237,17 @@ export abstract class WarUnit<
|
|||||||
(this.hasActivatedSkill(skillName) ? 1 : 0);
|
(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 {
|
public activateSkill(...skillNames: string[]): void {
|
||||||
for (const skillName of skillNames) {
|
for (const skillName of skillNames) {
|
||||||
this.activatedSkill[skillName] = true;
|
this.activatedSkill[skillName] = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user