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:
@@ -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';
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user