diff --git a/packages/logic/src/index.ts b/packages/logic/src/index.ts index e0679d8..fde081e 100644 --- a/packages/logic/src/index.ts +++ b/packages/logic/src/index.ts @@ -10,3 +10,4 @@ export * from './scenario/index.js'; export * from './triggers/index.js'; export * from './turn/index.js'; export * from './world/index.js'; +export * from './war/index.js'; diff --git a/packages/logic/src/war/actions.ts b/packages/logic/src/war/actions.ts new file mode 100644 index 0000000..d9875ee --- /dev/null +++ b/packages/logic/src/war/actions.ts @@ -0,0 +1,143 @@ +import type { RandUtil } from '@sammo-ts/common'; + +import type { + City, + General, + GeneralTriggerState, + Nation, +} from '../domain/entities.js'; +import type { ActionLogger } from '../logging/actionLogger.js'; +import type { WarUnit } from './units.js'; +import { WarTriggerCaller } from './triggers.js'; + +export interface WarActionContext { + general: General; + nation?: Nation | null; + city?: City; + log?: ActionLogger; + rng?: RandUtil; +} + +export interface WarActionModule { + getName?(): string; + getInfo?(): string; + + getBattleInitTriggerList?( + context: WarActionContext + ): WarTriggerCaller | null; + + getBattlePhaseTriggerList?( + context: WarActionContext + ): WarTriggerCaller | null; + + onCalcStat?( + context: WarActionContext, + statName: string, + value: number | [number, number], + aux?: unknown + ): number | [number, number]; + + onCalcOpposeStat?( + context: WarActionContext, + statName: string, + value: number | [number, number], + aux?: unknown + ): number | [number, number]; + + getWarPowerMultiplier?( + context: WarActionContext, + unit: WarUnit, + oppose: WarUnit + ): [number, number]; +} + +export class WarActionPipeline< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + // 전투용 iAction 파이프라인: 스탯/트리거/전투력 보정 흐름을 순서대로 적용한다. + private readonly modules: WarActionModule[]; + + constructor(modules: Array | null | undefined>) { + this.modules = modules.filter(Boolean) as WarActionModule[]; + } + + getBattleInitTriggerList( + context: WarActionContext + ): WarTriggerCaller { + const caller = new WarTriggerCaller(); + for (const module of this.modules) { + const triggers = module.getBattleInitTriggerList?.(context); + if (triggers) { + caller.merge(triggers); + } + } + return caller; + } + + getBattlePhaseTriggerList( + context: WarActionContext + ): WarTriggerCaller { + const caller = new WarTriggerCaller(); + for (const module of this.modules) { + const triggers = module.getBattlePhaseTriggerList?.(context); + if (triggers) { + caller.merge(triggers); + } + } + return caller; + } + + onCalcStat( + context: WarActionContext, + statName: string, + value: T, + aux?: unknown + ): T { + let current: number | [number, number] = value; + for (const module of this.modules) { + if (!module.onCalcStat) { + continue; + } + current = module.onCalcStat(context, statName, current, aux); + } + return current as T; + } + + onCalcOpposeStat( + context: WarActionContext, + statName: string, + value: T, + aux?: unknown + ): T { + let current: number | [number, number] = value; + for (const module of this.modules) { + if (!module.onCalcOpposeStat) { + continue; + } + current = module.onCalcOpposeStat(context, statName, current, aux); + } + return current as T; + } + + getWarPowerMultiplier( + context: WarActionContext, + unit: WarUnit, + oppose: WarUnit + ): [number, number] { + let attack = 1; + let defence = 1; + for (const module of this.modules) { + if (!module.getWarPowerMultiplier) { + continue; + } + const [attMul, defMul] = module.getWarPowerMultiplier( + context, + unit, + oppose + ); + attack *= attMul; + defence *= defMul; + } + return [attack, defence]; + } +} diff --git a/packages/logic/src/war/crewType.ts b/packages/logic/src/war/crewType.ts new file mode 100644 index 0000000..f33e079 --- /dev/null +++ b/packages/logic/src/war/crewType.ts @@ -0,0 +1,77 @@ +import type { CrewTypeDefinition } from '../world/types.js'; + +// 전투 계산에 필요한 병종 정보 래퍼. +export class WarCrewType { + constructor(private readonly definition: CrewTypeDefinition) {} + + get id(): number { + return this.definition.id; + } + + get armType(): number { + return this.definition.armType; + } + + get name(): string { + return this.definition.name; + } + + get speed(): number { + return this.definition.speed; + } + + get avoid(): number { + return this.definition.avoid; + } + + get attack(): number { + return this.definition.attack; + } + + get defence(): number { + return this.definition.defence; + } + + get rice(): number { + return this.definition.rice; + } + + get initSkillTrigger(): string[] { + return this.definition.initSkillTrigger ?? []; + } + + get phaseSkillTrigger(): string[] { + return this.definition.phaseSkillTrigger ?? []; + } + + getShortName(): string { + if (this.definition.name.length <= 4) { + return this.definition.name; + } + return this.definition.name.slice(0, 4); + } + + getAttackCoef(oppose: WarCrewType): number { + const byId = this.definition.attackCoef[String(oppose.id)]; + if (typeof byId === 'number') { + return byId; + } + const byType = this.definition.attackCoef[String(oppose.armType)]; + if (typeof byType === 'number') { + return byType; + } + return 1; + } + + getDefenceCoef(oppose: WarCrewType): number { + const byId = this.definition.defenceCoef[String(oppose.id)]; + if (typeof byId === 'number') { + return byId; + } + const byType = this.definition.defenceCoef[String(oppose.armType)]; + if (typeof byType === 'number') { + return byType; + } + return 1; + } +} diff --git a/packages/logic/src/war/engine.ts b/packages/logic/src/war/engine.ts new file mode 100644 index 0000000..cdeb86e --- /dev/null +++ b/packages/logic/src/war/engine.ts @@ -0,0 +1,645 @@ +import { + JosaUtil, + LiteHashDRBG, + RandUtil, +} from '@sammo-ts/common'; + +import type { + City, + General, + GeneralTriggerState, +} from '../domain/entities.js'; +import { ActionLogger } from '../logging/actionLogger.js'; +import { LogFormat } from '../logging/types.js'; +import { buildCrewTypeIndex as buildCrewTypeDefinitionIndex } from '../world/unitSet.js'; +import { WarActionPipeline } from './actions.js'; +import { WarCrewType } from './crewType.js'; +import { + ChePilsalActivateTrigger, + ChePilsalAttemptTrigger, +} from './triggersChePilsal.js'; +import { + WarTriggerCaller, + createWarTriggerEnv, + type WarTriggerRegistry, +} from './triggers.js'; +import type { + WarBattleInput, + WarBattleOutcome, + WarGeneralInput, + WarUnitReport, +} from './types.js'; +import { getMetaNumber } from './utils.js'; +import { WarUnitCity, WarUnitGeneral, type WarUnit } from './units.js'; + +const META_FULL_LEADERSHIP = 'fullLeadership'; +const META_FULL_STRENGTH = 'fullStrength'; +const META_FULL_INTELLIGENCE = 'fullIntelligence'; +const META_DEFENCE_TRAIN = 'defenceTrain'; + +const defaultLoggerFactory = (options: { + generalId?: number; + nationId?: number; +}): ActionLogger => new ActionLogger(options); + +const resolveCrewType = ( + crewTypeIndex: Map, + crewTypeId: number +): WarCrewType => { + const crewType = crewTypeIndex.get(crewTypeId); + if (!crewType) { + throw new Error(`Invalid crew type: ${crewTypeId}`); + } + return crewType; +}; + +const buildWarCrewTypeIndex = ( + unitSet: WarBattleInput['unitSet'] +): Map => { + const index = new Map(); + const crewTypes = buildCrewTypeDefinitionIndex(unitSet); + for (const [id, crewType] of crewTypes) { + index.set(id, new WarCrewType(crewType)); + } + return index; +}; + +const createPipeline = ( + input: WarGeneralInput +): WarActionPipeline => + new WarActionPipeline(input.modules ?? []); + +const appendCrewTypeTriggers = ( + caller: WarTriggerCaller, + unit: WarUnit, + names: string[], + registry: WarTriggerRegistry | undefined +): void => { + if (!registry) { + return; + } + for (const name of names) { + const factory = registry[name]; + if (!factory) { + continue; + } + const trigger = factory(unit); + if (trigger) { + caller.append(trigger); + } + } +}; + +const buildBattleInitTriggers = ( + unit: WarUnit, + registry: WarTriggerRegistry | undefined +): WarTriggerCaller => { + const caller = new WarTriggerCaller(); + if (unit instanceof WarUnitGeneral) { + const context = unit.getActionContext(); + caller.merge(unit.getActionPipeline().getBattleInitTriggerList(context)); + } + appendCrewTypeTriggers(caller, unit, unit.getCrewType().initSkillTrigger, registry); + return caller; +}; + +const buildBattlePhaseTriggers = ( + unit: WarUnit, + registry: WarTriggerRegistry | undefined +): WarTriggerCaller => { + const caller = new WarTriggerCaller(); + if (unit instanceof WarUnitGeneral) { + caller.append(new ChePilsalAttemptTrigger(unit)); + caller.append(new ChePilsalActivateTrigger(unit)); + const context = unit.getActionContext(); + caller.merge(unit.getActionPipeline().getBattlePhaseTriggerList(context)); + } + appendCrewTypeTriggers(caller, unit, unit.getCrewType().phaseSkillTrigger, registry); + return caller; +}; + +const resolveFullStats = (general: General): { + leadership: number; + strength: number; + intelligence: number; +} => ({ + leadership: getMetaNumber(general.meta, META_FULL_LEADERSHIP, general.stats.leadership), + strength: getMetaNumber(general.meta, META_FULL_STRENGTH, general.stats.strength), + intelligence: getMetaNumber( + general.meta, + META_FULL_INTELLIGENCE, + general.stats.intelligence + ), +}); + +const isSupplyCity = (city: City): boolean => { + const supply = city.meta.supply; + if (typeof supply === 'boolean') { + return supply; + } + if (typeof supply === 'number') { + return supply > 0; + } + return city.supplyState > 0; +}; + +const extractBattleOrder = ( + defender: WarUnit, + attacker: WarUnitGeneral +): number => { + if (defender instanceof WarUnitCity) { + const context = attacker.getActionContext(); + return attacker + .getActionPipeline() + .onCalcOpposeStat(context, 'cityBattleOrder', -1); + } + + if (!(defender instanceof WarUnitGeneral)) { + return 0; + } + + const general = defender.getGeneral(); + if (general.crew <= 0) { + return 0; + } + if (general.rice <= general.crew / 100) { + return 0; + } + + const defenceTrain = getMetaNumber(general.meta, META_DEFENCE_TRAIN, 0); + if (general.train < defenceTrain) { + return 0; + } + if (general.atmos < defenceTrain) { + return 0; + } + + const realStat = + general.stats.leadership + + general.stats.strength + + general.stats.intelligence; + const fullStats = resolveFullStats(general); + const fullStat = + fullStats.leadership + fullStats.strength + fullStats.intelligence; + const totalStat = (realStat + fullStat) / 2; + + const totalCrew = + (general.crew / 1_000_000) * + Math.pow(general.train * general.atmos, 1.5); + + return totalStat + totalCrew / 100; +}; + +const resolveUnitReport = (unit: WarUnit): WarUnitReport => { + if (unit instanceof WarUnitGeneral) { + return { + id: unit.getGeneral().id, + type: 'general', + name: unit.getName(), + isAttacker: unit.isAttacker(), + killed: unit.getKilled(), + dead: unit.getDead(), + }; + } + + if (unit instanceof WarUnitCity) { + return { + id: unit.getCityId(), + type: 'city', + name: unit.getName(), + isAttacker: unit.isAttacker(), + killed: unit.getKilled(), + dead: unit.getDead(), + }; + } + + return { + id: null, + type: 'city', + name: unit.getName(), + isAttacker: unit.isAttacker(), + killed: unit.getKilled(), + dead: unit.getDead(), + }; +}; + +const flushLoggers = (loggers: ActionLogger[]): Array[number]> => { + const logs: Array[number]> = []; + for (const logger of loggers) { + logs.push(...logger.flush()); + } + return logs; +}; + +export const resolveWarBattle = < + TriggerState extends GeneralTriggerState = GeneralTriggerState +>( + input: WarBattleInput +): WarBattleOutcome => { + // process_war.php 전투 루프를 순수 로직으로 이식한다. + const rng = + input.rng ?? + new RandUtil( + LiteHashDRBG.build(input.seed ?? '') + ); + const loggerFactory = input.loggerFactory ?? defaultLoggerFactory; + const triggerRegistry = input.triggerRegistry; + + 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 cityLogger = loggerFactory({ + nationId: input.defenderCity.nationId, + }); + const cityUnit = new WarUnitCity( + rng, + input.config, + input.defenderCity, + input.defenderNation, + resolveCrewType(crewTypeIndex, input.config.castleCrewTypeId), + cityLogger, + input.time.year, + input.time.startYear + ); + + const defenderUnits: WarUnit[] = []; + const defenderGenerals: 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 (extractBattleOrder(unit, attackerUnit) <= 0) { + continue; + } + defenderUnits.push(unit); + defenderGenerals.push(unit); + } + + if ( + defenderGenerals.length > 0 && + extractBattleOrder(cityUnit, attackerUnit) > 0 + ) { + defenderUnits.push(cityUnit); + } + + defenderUnits.sort( + (lhs, rhs) => + extractBattleOrder(rhs, attackerUnit) - + extractBattleOrder(lhs, attackerUnit) + ); + + const iter = defenderUnits.values(); + let defender: WarUnit | null = null; + + const getNextDefender = ( + _prevDefender: WarUnit | null, + reqNext: boolean + ): WarUnit | null => { + if (!reqNext) { + return null; + } + const next = iter.next(); + if (next.done) { + return null; + } + const candidate = next.value as WarUnit; + if (extractBattleOrder(candidate, attackerUnit) <= 0) { + return null; + } + return candidate; + }; + + defender = getNextDefender(null, true); + let conquerCity = false; + let logWritten = false; + + const attackerNationName = + (attackerUnit.getNationVar('name') as string | null) ?? 'UNKNOWN'; + const attackerName = attackerUnit.getName(); + const cityName = cityUnit.getName(); + const seedText = input.seed ? `(전투시드: ${input.seed})` : ''; + + const josaRo = JosaUtil.pick(cityName, '로'); + const josaYi = JosaUtil.pick(attackerName, '이'); + + attackerLogger.pushGlobalActionLog( + `${attackerNationName}${attackerName}${josaYi} ${cityName}${josaRo} 진격합니다.${seedText}`, + LogFormat.MONTH + ); + attackerLogger.pushGeneralActionLog( + `${cityName}${josaRo} 진격합니다.${seedText}`, + LogFormat.MONTH + ); + + while (attackerUnit.getPhase() < attackerUnit.getMaxPhase()) { + logWritten = false; + + if (!defender) { + defender = cityUnit; + cityUnit.setSiege(); + + const defenderRice = input.defenderNation?.rice ?? 0; + if (isSupplyCity(input.defenderCity) && defenderRice <= 0) { + attackerUnit.setOppose(defender); + defender.setOppose(attackerUnit); + + attackerUnit.addTrain(1); + + attackerUnit.addWin(); + defender.addLose(); + cityUnit.heavyDecreaseWealth(); + + attackerLogger.pushGlobalActionLog( + `병량 부족으로 ${cityName}의 수비병들이 패퇴합니다.`, + LogFormat.MONTH + ); + + conquerCity = true; + break; + } + } + + if (defender.getPhase() === 0 && !defender.getOppose()) { + defender.setPrePhase(attackerUnit.getPhase()); + + attackerUnit.addTrain(1); + defender.addTrain(1); + + const attackerName = attackerUnit.getName(); + const attackerCrewName = attackerUnit.getCrewTypeName(); + + if (defender instanceof WarUnitGeneral) { + const defenderName = defender.getName(); + const defenderCrewName = defender.getCrewTypeName(); + + const josaWa = JosaUtil.pick(attackerCrewName, '와'); + const josaYiDef = JosaUtil.pick(defenderCrewName, '이'); + attackerLogger.pushGlobalActionLog( + `${attackerName}의 ${attackerCrewName}${josaWa} ${defenderName}의 ${defenderCrewName}${josaYiDef} 대결합니다.`, + LogFormat.MONTH + ); + + const josaRo = JosaUtil.pick(attackerCrewName, '로'); + const josaUl = JosaUtil.pick(defenderCrewName, '을'); + attackerUnit.getLogger().pushGeneralActionLog( + `${attackerCrewName}${josaRo} ${defenderName}의 ${defenderCrewName}${josaUl} 공격합니다.`, + LogFormat.MONTH + ); + + const defJosaRo = JosaUtil.pick(defenderCrewName, '로'); + const defJosaUl = JosaUtil.pick(attackerCrewName, '을'); + defender.getLogger().pushGeneralActionLog( + `${defenderCrewName}${defJosaRo} ${attackerName}의 ${attackerCrewName}${defJosaUl} 수비합니다.`, + LogFormat.MONTH + ); + } else { + const josaYiName = JosaUtil.pick(attackerName, '이'); + const josaRoCrew = JosaUtil.pick(attackerCrewName, '로'); + attackerLogger.pushGlobalActionLog( + `${attackerName}${josaYiName} ${attackerCrewName}${josaRoCrew} 성벽을 공격합니다.`, + LogFormat.MONTH + ); + attackerLogger.pushGeneralActionLog( + `${attackerCrewName}${josaRoCrew} 성벽을 공격합니다.`, + LogFormat.PLAIN + ); + } + + attackerUnit.setOppose(defender); + defender.setOppose(attackerUnit); + + const initCaller = buildBattleInitTriggers(attackerUnit, triggerRegistry); + initCaller.merge(buildBattleInitTriggers(defender, triggerRegistry)); + initCaller.fire( + { rng, attacker: attackerUnit, defender }, + createWarTriggerEnv() + ); + } + + attackerUnit.beginPhase(); + defender.beginPhase(); + + const battleCaller = buildBattlePhaseTriggers(attackerUnit, triggerRegistry); + battleCaller.merge(buildBattlePhaseTriggers(defender, triggerRegistry)); + battleCaller.fire( + { rng, attacker: attackerUnit, defender }, + createWarTriggerEnv() + ); + + let deadDefender = attackerUnit.calcDamage(); + let deadAttacker = defender.calcDamage(); + + const attackerHP = attackerUnit.getHP(); + const defenderHP = defender.getHP(); + + if (deadAttacker > attackerHP || deadDefender > defenderHP) { + const deadAttackerRatio = deadAttacker / Math.max(1, attackerHP); + const deadDefenderRatio = deadDefender / Math.max(1, defenderHP); + + if (deadDefenderRatio > deadAttackerRatio) { + deadAttacker /= deadDefenderRatio; + deadDefender = defenderHP; + } else { + deadDefender /= deadAttackerRatio; + deadAttacker = attackerHP; + } + } + + deadAttacker = Math.min(Math.ceil(deadAttacker), attackerHP); + deadDefender = Math.min(Math.ceil(deadDefender), defenderHP); + + attackerUnit.decreaseHP(deadAttacker); + defender.decreaseHP(deadDefender); + + attackerUnit.increaseKilled(deadDefender); + defender.increaseKilled(deadAttacker); + + const phaseNickname = defender.getPhase() < 0 + ? '先' + : `${attackerUnit.getPhase() + 1} `; + + if (deadAttacker > 0 || deadDefender > 0) { + attackerUnit.getLogger().pushGeneralBattleDetailLog( + `${phaseNickname}: 【${attackerUnit.getName()}】 ${attackerUnit.getHP()} (-${deadAttacker}) VS ${defender.getHP()} (-${deadDefender}) 【${defender.getName()}】` + ); + + defender.getLogger().pushGeneralBattleDetailLog( + `${phaseNickname}: 【${defender.getName()}】 ${defender.getHP()} (-${deadDefender}) VS ${attackerUnit.getHP()} (-${deadAttacker}) 【${attackerUnit.getName()}】` + ); + } + + attackerUnit.addPhase(); + defender.addPhase(); + + const attackerState = attackerUnit.continueWar(); + if (!attackerState.canContinue) { + logWritten = true; + + attackerUnit.logBattleResult(); + defender.logBattleResult(); + + attackerUnit.addLose(); + defender.addWin(); + + attackerUnit.tryWound(); + defender.tryWound(); + + const josaYiCrew = JosaUtil.pick(attackerUnit.getCrewTypeName(), '이'); + attackerLogger.pushGlobalActionLog( + `${attackerUnit.getName()}의 ${attackerUnit.getCrewTypeName()}${josaYiCrew} 퇴각했습니다.`, + LogFormat.MONTH + ); + attackerUnit.getLogger().pushGeneralActionLog( + attackerState.noRice ? '군량 부족으로 퇴각합니다.' : '퇴각했습니다.', + LogFormat.PLAIN + ); + defender.getLogger().pushGeneralActionLog( + `${attackerUnit.getName()}의 ${attackerUnit.getCrewTypeName()}${josaYiCrew} 퇴각했습니다.`, + LogFormat.PLAIN + ); + break; + } + + const defenderState = defender.continueWar(); + if (!defenderState.canContinue) { + logWritten = true; + + attackerUnit.logBattleResult(); + defender.logBattleResult(); + + if (!(defender instanceof WarUnitCity) || defender.isSiege()) { + attackerUnit.addWin(); + defender.addLose(); + + attackerUnit.tryWound(); + defender.tryWound(); + + if (defender === cityUnit) { + attackerUnit.addLevelExp(1000); + conquerCity = true; + break; + } + } + + const josaYiDefCrew = JosaUtil.pick(defender.getCrewTypeName(), '이'); + + if (defender instanceof WarUnitCity && !defender.isSiege()) { + defender.setOppose(null); + } else if (defenderState.noRice) { + attackerLogger.pushGlobalActionLog( + `${defender.getName()}의 ${defender.getCrewTypeName()}${josaYiDefCrew} 패퇴했습니다.`, + LogFormat.MONTH + ); + attackerUnit.getLogger().pushGeneralActionLog( + `${defender.getName()}의 ${defender.getCrewTypeName()}${josaYiDefCrew} 패퇴했습니다.`, + LogFormat.PLAIN + ); + defender.getLogger().pushGeneralActionLog( + '군량 부족으로 패퇴합니다.', + LogFormat.PLAIN + ); + } else { + attackerLogger.pushGlobalActionLog( + `${defender.getName()}의 ${defender.getCrewTypeName()}${josaYiDefCrew} 전멸했습니다.`, + LogFormat.MONTH + ); + attackerUnit.getLogger().pushGeneralActionLog( + `${defender.getName()}의 ${defender.getCrewTypeName()}${josaYiDefCrew} 전멸했습니다.`, + LogFormat.PLAIN + ); + defender.getLogger().pushGeneralActionLog( + '전멸했습니다.', + LogFormat.PLAIN + ); + } + + if (attackerUnit.getPhase() >= attackerUnit.getMaxPhase()) { + break; + } + + defender.finishBattle(); + defender = getNextDefender(defender, true); + } + } + + if (!logWritten && defender) { + attackerUnit.logBattleResult(); + defender.logBattleResult(); + + attackerUnit.tryWound(); + defender.tryWound(); + } + + attackerUnit.finishBattle(); + defender?.finishBattle(); + + if (cityUnit.getDead() > 0 || defender instanceof WarUnitCity) { + if (cityUnit !== defender) { + cityUnit.setOppose(attackerUnit); + cityUnit.setSiege(); + cityUnit.finishBattle(); + } + + const newConflict = cityUnit.addConflict(); + if (newConflict) { + const nationName = attackerUnit.getNationVar('name') as string; + const josaYiNation = JosaUtil.pick(nationName, '이'); + attackerLogger.pushGlobalHistoryLog( + `【분쟁】${nationName}${josaYiNation} ${cityName} 공략에 가담하여 분쟁이 발생하고 있습니다.`, + LogFormat.YEAR_MONTH + ); + } + } + + const logs = flushLoggers([ + attackerLogger, + ...defenderGenerals.map((unit) => unit.getLogger()), + ]); + + const reports: WarUnitReport[] = [ + resolveUnitReport(attackerUnit), + ...defenderUnits.map(resolveUnitReport), + resolveUnitReport(cityUnit), + ]; + + return { + attacker: attackerUnit.getGeneral(), + defenders: defenderGenerals.map((unit) => unit.getGeneral()), + defenderCity: input.defenderCity, + logs, + conquered: conquerCity, + reports, + }; +}; diff --git a/packages/logic/src/war/index.ts b/packages/logic/src/war/index.ts new file mode 100644 index 0000000..99a407f --- /dev/null +++ b/packages/logic/src/war/index.ts @@ -0,0 +1,8 @@ +export * from './types.js'; +export * from './actions.js'; +export * from './engine.js'; +export * from './units.js'; +export * from './triggers.js'; +export * from './triggersChePilsal.js'; +export * from './crewType.js'; +export * from './utils.js'; diff --git a/packages/logic/src/war/triggers.ts b/packages/logic/src/war/triggers.ts new file mode 100644 index 0000000..b122fed --- /dev/null +++ b/packages/logic/src/war/triggers.ts @@ -0,0 +1,100 @@ +import type { RandUtil } from '@sammo-ts/common'; + +import { TriggerCaller, type Trigger } from '../triggers/core.js'; +import type { WarUnit } from './units.js'; + +export interface WarTriggerContext { + rng: RandUtil; + attacker: WarUnit; + defender: WarUnit; +} + +export interface WarTriggerEnv { + e_attacker: Record; + e_defender: Record; + stopNextAction?: boolean; +} + +export type WarTrigger = Trigger; + +export const createWarTriggerEnv = (): WarTriggerEnv => ({ + e_attacker: {}, + e_defender: {}, +}); + +export class WarTriggerCaller extends TriggerCaller {} + +export type WarTriggerFactory = (unit: WarUnit) => WarTrigger | null; + +export type WarTriggerRegistry = Record; + +// 전투 트리거 공통 베이스. 전투 유닛 기준으로 동작하며, env는 공격/수비 분리 상태를 유지한다. +export abstract class BaseWarUnitTrigger implements WarTrigger { + public static readonly TYPE_NONE = 0; + public static readonly TYPE_ITEM = 1; + public static readonly TYPE_CONSUMABLE_ITEM = 1 | 2; + public static readonly TYPE_DEDUP_TYPE_BASE = 1024; + + protected readonly raiseType: number; + + protected constructor( + protected readonly unit: WarUnit, + public readonly priority: number, + raiseType = BaseWarUnitTrigger.TYPE_NONE + ) { + this.raiseType = raiseType; + } + + get uniqueId(): string { + return `${this.priority}_${this.constructor.name}_${this.unit.getUnitId()}_${this.raiseType}`; + } + + action( + context: WarTriggerContext, + env: WarTriggerEnv + ): WarTriggerEnv { + const nextEnv: WarTriggerEnv = { + e_attacker: env.e_attacker ?? {}, + e_defender: env.e_defender ?? {}, + ...(env.stopNextAction ? { stopNextAction: true } : {}), + }; + + if (nextEnv.stopNextAction) { + return nextEnv; + } + + const self = this.unit; + const isAttacker = self.isAttacker(); + const oppose = isAttacker ? context.defender : context.attacker; + + const selfEnv = isAttacker ? nextEnv.e_attacker : nextEnv.e_defender; + const opposeEnv = isAttacker ? nextEnv.e_defender : nextEnv.e_attacker; + + const shouldContinue = this.actionWar(self, oppose, selfEnv, opposeEnv); + + nextEnv.e_attacker = isAttacker ? selfEnv : opposeEnv; + nextEnv.e_defender = isAttacker ? opposeEnv : selfEnv; + + if (!shouldContinue) { + nextEnv.stopNextAction = true; + } + + return nextEnv; + } + + // 실제 트리거 구현부는 여기서 처리한다. + protected abstract actionWar( + self: WarUnit, + oppose: WarUnit, + selfEnv: Record, + opposeEnv: Record + ): boolean; + + // 아이템 소모 트리거는 아직 미구현. 필요 시 raiseType을 활용해 확장 가능하다. + public processConsumableItem(): boolean { + if (!(this.raiseType & BaseWarUnitTrigger.TYPE_ITEM)) { + return false; + } + return false; + } +} diff --git a/packages/logic/src/war/triggersChePilsal.ts b/packages/logic/src/war/triggersChePilsal.ts new file mode 100644 index 0000000..af00416 --- /dev/null +++ b/packages/logic/src/war/triggersChePilsal.ts @@ -0,0 +1,65 @@ +import { LogFormat } from '../logging/types.js'; +import { TriggerPriority } from '../triggers/core.js'; +import { BaseWarUnitTrigger } from './triggers.js'; +import { WarUnitGeneral, type WarUnit } from './units.js'; + +// 기본 필살: 시도 단계 +export class ChePilsalAttemptTrigger extends BaseWarUnitTrigger { + constructor(unit: WarUnit) { + super(unit, TriggerPriority.Pre + 120); + } + + protected actionWar( + self: WarUnit, + _oppose: WarUnit, + _selfEnv: Record, + _opposeEnv: Record + ): boolean { + if (!(self instanceof WarUnitGeneral)) { + return true; + } + if (self.hasActivatedSkill('특수')) { + return true; + } + if (self.hasActivatedSkill('필살불가')) { + return true; + } + if (!self.rng.nextBool(self.getComputedCriticalRatio())) { + return true; + } + self.activateSkill('필살시도', '필살'); + return true; + } +} + +// 기본 필살: 발동 단계 +export class ChePilsalActivateTrigger extends BaseWarUnitTrigger { + constructor(unit: WarUnit) { + super(unit, TriggerPriority.Post + 400); + } + + protected actionWar( + self: WarUnit, + oppose: WarUnit, + selfEnv: Record, + _opposeEnv: Record + ): boolean { + if (!self.hasActivatedSkill('필살')) { + return true; + } + if (selfEnv['필살발동']) { + return true; + } + selfEnv['필살발동'] = true; + + oppose + .getLogger() + .pushGeneralBattleDetailLog('상대의 필살공격!', LogFormat.PLAIN); + self + .getLogger() + .pushGeneralBattleDetailLog('필살공격!', LogFormat.PLAIN); + + self.multiplyWarPowerMultiply(self.criticalDamage()); + return true; + } +} diff --git a/packages/logic/src/war/types.ts b/packages/logic/src/war/types.ts new file mode 100644 index 0000000..36937e2 --- /dev/null +++ b/packages/logic/src/war/types.ts @@ -0,0 +1,85 @@ +import type { RandUtil } from '@sammo-ts/common'; + +import type { + City, + General, + GeneralTriggerState, + Nation, +} from '../domain/entities.js'; +import type { ActionLogger } from '../logging/actionLogger.js'; +import type { LogEntryDraft } from '../logging/types.js'; +import type { UnitSetDefinition } from '../world/types.js'; +import type { WarActionModule } from './actions.js'; +import type { WarTriggerRegistry } from './triggers.js'; + +export interface WarArmTypes { + footman?: number; + archer?: number; + cavalry?: number; + wizard?: number; + siege?: number; + misc?: number; + castle?: number; +} + +export interface WarEngineConfig { + armPerPhase: number; + maxTrainByCommand: number; + maxAtmosByCommand: number; + maxTrainByWar: number; + maxAtmosByWar: number; + castleCrewTypeId: number; + armTypes: WarArmTypes; +} + +export interface WarTimeContext { + year: number; + month: number; + startYear: number; +} + +export interface WarGeneralInput< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + general: General; + city: City; + nation: Nation | null; + logger?: ActionLogger; + modules?: Array | null | undefined>; +} + +export interface WarBattleInput< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + seed?: string; + rng?: RandUtil; + unitSet: UnitSetDefinition; + config: WarEngineConfig; + time: WarTimeContext; + attacker: WarGeneralInput; + defenders: WarGeneralInput[]; + defenderCity: City; + defenderNation: Nation | null; + triggerRegistry?: WarTriggerRegistry; + loggerFactory?: (options: { generalId?: number; nationId?: number }) => ActionLogger; +} + +export interface WarUnitReport { + id: number | null; + type: 'general' | 'city'; + name: string; + isAttacker: boolean; + killed: number; + dead: number; +} + +export interface WarBattleOutcome< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + attacker: General; + defenders: General[]; + defenderCity: City; + logs: LogEntryDraft[]; + conquered: boolean; + reports: WarUnitReport[]; +} diff --git a/packages/logic/src/war/units.ts b/packages/logic/src/war/units.ts new file mode 100644 index 0000000..3b3a4b8 --- /dev/null +++ b/packages/logic/src/war/units.ts @@ -0,0 +1,1043 @@ +import type { RandUtil } from '@sammo-ts/common'; + +import type { + City, + General, + GeneralTriggerState, + Nation, + StatBlock, + TriggerValue, +} from '../domain/entities.js'; +import type { ActionLogger } from '../logging/actionLogger.js'; +import { LogFormat } from '../logging/types.js'; +import { getTechAbility, getTechCost } from '../world/unitSet.js'; +import { WarActionPipeline, type WarActionContext } from './actions.js'; +import type { WarEngineConfig } from './types.js'; +import { WarCrewType } from './crewType.js'; +import { + clamp, + clampMin, + getDexLog, + getMetaNumber, + getMetaString, + increaseMetaNumber, + round, + sortConflict, + parseConflict, + stringifyConflict, +} from './utils.js'; + +const META_EXP_LEVEL = 'explevel'; +const META_TURN_TIME = 'turnTime'; +const META_RECENT_WAR = 'recentWar'; +const META_DEX_PREFIX = 'dex'; +const META_RANK_PREFIX = 'rank_'; +const META_INTEL_EXP = 'intelExp'; +const META_STRENGTH_EXP = 'strengthExp'; +const META_LEADERSHIP_EXP = 'leadershipExp'; + +const RANK_WARNUM = `${META_RANK_PREFIX}warnum`; +const RANK_KILLNUM = `${META_RANK_PREFIX}killnum`; +const RANK_DEATHNUM = `${META_RANK_PREFIX}deathnum`; +const RANK_OCCUPIED = `${META_RANK_PREFIX}occupied`; +const RANK_KILLCREW = `${META_RANK_PREFIX}killcrew`; +const RANK_DEATHCREW = `${META_RANK_PREFIX}deathcrew`; +const RANK_KILLCREW_PERSON = `${META_RANK_PREFIX}killcrew_person`; +const RANK_DEATHCREW_PERSON = `${META_RANK_PREFIX}deathcrew_person`; + +const WAR_CRITICAL_RANGE: [number, number] = [1.3, 2.0]; + +const resolveNationTech = (nation: Nation | null): number => + nation ? getMetaNumber(nation.meta, 'tech', 0) : 0; + +const resolveNationVar = (nation: Nation | null, key: string): TriggerValue | null => { + if (!nation) { + return null; + } + switch (key) { + case 'nation': + return nation.id; + case 'name': + return nation.name; + case 'capital': + return nation.capitalCityId ?? 0; + case 'gold': + return nation.gold; + case 'rice': + return nation.rice; + case 'tech': + return resolveNationTech(nation); + case 'type': + return nation.typeCode; + default: + return nation.meta[key] ?? null; + } +}; + +// 전투 유닛 공통 상태와 수치 계산(legacy WarUnit 계열 포팅). +export abstract class WarUnit< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + private static nextUnitId = 1; + private readonly unitId: number; + + protected oppose: WarUnit | null = null; + protected killedCurr = 0; + protected killed = 0; + protected deadCurr = 0; + protected dead = 0; + + protected currPhase = 0; + protected prePhase = 0; + protected bonusPhase = 0; + + protected atmosBonus = 0; + protected trainBonus = 0; + + protected warPower = 0; + protected warPowerMultiply = 1; + + protected activatedSkill: Record = {}; + protected logActivatedSkill: Record = {}; + protected isFinished = false; + + protected constructor( + public readonly rng: RandUtil, + protected readonly config: WarEngineConfig, + protected readonly crewType: WarCrewType, + protected readonly logger: ActionLogger, + protected readonly attacker: boolean, + protected readonly nation: Nation | null + ) { + this.unitId = WarUnit.nextUnitId; + WarUnit.nextUnitId += 1; + } + + // 전투 유닛 식별자(트리거 dedup에 사용). + public getUnitId(): number { + return this.unitId; + } + + public getNationVar(key: string): TriggerValue | null { + return resolveNationVar(this.nation, key); + } + + public getRawNation(): Nation | null { + return this.nation; + } + + public getPhase(): number { + return this.currPhase; + } + + public getRealPhase(): number { + return this.prePhase + this.currPhase; + } + + public getCrewType(): WarCrewType { + return this.crewType; + } + + public getCrewTypeName(): string { + return this.crewType.name; + } + + public getCrewTypeShortName(): string { + return this.crewType.getShortName(); + } + + public getLogger(): ActionLogger { + return this.logger; + } + + public getKilled(): number { + return this.killed; + } + + public getDead(): number { + return this.dead; + } + + public getKilledCurrentBattle(): number { + return this.killedCurr; + } + + public getDeadCurrentBattle(): number { + return this.deadCurr; + } + + public isAttacker(): boolean { + return this.attacker; + } + + public getMaxPhase(): number { + return this.getCrewType().speed + this.bonusPhase; + } + + public setPrePhase(phase: number): void { + this.prePhase = phase; + } + + public addPhase(phase = 1): void { + this.currPhase += phase; + } + + public addBonusPhase(count: number): void { + this.bonusPhase += count; + } + + public setOppose(oppose: WarUnit | null): void { + this.oppose = oppose; + this.killedCurr = 0; + this.deadCurr = 0; + this.clearActivatedSkill(); + } + + public getOppose(): WarUnit | null { + return this.oppose; + } + + public getWarPower(): number { + return this.warPower * this.warPowerMultiply; + } + + public getWarPowerMultiply(): number { + return this.warPowerMultiply; + } + + public setWarPowerMultiply(multiply = 1): void { + this.warPowerMultiply = multiply; + } + + public multiplyWarPowerMultiply(multiply: number): void { + this.warPowerMultiply *= multiply; + } + + public getRawWarPower(): number { + return this.warPower; + } + + protected clearActivatedSkill(): void { + for (const [skillName, state] of Object.entries(this.activatedSkill)) { + if (!state) { + continue; + } + this.logActivatedSkill[skillName] = + (this.logActivatedSkill[skillName] ?? 0) + 1; + } + this.activatedSkill = {}; + } + + public hasActivatedSkill(skillName: string): boolean { + return Boolean(this.activatedSkill[skillName]); + } + + public hasActivatedSkillOnLog(skillName: string): number { + return (this.logActivatedSkill[skillName] ?? 0) + + (this.hasActivatedSkill(skillName) ? 1 : 0); + } + + public activateSkill(...skillNames: string[]): void { + for (const skillName of skillNames) { + this.activatedSkill[skillName] = true; + } + } + + public deactivateSkill(...skillNames: string[]): void { + for (const skillName of skillNames) { + this.activatedSkill[skillName] = false; + } + } + + public beginPhase(): void { + this.clearActivatedSkill(); + this.computeWarPower(); + } + + public computeWarPower(): [number, number] { + const oppose = this.getOppose(); + if (!oppose) { + return [0, 1]; + } + + const myAttack = this.getComputedAttack(); + const opposeDef = oppose.getComputedDefence(); + + let warPower = this.config.armPerPhase + myAttack - opposeDef; + let opposeWarPowerMultiply = 1; + + if (warPower < 100) { + warPower = clampMin(warPower, 0); + warPower = (warPower + 100) / 2; + warPower = this.rng.nextRangeInt(warPower, 100); + } + + warPower *= this.getComputedAtmos(); + warPower /= oppose.getComputedTrain(); + + const myDex = this.getDex(this.getCrewType()); + const opposeDex = oppose.getDex(this.getCrewType()); + warPower *= getDexLog(myDex, opposeDex); + + warPower *= this.getCrewType().getAttackCoef(oppose.getCrewType()); + opposeWarPowerMultiply *= this.getCrewType().getDefenceCoef( + oppose.getCrewType() + ); + + this.warPower = warPower; + oppose.setWarPowerMultiply(opposeWarPowerMultiply); + + return [warPower, opposeWarPowerMultiply]; + } + + public addTrain(_train: number): void { + return; + } + + public addAtmos(_atmos: number): void { + return; + } + + public addTrainBonus(trainBonus: number): void { + this.trainBonus += trainBonus; + } + + public addAtmosBonus(atmosBonus: number): void { + this.atmosBonus += atmosBonus; + } + + public getComputedTrain(): number { + return this.config.maxTrainByCommand; + } + + public getComputedAtmos(): number { + return this.config.maxAtmosByCommand; + } + + public getComputedCriticalRatio(): number { + return 0; + } + + public getComputedAvoidRatio(): number { + return this.getCrewType().avoid / 100; + } + + public addWin(): void { + return; + } + + public addLose(): void { + return; + } + + public calcDamage(): number { + const warPower = this.getWarPower() * this.rng.nextRange(0.9, 1.1); + return round(warPower); + } + + public criticalDamage(): number { + return this.rng.nextRange(...WAR_CRITICAL_RANGE); + } + + public logBattleResult(): void { + const oppose = this.getOppose(); + if (!oppose || !this.logger) { + return; + } + + const warTypeStr = this.isAttacker() ? '→' : '←'; + const message = `${this.getCrewTypeShortName()} ${this.getName()} ${warTypeStr} ${oppose.getCrewTypeShortName()} ${oppose.getName()} (${this.getHP()} / ${oppose.getHP()})`; + this.logger.pushGeneralBattleResultLog(message, LogFormat.EVENT_YEAR_MONTH); + this.logger.pushGeneralBattleDetailLog(message, LogFormat.EVENT_YEAR_MONTH); + this.logger.pushGeneralActionLog(message, LogFormat.EVENT_YEAR_MONTH); + } + + public tryWound(): boolean { + return false; + } + + public continueWar(): { canContinue: boolean; noRice: boolean } { + return { canContinue: false, noRice: false }; + } + + public abstract getName(): string; + + public abstract getDex(crewType: WarCrewType): number; + + public abstract getComputedAttack(): number; + + public abstract getComputedDefence(): number; + + public abstract getHP(): number; + + public abstract decreaseHP(damage: number): number; + + public abstract increaseKilled(damage: number): number; + + public abstract finishBattle(): void; +} + +// 장수 전투 유닛(legacy WarUnitGeneral 포팅). +export class WarUnitGeneral< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends WarUnit { + private readonly actionPipeline: WarActionPipeline; + private killedPerson = 0; + private deadPerson = 0; + + constructor( + rng: RandUtil, + config: WarEngineConfig, + private readonly general: General, + private readonly city: City, + nation: Nation | null, + isAttacker: boolean, + crewType: WarCrewType, + logger: ActionLogger, + pipeline: WarActionPipeline + ) { + super(rng, config, crewType, logger, isAttacker, nation); + this.actionPipeline = pipeline; + + if (isAttacker) { + if (this.city.level === 2) { + this.atmosBonus += 5; + } + if (nation && nation.capitalCityId === this.city.id) { + this.atmosBonus += 5; + } + } else { + if (this.city.level === 1 || this.city.level === 3) { + this.trainBonus += 5; + } + } + } + + public getGeneral(): General { + return this.general; + } + + public getActionContext(): WarActionContext { + return { + general: this.general, + nation: this.nation, + city: this.city, + log: this.logger, + rng: this.rng, + }; + } + + public getActionPipeline(): WarActionPipeline { + return this.actionPipeline; + } + + public override getName(): string { + return this.general.name; + } + + public getCityVar(key: keyof City): TriggerValue | null { + const value = this.city[key]; + if (typeof value === 'number' || typeof value === 'string') { + return value; + } + return null; + } + + public override setOppose(oppose: WarUnit | null): void { + super.setOppose(oppose); + increaseMetaNumber(this.general.meta, RANK_WARNUM, 1); + + if (!oppose) { + return; + } + + const baseTurnTime = this.isAttacker() + ? getMetaString(this.general.meta, META_TURN_TIME) + : oppose instanceof WarUnitGeneral + ? getMetaString(oppose.general.meta, META_TURN_TIME) + : getMetaString(this.general.meta, META_TURN_TIME); + if (!baseTurnTime) { + return; + } + + const base = baseTurnTime.slice(0, Math.max(0, baseTurnTime.length - 2)); + const phase = clamp(this.getRealPhase(), 0, 99); + const phaseText = phase.toString().padStart(2, '0'); + this.general.meta[META_RECENT_WAR] = `${base}${phaseText}`; + } + + public override getMaxPhase(): number { + const base = this.getCrewType().speed; + const aux = { isAttacker: this.isAttacker() }; + const context = this.getActionContext(); + const phase = this.actionPipeline.onCalcStat( + context, + 'initWarPhase', + base, + aux + ); + return phase + this.bonusPhase; + } + + private resolveStatValue(statName: keyof StatBlock, base: number): number { + return this.actionPipeline.onCalcStat( + this.getActionContext(), + statName, + base + ); + } + + private resolveMainStat(armType: number): number { + const leadership = this.resolveStatValue( + 'leadership', + this.general.stats.leadership + ); + const strength = this.resolveStatValue( + 'strength', + this.general.stats.strength + ); + const intelligence = this.resolveStatValue( + 'intelligence', + this.general.stats.intelligence + ); + + if (armType === this.config.armTypes.wizard) { + return intelligence; + } + if (armType === this.config.armTypes.siege) { + return leadership; + } + if (armType === this.config.armTypes.misc) { + return (intelligence + leadership + strength) / 3; + } + return strength; + } + + private resolveOpposeStatValue( + statName: string, + value: number, + aux?: Record + ): number { + const oppose = this.getOppose(); + if (!(oppose instanceof WarUnitGeneral)) { + return value; + } + return oppose + .getActionPipeline() + .onCalcOpposeStat(this.getActionContext(), statName, value, aux); + } + + public override getDex(crewType: WarCrewType): number { + const armType = + crewType.armType === this.config.armTypes.castle + ? this.config.armTypes.siege ?? crewType.armType + : crewType.armType; + const base = getMetaNumber(this.general.meta, `${META_DEX_PREFIX}${armType}`); + const aux = { + isAttacker: this.isAttacker(), + opposeType: this.oppose?.getCrewType() ?? null, + }; + let dex = this.actionPipeline.onCalcStat( + this.getActionContext(), + `dex${armType}`, + base, + aux + ); + dex = this.resolveOpposeStatValue(`dex${armType}`, dex, aux); + return dex; + } + + public override getComputedAttack(): number { + const tech = resolveNationTech(this.nation); + const armType = this.getCrewType().armType; + const mainStat = this.resolveMainStat(armType); + let ratio = mainStat * 2 - 40; + + ratio = clampMin(ratio, 10); + if (ratio > 100) { + ratio = 50 + ratio / 2; + } + + const attack = this.getCrewType().attack + getTechAbility(tech); + return attack * (ratio / 100); + } + + public override getComputedDefence(): number { + const tech = resolveNationTech(this.nation); + const defence = this.getCrewType().defence + getTechAbility(tech); + const crew = this.general.crew / (7000 / 30) + 70; + return defence * (crew / 100); + } + + public override getComputedTrain(): number { + const aux = { isAttacker: this.isAttacker() }; + let train = this.actionPipeline.onCalcStat( + this.getActionContext(), + 'bonusTrain', + this.general.train, + aux + ); + train = this.resolveOpposeStatValue('bonusTrain', train, aux); + train += this.trainBonus; + return train; + } + + public override getComputedAtmos(): number { + const aux = { isAttacker: this.isAttacker() }; + let atmos = this.actionPipeline.onCalcStat( + this.getActionContext(), + 'bonusAtmos', + this.general.atmos, + aux + ); + atmos = this.resolveOpposeStatValue('bonusAtmos', atmos, aux); + atmos += this.atmosBonus; + return atmos; + } + + public override getComputedCriticalRatio(): number { + const armType = this.getCrewType().armType; + if (armType === this.config.armTypes.castle) { + return 0; + } + + const mainStat = this.resolveMainStat(armType); + const coef = + armType === this.config.armTypes.wizard || + armType === this.config.armTypes.siege || + armType === this.config.armTypes.misc + ? 0.4 + : 0.5; + + let ratio = clampMin(mainStat - 65, 0) * coef; + ratio = Math.min(50, ratio) / 100; + + const aux = { isAttacker: this.isAttacker() }; + ratio = this.actionPipeline.onCalcStat( + this.getActionContext(), + 'warCriticalRatio', + ratio, + aux + ); + ratio = this.resolveOpposeStatValue('warCriticalRatio', ratio, aux); + return ratio; + } + + public override getComputedAvoidRatio(): number { + const oppose = this.getOppose(); + let avoidRatio = this.getCrewType().avoid / 100; + avoidRatio *= this.getComputedTrain() / 100; + + const aux = { isAttacker: this.isAttacker() }; + avoidRatio = this.actionPipeline.onCalcStat( + this.getActionContext(), + 'warAvoidRatio', + avoidRatio, + aux + ); + avoidRatio = this.resolveOpposeStatValue('warAvoidRatio', avoidRatio, aux); + + if ( + oppose && + oppose.getCrewType().armType === this.config.armTypes.footman + ) { + avoidRatio *= 0.75; + } + + return avoidRatio; + } + + public override addTrain(train: number): void { + this.general.train = clamp( + this.general.train + train, + 0, + this.config.maxTrainByWar + ); + } + + public override addAtmos(atmos: number): void { + this.general.atmos = clamp( + this.general.atmos + atmos, + 0, + this.config.maxAtmosByWar + ); + } + + public override addWin(): void { + increaseMetaNumber(this.general.meta, RANK_KILLNUM, 1); + + if (this.oppose instanceof WarUnitCity) { + increaseMetaNumber(this.general.meta, RANK_OCCUPIED, 1); + } + + const atmosMultiplier = this.isAttacker() ? 1.1 : 1.05; + this.general.atmos = clamp( + Math.round(this.general.atmos * atmosMultiplier), + 0, + this.config.maxAtmosByWar + ); + + this.addStatExp(1); + } + + public override addLose(): void { + increaseMetaNumber(this.general.meta, RANK_DEATHNUM, 1); + this.addStatExp(1); + } + + public addStatExp(value = 1): void { + const armType = this.getCrewType().armType; + if (armType === this.config.armTypes.wizard) { + increaseMetaNumber(this.general.meta, META_INTEL_EXP, value); + return; + } + if (armType === this.config.armTypes.siege) { + increaseMetaNumber(this.general.meta, META_LEADERSHIP_EXP, value); + return; + } + increaseMetaNumber(this.general.meta, META_STRENGTH_EXP, value); + } + + public addLevelExp(value: number): void { + const adjust = this.isAttacker() ? value : value * 0.8; + this.general.experience += adjust; + } + + public addDedication(value: number): void { + this.general.dedication += value; + } + + public addDex(crewType: WarCrewType, exp: number): void { + const armType = crewType.armType; + const key = `${META_DEX_PREFIX}${armType}`; + const base = getMetaNumber(this.general.meta, key); + this.general.meta[key] = round(base + exp); + } + + public calcRiceConsumption(damage: number): number { + let rice = damage / 100; + if (!this.isAttacker()) { + rice *= 0.8; + } + if (this.oppose instanceof WarUnitCity) { + rice *= 0.8; + } + rice *= this.getCrewType().rice; + rice *= getTechCost(resolveNationTech(this.nation)); + rice = this.actionPipeline.onCalcStat( + this.getActionContext(), + 'killRice', + rice + ); + return rice; + } + + public override increaseKilled(damage: number): number { + this.addLevelExp(damage / 50); + + const rice = this.calcRiceConsumption(damage); + this.general.rice = clampMin(this.general.rice - rice, 0); + + let addDex = damage; + if (!this.isAttacker()) { + addDex *= 0.8; + } + this.addDex(this.getCrewType(), addDex); + + this.killed += damage; + this.killedCurr += damage; + + if (this.oppose instanceof WarUnitGeneral) { + this.killedPerson += damage; + } + return this.killed; + } + + public override getHP(): number { + return this.general.crew; + } + + public override decreaseHP(damage: number): number { + const maxDamage = Math.min(damage, this.general.crew); + + this.dead += maxDamage; + this.deadCurr += maxDamage; + this.general.crew -= maxDamage; + + let addDex = maxDamage; + if (!this.isAttacker()) { + addDex *= 0.1; + } + if (this.oppose) { + this.addDex(this.oppose.getCrewType(), addDex); + } + + if (this.oppose instanceof WarUnitGeneral) { + this.deadPerson += maxDamage; + } + + return this.general.crew; + } + + public override computeWarPower(): [number, number] { + const [warPower, opposeWarPowerMultiply] = super.computeWarPower(); + const oppose = this.getOppose(); + if (!oppose) { + return [warPower, opposeWarPowerMultiply]; + } + + const expLevel = getMetaNumber(this.general.meta, META_EXP_LEVEL, 0); + let nextWarPower = warPower; + let nextOpposeMultiply = opposeWarPowerMultiply; + + if (oppose instanceof WarUnitCity) { + nextWarPower *= 1 + expLevel / 600; + } else { + const ratio = clampMin(1 - expLevel / 300, 0.01); + nextWarPower /= ratio; + nextOpposeMultiply *= ratio; + } + + const [myMul, opposeMul] = this.actionPipeline.getWarPowerMultiplier( + this.getActionContext(), + this, + oppose + ); + nextWarPower *= myMul; + nextOpposeMultiply *= opposeMul; + + this.warPower = nextWarPower; + oppose.setWarPowerMultiply(nextOpposeMultiply); + return [nextWarPower, nextOpposeMultiply]; + } + + public override criticalDamage(): number { + let range: [number, number] = WAR_CRITICAL_RANGE; + range = this.actionPipeline.onCalcStat( + this.getActionContext(), + 'criticalDamageRange', + range + ); + return this.rng.nextRange(...range); + } + + public override tryWound(): boolean { + if (this.hasActivatedSkillOnLog('부상무효')) { + return false; + } + if (this.hasActivatedSkillOnLog('퇴각부상무효')) { + return false; + } + if (!this.rng.nextBool(0.05)) { + return false; + } + + this.activateSkill('부상'); + this.general.injury = clamp( + this.general.injury + this.rng.nextRangeInt(10, 80), + 0, + 80 + ); + this.logger.pushGeneralActionLog('전투중 부상당했다!', LogFormat.PLAIN); + return true; + } + + public override continueWar(): { canContinue: boolean; noRice: boolean } { + if (this.general.crew <= 0) { + return { canContinue: false, noRice: false }; + } + if (this.general.rice <= this.general.crew / 100) { + return { canContinue: false, noRice: true }; + } + return { canContinue: true, noRice: false }; + } + + public override finishBattle(): void { + if (this.isFinished) { + return; + } + this.clearActivatedSkill(); + this.isFinished = true; + + increaseMetaNumber(this.general.meta, RANK_KILLCREW, this.killed); + increaseMetaNumber(this.general.meta, RANK_DEATHCREW, this.dead); + + if (this.killedPerson) { + increaseMetaNumber( + this.general.meta, + RANK_KILLCREW_PERSON, + this.killedPerson + ); + } + if (this.deadPerson) { + increaseMetaNumber( + this.general.meta, + RANK_DEATHCREW_PERSON, + this.deadPerson + ); + } + + this.general.rice = round(this.general.rice); + this.general.experience = round(this.general.experience); + this.general.dedication = round(this.general.dedication); + } +} + +// 도시 성벽 전투 유닛(legacy WarUnitCity 포팅). +export class WarUnitCity extends WarUnit { + private hp: number; + private cityTrainAtmos: number; + private onSiege = false; + + constructor( + rng: RandUtil, + config: WarEngineConfig, + private readonly city: City, + nation: Nation | null, + crewType: WarCrewType, + logger: ActionLogger, + year: number, + startYear: number + ) { + super(rng, config, crewType, logger, false, nation); + this.cityTrainAtmos = clamp(year - startYear + 59, 60, 110); + this.hp = this.city.defence * 10; + + if (this.city.level === 1 || this.city.level === 3) { + this.trainBonus += 5; + } + } + + public getCityTrainAtmos(): number { + return this.cityTrainAtmos; + } + + public getCityId(): number { + return this.city.id; + } + + public override getName(): string { + return this.city.name; + } + + public override getComputedAttack(): number { + return (this.city.defence + this.city.wall * 9) / 500 + 200; + } + + public override getComputedDefence(): number { + return (this.city.defence + this.city.wall * 9) / 500 + 200; + } + + public override increaseKilled(damage: number): number { + this.killed += damage; + this.killedCurr += damage; + return this.killed; + } + + public override getComputedTrain(): number { + return this.cityTrainAtmos + this.trainBonus; + } + + public override getComputedAtmos(): number { + return this.cityTrainAtmos + this.atmosBonus; + } + + public override getHP(): number { + return this.hp; + } + + public setSiege(): void { + this.onSiege = true; + this.currPhase = 0; + this.prePhase = 0; + this.bonusPhase = 0; + this.isFinished = false; + } + + public isSiege(): boolean { + return this.onSiege; + } + + public override getDex(_crewType: WarCrewType): number { + return (this.cityTrainAtmos - 60) * 7200; + } + + public override decreaseHP(damage: number): number { + const applied = Math.min(damage, this.hp); + this.dead += applied; + this.deadCurr += applied; + this.hp -= applied; + + this.city.wall = clampMin(this.city.wall - applied / 20, 0); + return this.hp; + } + + public override continueWar(): { canContinue: boolean; noRice: boolean } { + if (!this.onSiege) { + return { canContinue: false, noRice: false }; + } + if (this.hp <= 0) { + return { canContinue: false, noRice: false }; + } + return { canContinue: true, noRice: false }; + } + + public heavyDecreaseWealth(): void { + this.city.agriculture = round(this.city.agriculture * 0.5); + this.city.commerce = round(this.city.commerce * 0.5); + this.city.security = round(this.city.security * 0.5); + } + + public override finishBattle(): void { + this.clearActivatedSkill(); + this.isFinished = true; + + this.city.defence = round(this.hp / 10); + this.city.wall = round(this.city.wall); + + // legacy에서는 onSiege 전투로 끝난 경우에만 추가 피해 처리했지만, + // 실제 구현은 이미 return 되도록 되어 있어 그대로 유지한다. + if (this.isFinished || !this.onSiege) { + return; + } + + const decWealth = this.getKilled() / 20; + this.city.agriculture = clampMin(this.city.agriculture - decWealth, 0); + this.city.commerce = clampMin(this.city.commerce - decWealth, 0); + this.city.security = clampMin(this.city.security - decWealth, 0); + } + + public addConflict(): boolean { + const raw = this.city.meta.conflict; + const conflict = parseConflict(raw) ?? {}; + const oppose = this.getOppose(); + + const nationId = oppose?.getNationVar('nation'); + if (typeof nationId !== 'number') { + return false; + } + + let dead = Math.max(1, this.dead); + let isNew = false; + + if (Object.keys(conflict).length === 0 || this.getHP() === 0) { + dead *= 1.05; + } + + if (conflict[nationId] !== undefined) { + conflict[nationId] += dead; + } else { + conflict[nationId] = dead; + isNew = true; + } + + const sorted = sortConflict(conflict); + this.city.meta.conflict = stringifyConflict(sorted); + + return isNew; + } + + public override logBattleResult(): void { + return; + } +} diff --git a/packages/logic/src/war/utils.ts b/packages/logic/src/war/utils.ts new file mode 100644 index 0000000..8eaa2dd --- /dev/null +++ b/packages/logic/src/war/utils.ts @@ -0,0 +1,156 @@ +import type { TriggerValue } from '../domain/entities.js'; + +const DEX_LEVEL_THRESHOLDS = [ + 0, + 350, + 1375, + 3500, + 7125, + 12650, + 20475, + 31000, + 44625, + 61750, + 82775, + 108100, + 138125, + 173250, + 213875, + 260400, + 313225, + 372750, + 439375, + 513500, + 595525, + 685850, + 784875, + 893000, + 1010625, + 1138150, + 1275975, +]; + +export const clamp = (value: number, min: number, max: number): number => + Math.max(min, Math.min(value, max)); + +export const clampMin = (value: number, min: number): number => + value < min ? min : value; + +export const clampMax = (value: number, max: number): number => + value > max ? max : value; + +export const round = (value: number): number => Math.round(value); + +export const getMetaNumber = ( + meta: Record, + key: string, + fallback = 0 +): number => { + const value = meta[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +}; + +export const getMetaString = ( + meta: Record, + key: string +): string | null => { + const value = meta[key]; + return typeof value === 'string' ? value : null; +}; + +export const setMetaNumber = ( + meta: Record, + key: string, + value: number +): void => { + meta[key] = round(value); +}; + +export const increaseMetaNumber = ( + meta: Record, + key: string, + delta: number +): number => { + const next = getMetaNumber(meta, key) + delta; + meta[key] = round(next); + return next; +}; + +export const getDexLevel = (dex: number): number => { + if (!Number.isFinite(dex) || dex < 0) { + return 0; + } + + let level = 0; + for (let idx = 0; idx < DEX_LEVEL_THRESHOLDS.length; idx += 1) { + const threshold = DEX_LEVEL_THRESHOLDS[idx]!; + if (dex < threshold) { + break; + } + level = idx; + } + return level; +}; + +export const getDexLog = (dex1: number, dex2: number): number => + (getDexLevel(dex1) - getDexLevel(dex2)) / 55 + 1; + +export const parseConflict = ( + raw: TriggerValue | undefined +): Record | null => { + if (raw === undefined || raw === null) { + return null; + } + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw) as Record; + const result: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + const id = Number(key); + if (!Number.isFinite(id) || typeof value !== 'number') { + continue; + } + result[id] = value; + } + return Object.keys(result).length ? result : null; + } catch { + return null; + } + } + return null; +}; + +export const stringifyConflict = ( + conflict: Record | null +): string => { + if (!conflict) { + return '{}'; + } + const sorted = Object.entries(conflict) + .map(([key, value]) => [Number(key), value] as const) + .filter(([key, value]) => Number.isFinite(key) && typeof value === 'number') + .sort(([, lhs], [, rhs]) => rhs - lhs); + + const ordered: Record = {}; + for (const [key, value] of sorted) { + ordered[String(key)] = value; + } + + return JSON.stringify(ordered); +}; + +export const sortConflict = ( + conflict: Record +): Record => { + const ordered: Record = {}; + const entries = Object.entries(conflict) + .map(([key, value]) => [Number(key), value] as const) + .filter(([key, value]) => Number.isFinite(key) && typeof value === 'number') + .sort(([, lhs], [, rhs]) => rhs - lhs); + + for (const [key, value] of entries) { + ordered[key] = value; + } + + return ordered; +}; diff --git a/packages/logic/test/warEngine.test.ts b/packages/logic/test/warEngine.test.ts new file mode 100644 index 0000000..7f96f0f --- /dev/null +++ b/packages/logic/test/warEngine.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from 'vitest'; + +import { ConstantRNG, RandUtil } from '@sammo-ts/common'; + +import type { City, General, Nation } from '../src/domain/entities.js'; +import type { UnitSetDefinition } from '../src/world/types.js'; +import { ActionLogger } from '../src/logging/actionLogger.js'; +import { WarActionPipeline } from '../src/war/actions.js'; +import { resolveWarBattle } from '../src/war/engine.js'; +import type { WarEngineConfig } from '../src/war/types.js'; +import { WarCrewType } from '../src/war/crewType.js'; +import { ChePilsalActivateTrigger, ChePilsalAttemptTrigger } from '../src/war/triggersChePilsal.js'; +import { WarUnitCity, WarUnitGeneral } from '../src/war/units.js'; + +const buildConfig = (): WarEngineConfig => ({ + armPerPhase: 500, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + maxTrainByWar: 110, + maxAtmosByWar: 150, + castleCrewTypeId: 999, + armTypes: { + footman: 1, + wizard: 4, + siege: 5, + misc: 6, + castle: 9, + }, +}); + +const buildUnitSet = (): UnitSetDefinition => ({ + 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: 0, + requirements: [], + attackCoef: {}, + defenceCoef: {}, + info: [], + initSkillTrigger: null, + phaseSkillTrigger: null, + iActionList: null, + }, + ], +}); + +const buildNation = (): Nation => ({ + id: 1, + name: 'TestNation', + color: '#000000', + capitalCityId: 1, + chiefGeneralId: null, + gold: 1000, + rice: 1000, + power: 0, + level: 1, + typeCode: 'test', + meta: { + tech: 3000, + }, +}); + +const buildCity = (): City => ({ + id: 1, + name: 'TestCity', + nationId: 1, + level: 2, + population: 10000, + populationMax: 10000, + agriculture: 500, + agricultureMax: 1000, + commerce: 500, + commerceMax: 1000, + security: 500, + securityMax: 1000, + defence: 100, + defenceMax: 200, + supplyState: 1, + frontState: 0, + wall: 100, + wallMax: 200, + meta: {}, +}); + +const buildGeneral = (strength: number): General => ({ + id: 1, + name: 'Tester', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { + leadership: 70, + strength, + intelligence: 70, + }, + experience: 0, + dedication: 0, + officerLevel: 3, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { + horse: null, + weapon: null, + book: null, + item: null, + }, + }, + injury: 0, + gold: 1000, + rice: 1000, + crew: 1000, + crewTypeId: 100, + train: 80, + atmos: 80, + age: 20, + npcState: 0, + triggerState: { + flags: {}, + counters: {}, + modifiers: {}, + meta: {}, + }, + meta: { + dex1: 5000, + }, +}); + +describe('war triggers', () => { + it('activates and applies critical damage', () => { + const rng = new RandUtil(new ConstantRNG(0)); + const config = buildConfig(); + const crewType = new WarCrewType(buildUnitSet().crewTypes?.[0]!); + const nation = buildNation(); + const city = buildCity(); + + const attacker = new WarUnitGeneral( + rng, + config, + buildGeneral(80), + city, + nation, + true, + crewType, + new ActionLogger({ generalId: 1, nationId: 1 }), + new WarActionPipeline([]) + ); + const defender = new WarUnitCity( + rng, + config, + city, + nation, + new WarCrewType(buildUnitSet().crewTypes?.[1]!), + new ActionLogger({}), + 200, + 180 + ); + + attacker.setOppose(defender); + defender.setOppose(attacker); + + attacker.beginPhase(); + + const attempt = new ChePilsalAttemptTrigger(attacker); + attempt.action({ rng, attacker, defender }, { e_attacker: {}, e_defender: {} }); + + expect(attacker.hasActivatedSkill('필살')).toBe(true); + + const activate = new ChePilsalActivateTrigger(attacker); + activate.action({ rng, attacker, defender }, { e_attacker: {}, e_defender: {} }); + + expect(attacker.getWarPowerMultiply()).toBeCloseTo(1.3); + }); +}); + +describe('resolveWarBattle', () => { + it('handles supply rout when defender nation has no rice', () => { + const rng = new RandUtil(new ConstantRNG(0)); + const config = buildConfig(); + const unitSet = buildUnitSet(); + const attackerNation = buildNation(); + const defenderNation = { ...buildNation(), rice: 0 }; + const city = buildCity(); + + const outcome = resolveWarBattle({ + rng, + unitSet, + config, + time: { + year: 200, + month: 1, + startYear: 180, + }, + attacker: { + general: buildGeneral(80), + city, + nation: attackerNation, + }, + defenders: [], + defenderCity: city, + defenderNation, + }); + + expect(outcome.conquered).toBe(true); + expect(outcome.reports.length).toBeGreaterThan(0); + }); +});