feat: implement war engine with unit types, battle mechanics, and logging

- Added types for war engine configuration, battle input, and outcomes.
- Implemented core war unit classes for generals and cities, including combat logic.
- Introduced utility functions for managing meta data and conflict resolution.
- Developed tests for war triggers and battle resolution scenarios.
This commit is contained in:
2025-12-30 10:25:48 +00:00
parent d599d94a7b
commit b0c4f84656
11 changed files with 2560 additions and 0 deletions
+1
View File
@@ -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';
+143
View File
@@ -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<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
general: General<TriggerState>;
nation?: Nation | null;
city?: City;
log?: ActionLogger;
rng?: RandUtil;
}
export interface WarActionModule<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
getName?(): string;
getInfo?(): string;
getBattleInitTriggerList?(
context: WarActionContext<TriggerState>
): WarTriggerCaller | null;
getBattlePhaseTriggerList?(
context: WarActionContext<TriggerState>
): WarTriggerCaller | null;
onCalcStat?(
context: WarActionContext<TriggerState>,
statName: string,
value: number | [number, number],
aux?: unknown
): number | [number, number];
onCalcOpposeStat?(
context: WarActionContext<TriggerState>,
statName: string,
value: number | [number, number],
aux?: unknown
): number | [number, number];
getWarPowerMultiplier?(
context: WarActionContext<TriggerState>,
unit: WarUnit<TriggerState>,
oppose: WarUnit<TriggerState>
): [number, number];
}
export class WarActionPipeline<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
// 전투용 iAction 파이프라인: 스탯/트리거/전투력 보정 흐름을 순서대로 적용한다.
private readonly modules: WarActionModule<TriggerState>[];
constructor(modules: Array<WarActionModule<TriggerState> | null | undefined>) {
this.modules = modules.filter(Boolean) as WarActionModule<TriggerState>[];
}
getBattleInitTriggerList(
context: WarActionContext<TriggerState>
): 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<TriggerState>
): WarTriggerCaller {
const caller = new WarTriggerCaller();
for (const module of this.modules) {
const triggers = module.getBattlePhaseTriggerList?.(context);
if (triggers) {
caller.merge(triggers);
}
}
return caller;
}
onCalcStat<T extends number | [number, number]>(
context: WarActionContext<TriggerState>,
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<T extends number | [number, number]>(
context: WarActionContext<TriggerState>,
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<TriggerState>,
unit: WarUnit<TriggerState>,
oppose: WarUnit<TriggerState>
): [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];
}
}
+77
View File
@@ -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;
}
}
+645
View File
@@ -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<number, WarCrewType>,
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<number, WarCrewType> => {
const index = new Map<number, WarCrewType>();
const crewTypes = buildCrewTypeDefinitionIndex(unitSet);
for (const [id, crewType] of crewTypes) {
index.set(id, new WarCrewType(crewType));
}
return index;
};
const createPipeline = <TriggerState extends GeneralTriggerState>(
input: WarGeneralInput<TriggerState>
): WarActionPipeline<TriggerState> =>
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<ReturnType<ActionLogger['flush']>[number]> => {
const logs: Array<ReturnType<ActionLogger['flush']>[number]> = [];
for (const logger of loggers) {
logs.push(...logger.flush());
}
return logs;
};
export const resolveWarBattle = <
TriggerState extends GeneralTriggerState = GeneralTriggerState
>(
input: WarBattleInput<TriggerState>
): WarBattleOutcome<TriggerState> => {
// 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<TriggerState>[] = [];
const defenderGenerals: WarUnitGeneral<TriggerState>[] = [];
for (const defender of input.defenders) {
const defenderLogger =
defender.logger ??
loggerFactory({
generalId: defender.general.id,
nationId: defender.general.nationId,
});
const unit = new WarUnitGeneral(
rng,
input.config,
defender.general,
input.defenderCity,
defender.nation,
false,
resolveCrewType(crewTypeIndex, defender.general.crewTypeId),
defenderLogger,
createPipeline(defender)
);
if (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<TriggerState> | null = null;
const getNextDefender = (
_prevDefender: WarUnit<TriggerState> | null,
reqNext: boolean
): WarUnit<TriggerState> | null => {
if (!reqNext) {
return null;
}
const next = iter.next();
if (next.done) {
return null;
}
const candidate = next.value as WarUnit<TriggerState>;
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(
`<D><b>${attackerNationName}</b></>의 <Y>${attackerName}</>${josaYi} <G><b>${cityName}</b></>${josaRo} 진격합니다.${seedText}`,
LogFormat.MONTH
);
attackerLogger.pushGeneralActionLog(
`<G><b>${cityName}</b></>${josaRo} <M>진격</>합니다.${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(
`병량 부족으로 <G><b>${cityName}</b></>의 수비병들이 <R>패퇴</>합니다.`,
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(
`<Y>${attackerName}</>의 ${attackerCrewName}${josaWa} <Y>${defenderName}</>의 ${defenderCrewName}${josaYiDef} 대결합니다.`,
LogFormat.MONTH
);
const josaRo = JosaUtil.pick(attackerCrewName, '로');
const josaUl = JosaUtil.pick(defenderCrewName, '을');
attackerUnit.getLogger().pushGeneralActionLog(
`${attackerCrewName}${josaRo} <Y>${defenderName}</>의 ${defenderCrewName}${josaUl} <M>공격</>합니다.`,
LogFormat.MONTH
);
const defJosaRo = JosaUtil.pick(defenderCrewName, '로');
const defJosaUl = JosaUtil.pick(attackerCrewName, '을');
defender.getLogger().pushGeneralActionLog(
`${defenderCrewName}${defJosaRo} <Y>${attackerName}</>의 ${attackerCrewName}${defJosaUl} <M>수비</>합니다.`,
LogFormat.MONTH
);
} else {
const josaYiName = JosaUtil.pick(attackerName, '이');
const josaRoCrew = JosaUtil.pick(attackerCrewName, '로');
attackerLogger.pushGlobalActionLog(
`<Y>${attackerName}</>${josaYiName} ${attackerCrewName}${josaRoCrew} 성벽을 공격합니다.`,
LogFormat.MONTH
);
attackerLogger.pushGeneralActionLog(
`${attackerCrewName}${josaRoCrew} 성벽을 <M>공격</>합니다.`,
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}: <Y1>【${attackerUnit.getName()}】</> <C>${attackerUnit.getHP()} (-${deadAttacker})</> VS <C>${defender.getHP()} (-${deadDefender})</> <Y1>【${defender.getName()}】</>`
);
defender.getLogger().pushGeneralBattleDetailLog(
`${phaseNickname}: <Y1>【${defender.getName()}】</> <C>${defender.getHP()} (-${deadDefender})</> VS <C>${attackerUnit.getHP()} (-${deadAttacker})</> <Y1>【${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(
`<Y>${attackerUnit.getName()}</>의 ${attackerUnit.getCrewTypeName()}${josaYiCrew} 퇴각했습니다.`,
LogFormat.MONTH
);
attackerUnit.getLogger().pushGeneralActionLog(
attackerState.noRice ? '군량 부족으로 퇴각합니다.' : '퇴각했습니다.',
LogFormat.PLAIN
);
defender.getLogger().pushGeneralActionLog(
`<Y>${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(
`<Y>${defender.getName()}</>의 ${defender.getCrewTypeName()}${josaYiDefCrew} 패퇴했습니다.`,
LogFormat.MONTH
);
attackerUnit.getLogger().pushGeneralActionLog(
`<Y>${defender.getName()}</>의 ${defender.getCrewTypeName()}${josaYiDefCrew} 패퇴했습니다.`,
LogFormat.PLAIN
);
defender.getLogger().pushGeneralActionLog(
'군량 부족으로 패퇴합니다.',
LogFormat.PLAIN
);
} else {
attackerLogger.pushGlobalActionLog(
`<Y>${defender.getName()}</>의 ${defender.getCrewTypeName()}${josaYiDefCrew} 전멸했습니다.`,
LogFormat.MONTH
);
attackerUnit.getLogger().pushGeneralActionLog(
`<Y>${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(
`<M><b>【분쟁】</b></><D><b>${nationName}</b></>${josaYiNation} <G><b>${cityName}</b></> 공략에 가담하여 분쟁이 발생하고 있습니다.`,
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,
};
};
+8
View File
@@ -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';
+100
View File
@@ -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<string, unknown>;
e_defender: Record<string, unknown>;
stopNextAction?: boolean;
}
export type WarTrigger = Trigger<WarTriggerContext, WarTriggerEnv>;
export const createWarTriggerEnv = (): WarTriggerEnv => ({
e_attacker: {},
e_defender: {},
});
export class WarTriggerCaller extends TriggerCaller<WarTriggerContext, WarTriggerEnv> {}
export type WarTriggerFactory = (unit: WarUnit) => WarTrigger | null;
export type WarTriggerRegistry = Record<string, WarTriggerFactory>;
// 전투 트리거 공통 베이스. 전투 유닛 기준으로 동작하며, 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<string, unknown>,
opposeEnv: Record<string, unknown>
): boolean;
// 아이템 소모 트리거는 아직 미구현. 필요 시 raiseType을 활용해 확장 가능하다.
public processConsumableItem(): boolean {
if (!(this.raiseType & BaseWarUnitTrigger.TYPE_ITEM)) {
return false;
}
return false;
}
}
@@ -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<string, unknown>,
_opposeEnv: Record<string, unknown>
): 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<string, unknown>,
_opposeEnv: Record<string, unknown>
): boolean {
if (!self.hasActivatedSkill('필살')) {
return true;
}
if (selfEnv['필살발동']) {
return true;
}
selfEnv['필살발동'] = true;
oppose
.getLogger()
.pushGeneralBattleDetailLog('상대의 <R>필살</>공격!</>', LogFormat.PLAIN);
self
.getLogger()
.pushGeneralBattleDetailLog('<C>필살</>공격!</>', LogFormat.PLAIN);
self.multiplyWarPowerMultiply(self.criticalDamage());
return true;
}
}
+85
View File
@@ -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<TriggerState>;
city: City;
nation: Nation | null;
logger?: ActionLogger;
modules?: Array<WarActionModule<TriggerState> | null | undefined>;
}
export interface WarBattleInput<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
seed?: string;
rng?: RandUtil;
unitSet: UnitSetDefinition;
config: WarEngineConfig;
time: WarTimeContext;
attacker: WarGeneralInput<TriggerState>;
defenders: WarGeneralInput<TriggerState>[];
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<TriggerState>;
defenders: General<TriggerState>[];
defenderCity: City;
logs: LogEntryDraft[];
conquered: boolean;
reports: WarUnitReport[];
}
+1043
View File
@@ -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<TriggerState> | 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<string, boolean> = {};
protected logActivatedSkill: Record<string, number> = {};
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<TriggerState> | null): void {
this.oppose = oppose;
this.killedCurr = 0;
this.deadCurr = 0;
this.clearActivatedSkill();
}
public getOppose(): WarUnit<TriggerState> | 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<TriggerState> {
private readonly actionPipeline: WarActionPipeline<TriggerState>;
private killedPerson = 0;
private deadPerson = 0;
constructor(
rng: RandUtil,
config: WarEngineConfig,
private readonly general: General<TriggerState>,
private readonly city: City,
nation: Nation | null,
isAttacker: boolean,
crewType: WarCrewType,
logger: ActionLogger,
pipeline: WarActionPipeline<TriggerState>
) {
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<TriggerState> {
return this.general;
}
public getActionContext(): WarActionContext<TriggerState> {
return {
general: this.general,
nation: this.nation,
city: this.city,
log: this.logger,
rng: this.rng,
};
}
public getActionPipeline(): WarActionPipeline<TriggerState> {
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<TriggerState> | 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<string, unknown>
): 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('전투중 <R>부상</>당했다!', 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;
}
}
+156
View File
@@ -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<string, TriggerValue>,
key: string,
fallback = 0
): number => {
const value = meta[key];
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
};
export const getMetaString = (
meta: Record<string, TriggerValue>,
key: string
): string | null => {
const value = meta[key];
return typeof value === 'string' ? value : null;
};
export const setMetaNumber = (
meta: Record<string, TriggerValue>,
key: string,
value: number
): void => {
meta[key] = round(value);
};
export const increaseMetaNumber = (
meta: Record<string, TriggerValue>,
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<number, number> | null => {
if (raw === undefined || raw === null) {
return null;
}
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw) as Record<string, number>;
const result: Record<number, number> = {};
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<number, number> | 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<string, number> = {};
for (const [key, value] of sorted) {
ordered[String(key)] = value;
}
return JSON.stringify(ordered);
};
export const sortConflict = (
conflict: Record<number, number>
): Record<number, number> => {
const ordered: Record<number, number> = {};
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;
};
+237
View File
@@ -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);
});
});