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 './triggers/index.js';
|
||||||
export * from './turn/index.js';
|
export * from './turn/index.js';
|
||||||
export * from './world/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