diff --git a/app/game-api/src/battleSim/environment.ts b/app/game-api/src/battleSim/environment.ts new file mode 100644 index 0000000..6fc66ee --- /dev/null +++ b/app/game-api/src/battleSim/environment.ts @@ -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 => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + return value as Record; +}; + +const resolveNumber = ( + record: Record, + 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 => { + 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), + }, + }; +}; diff --git a/app/game-api/src/battleSim/inMemoryTransport.ts b/app/game-api/src/battleSim/inMemoryTransport.ts new file mode 100644 index 0000000..6ad068d --- /dev/null +++ b/app/game-api/src/battleSim/inMemoryTransport.ts @@ -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(); + + public async simulate(payload: BattleSimJobPayload): Promise { + 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 { + return this.results.get(jobId) ?? null; + } +} diff --git a/app/game-api/src/battleSim/keys.ts b/app/game-api/src/battleSim/keys.ts new file mode 100644 index 0000000..ffd586d --- /dev/null +++ b/app/game-api/src/battleSim/keys.ts @@ -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:`, +}); diff --git a/app/game-api/src/battleSim/logFormatter.ts b/app/game-api/src/battleSim/logFormatter.ts new file mode 100644 index 0000000..a93e013 --- /dev/null +++ b/app/game-api/src/battleSim/logFormatter.ts @@ -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>', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + return result; + } + + result = result.replaceAll('<1>', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + result = result.replaceAll('', ''); + return result; +}; diff --git a/app/game-api/src/battleSim/processor.ts b/app/game-api/src/battleSim/processor.ts new file mode 100644 index 0000000..8b66f4b --- /dev/null +++ b/app/game-api/src/battleSim/processor.ts @@ -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('
')), + generalActionLog: convertLog(buckets.generalActionLog.join('
')), + generalBattleResultLog: convertLog(buckets.generalBattleResultLog.join('
')), + generalBattleDetailLog: convertLog(buckets.generalBattleDetailLog.join('
')), + nationalHistoryLog: convertLog(buckets.nationalHistoryLog.join('
')), + globalHistoryLog: convertLog(buckets.globalHistoryLog.join('
')), + globalActionLog: convertLog(buckets.globalActionLog.join('
')), + }; +}; + +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 = {}; + const defendersSkills: Array> = []; + + 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(); + 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, + }; +}; diff --git a/app/game-api/src/battleSim/redisTransport.ts b/app/game-api/src/battleSim/redisTransport.ts new file mode 100644 index 0000000..9ae5bd5 --- /dev/null +++ b/app/game-api/src/battleSim/redisTransport.ts @@ -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; + blPop(key: string, timeout: number): Promise; + set(key: string, value: string, options?: { EX?: number }): Promise; + get(key: string): Promise; + expire(key: string, seconds: number): Promise; +} + +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 { + 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 { + 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 { + 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 { + return this.readResult(jobId); + } + + public async pushResult(jobId: string, payload: BattleSimResultPayload): Promise { + 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); + } +} diff --git a/app/game-api/src/battleSim/transport.ts b/app/game-api/src/battleSim/transport.ts new file mode 100644 index 0000000..cd6592a --- /dev/null +++ b/app/game-api/src/battleSim/transport.ts @@ -0,0 +1,6 @@ +import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js'; + +export interface BattleSimTransport { + simulate(payload: BattleSimJobPayload): Promise; + getSimulationResult(jobId: string): Promise; +} diff --git a/app/game-api/src/battleSim/types.ts b/app/game-api/src/battleSim/types.ts new file mode 100644 index 0000000..eb80b82 --- /dev/null +++ b/app/game-api/src/battleSim/types.ts @@ -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 | 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; + defendersSkills?: Array>; + 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; diff --git a/app/game-api/src/battleSim/unitSetLoader.ts b/app/game-api/src/battleSim/unitSetLoader.ts new file mode 100644 index 0000000..9e3c9d9 --- /dev/null +++ b/app/game-api/src/battleSim/unitSetLoader.ts @@ -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 => { + 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 => { + const raw = await readJsonFile(unitSetPath); + return parseUnitSetDefinition(raw); +}; + +export const loadUnitSetDefinitionByName = async ( + unitSetName: string, + options?: UnitSetLoaderOptions +): Promise => { + const unitSetPath = resolveUnitSetDefinitionPath(unitSetName, options); + return loadUnitSetDefinition(unitSetPath); +}; diff --git a/app/game-api/src/battleSim/worker.ts b/app/game-api/src/battleSim/worker.ts new file mode 100644 index 0000000..4d35fe3 --- /dev/null +++ b/app/game-api/src/battleSim/worker.ts @@ -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 => { + 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, + }); + } + } +}; diff --git a/app/game-api/src/config.ts b/app/game-api/src/config.ts index d9fc006..7372054 100644 --- a/app/game-api/src/config.ts +++ b/app/game-api/src/config.ts @@ -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`, }; diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 2b89be7..ed788ce 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -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, }; diff --git a/app/game-api/src/index.ts b/app/game-api/src/index.ts index 0c39643..a79203c 100644 --- a/app/game-api/src/index.ts +++ b/app/game-api/src/index.ts @@ -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; }); diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index 0bc9bb4..707f74a 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -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 = 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(); diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index a206321..d476e64 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -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, diff --git a/app/game-api/test/battleSimProcessor.test.ts b/app/game-api/test/battleSimProcessor.test.ts new file mode 100644 index 0000000..b4fb1bb --- /dev/null +++ b/app/game-api/test/battleSimProcessor.test.ts @@ -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); + }); +}); diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 934d511..4c48965 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -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, }; diff --git a/packages/logic/src/war/engine.ts b/packages/logic/src/war/engine.ts index cdeb86e..fe0f38c 100644 --- a/packages/logic/src/war/engine.ts +++ b/packages/logic/src/war/engine.ts @@ -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; - 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 +): 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[] = []; + 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); +}; diff --git a/packages/logic/src/war/types.ts b/packages/logic/src/war/types.ts index 9e7bcaa..a6f18ee 100644 --- a/packages/logic/src/war/types.ts +++ b/packages/logic/src/war/types.ts @@ -73,6 +73,12 @@ export interface WarUnitReport { dead: number; } +export interface WarBattleMetrics { + attackerPhase: number; + attackerActivatedSkills: Record; + defenderActivatedSkills: Array>; +} + 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 { diff --git a/packages/logic/src/war/units.ts b/packages/logic/src/war/units.ts index 3b3a4b8..292e615 100644 --- a/packages/logic/src/war/units.ts +++ b/packages/logic/src/war/units.ts @@ -237,6 +237,17 @@ export abstract class WarUnit< (this.hasActivatedSkill(skillName) ? 1 : 0); } + public getActivatedSkillLog(): Record { + const snapshot: Record = { ...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;