feat: add special action modules for domestic and war triggers
- Implemented '발명' and '인덕' domestic special action modules with modifiers for technology research and population management. - Added '의술' and '징병' war special action modules to enhance healing and recruitment capabilities during battles. - Created a general trigger for city healing in '의술' and integrated it with the war action pipeline. - Developed a comprehensive registry and loader for special action modules to streamline their usage in the game logic. - Enhanced test coverage for special action modules, ensuring correct application of modifiers and trigger functionalities.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import type { GeneralActionModule } from '../../triggers/general-action.js';
|
||||
|
||||
export interface TurnCommandEnv {
|
||||
develCost: number;
|
||||
trainDelta: number;
|
||||
@@ -21,4 +23,5 @@ export interface TurnCommandEnv {
|
||||
baseGold: number;
|
||||
baseRice: number;
|
||||
maxResourceActionAmount: number;
|
||||
generalActionModules?: Array<GeneralActionModule>;
|
||||
}
|
||||
|
||||
@@ -431,5 +431,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
||||
category: '내정',
|
||||
reqArg: false,
|
||||
args: {},
|
||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition([], env),
|
||||
createDefinition: (env: TurnCommandEnv) =>
|
||||
new ActionDefinition(env.generalActionModules ?? [], env),
|
||||
};
|
||||
|
||||
@@ -473,5 +473,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
||||
category: '인사',
|
||||
reqArg: false,
|
||||
args: {},
|
||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition([], env),
|
||||
createDefinition: (env: TurnCommandEnv) =>
|
||||
new ActionDefinition(env.generalActionModules ?? [], env),
|
||||
};
|
||||
|
||||
@@ -614,5 +614,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
||||
category: '내정',
|
||||
reqArg: true,
|
||||
args: {},
|
||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition([], {}),
|
||||
createDefinition: (env: TurnCommandEnv) =>
|
||||
new ActionDefinition(env.generalActionModules ?? [], {}),
|
||||
};
|
||||
|
||||
@@ -504,5 +504,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
||||
category: '계략',
|
||||
reqArg: true,
|
||||
args: { destCityId: 0 },
|
||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition([], env),
|
||||
createDefinition: (env: TurnCommandEnv) =>
|
||||
new ActionDefinition(env.generalActionModules ?? [], env),
|
||||
};
|
||||
|
||||
@@ -450,5 +450,6 @@ export const commandSpec: NationTurnCommandSpec = {
|
||||
category: '전략',
|
||||
reqArg: false,
|
||||
args: {},
|
||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition([], env),
|
||||
createDefinition: (env: TurnCommandEnv) =>
|
||||
new ActionDefinition(env.generalActionModules ?? [], env),
|
||||
};
|
||||
|
||||
@@ -3,6 +3,13 @@ import type { RandomGenerator } from '@sammo-ts/common';
|
||||
import type { WorldStateRepository } from '../ports/world.js';
|
||||
import { TriggerCaller, type Trigger } from './core.js';
|
||||
|
||||
export interface GeneralWorldView<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> {
|
||||
listGenerals(): General<TriggerState>[];
|
||||
listGeneralsByCity?(cityId: number): General<TriggerState>[];
|
||||
}
|
||||
|
||||
export interface GeneralActionLogSink {
|
||||
push(message: string): void;
|
||||
}
|
||||
@@ -28,6 +35,7 @@ export const createGeneralSkillActivation = <
|
||||
export interface GeneralActionContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||
general: General<TriggerState>;
|
||||
world?: WorldStateRepository;
|
||||
worldView?: GeneralWorldView<TriggerState>;
|
||||
log?: GeneralActionLogSink;
|
||||
rng?: RandomGenerator;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
|
||||
import type { General, GeneralTriggerState } from '../../domain/entities.js';
|
||||
import { TriggerPriority } from '../core.js';
|
||||
import {
|
||||
BaseGeneralTrigger,
|
||||
type GeneralTriggerContext,
|
||||
} from '../general.js';
|
||||
|
||||
const HEAL_PROBABILITY = 0.5;
|
||||
const MIN_HEAL_INJURY = 10;
|
||||
|
||||
const resolveCityGenerals = <TriggerState extends GeneralTriggerState>(
|
||||
general: General<TriggerState>,
|
||||
context: GeneralTriggerContext<TriggerState>
|
||||
): General<TriggerState>[] => {
|
||||
const worldView = context.worldView;
|
||||
if (!worldView) {
|
||||
return [];
|
||||
}
|
||||
const list = worldView.listGeneralsByCity
|
||||
? worldView.listGeneralsByCity(general.cityId)
|
||||
: worldView.listGenerals().filter(
|
||||
(candidate) => candidate.cityId === general.cityId
|
||||
);
|
||||
return list.filter((candidate) => candidate.id !== general.id);
|
||||
};
|
||||
|
||||
// 의술 특기의 도시 치료 트리거.
|
||||
export class CheUisulCityHealTrigger<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> extends BaseGeneralTrigger<TriggerState> {
|
||||
public readonly priority = TriggerPriority.Begin + 10;
|
||||
|
||||
public constructor(general: General<TriggerState>) {
|
||||
super(general);
|
||||
}
|
||||
|
||||
action(
|
||||
context: GeneralTriggerContext<TriggerState>,
|
||||
env: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const general = context.general;
|
||||
const rng = context.rng;
|
||||
const logger = context.log;
|
||||
|
||||
if (general.injury > 0) {
|
||||
general.injury = 0;
|
||||
context.skill.activate('pre.부상경감', 'pre.치료');
|
||||
logger?.push('<C>의술</>을 펼쳐 스스로 치료합니다!');
|
||||
}
|
||||
|
||||
const candidates = resolveCityGenerals(general, context).filter(
|
||||
(candidate) => {
|
||||
if (candidate.injury <= MIN_HEAL_INJURY) {
|
||||
return false;
|
||||
}
|
||||
if (general.nationId === 0) {
|
||||
return candidate.nationId === 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
const healed = candidates.filter(() => rng.nextBool(HEAL_PROBABILITY));
|
||||
|
||||
for (const patient of healed) {
|
||||
patient.injury = 0;
|
||||
}
|
||||
|
||||
if (healed.length === 0) {
|
||||
return env;
|
||||
}
|
||||
|
||||
const firstName = healed[0]?.name ?? '장수';
|
||||
if (healed.length === 1) {
|
||||
const josa = JosaUtil.pick(firstName, '을');
|
||||
logger?.push(
|
||||
`<C>의술</>을 펼쳐 도시의 장수 <Y>${firstName}</>${josa} 치료합니다!`
|
||||
);
|
||||
} else {
|
||||
const otherCount = healed.length - 1;
|
||||
logger?.push(
|
||||
`<C>의술</>을 펼쳐 도시의 장수들 <Y>${firstName}</> 외 <C>${otherCount}</>명을 치료합니다!`
|
||||
);
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './core.js';
|
||||
export * from './general.js';
|
||||
export * from './general-action.js';
|
||||
export * from './special/index.js';
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { SpecialActionModule } from '../types.js';
|
||||
|
||||
// 내정 특기: 발명
|
||||
export const specialModule: SpecialActionModule = {
|
||||
key: 'che_발명',
|
||||
name: '발명',
|
||||
info: '[내정] 기술 연구 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
|
||||
kind: 'domestic',
|
||||
getName: () => '발명',
|
||||
getInfo: () => '[내정] 기술 연구 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
|
||||
onCalcDomestic: (_context, turnType, varType, value) => {
|
||||
if (turnType === '기술') {
|
||||
if (varType === 'score') {
|
||||
return value * 1.1;
|
||||
}
|
||||
if (varType === 'cost') {
|
||||
return value * 0.8;
|
||||
}
|
||||
if (varType === 'success') {
|
||||
return value + 0.1;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { SpecialActionModule } from '../types.js';
|
||||
|
||||
// 내정 특기: 인덕
|
||||
export const specialModule: SpecialActionModule = {
|
||||
key: 'che_인덕',
|
||||
name: '인덕',
|
||||
info: '[내정] 주민 선정·정착 장려 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
|
||||
kind: 'domestic',
|
||||
getName: () => '인덕',
|
||||
getInfo: () => '[내정] 주민 선정·정착 장려 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
|
||||
onCalcDomestic: (_context, turnType, varType, value) => {
|
||||
if (turnType === '민심' || turnType === '인구') {
|
||||
if (varType === 'score') {
|
||||
return value * 1.1;
|
||||
}
|
||||
if (varType === 'cost') {
|
||||
return value * 0.8;
|
||||
}
|
||||
if (varType === 'success') {
|
||||
return value + 0.1;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import type {
|
||||
SpecialActionModule,
|
||||
SpecialActionModuleExport,
|
||||
} from '../types.js';
|
||||
|
||||
export const DOMESTIC_SPECIAL_KEYS = [
|
||||
'che_인덕',
|
||||
'che_발명',
|
||||
] as const;
|
||||
|
||||
export type DomesticSpecialKey =
|
||||
(typeof DOMESTIC_SPECIAL_KEYS)[number];
|
||||
|
||||
export type DomesticSpecialModule = SpecialActionModule;
|
||||
|
||||
export type DomesticSpecialImporter = () => Promise<SpecialActionModuleExport>;
|
||||
|
||||
const defaultImporters: Record<
|
||||
DomesticSpecialKey,
|
||||
DomesticSpecialImporter
|
||||
> = {
|
||||
che_인덕: async () => import('./che_인덕.js'),
|
||||
che_발명: async () => import('./che_발명.js'),
|
||||
};
|
||||
|
||||
export const isDomesticSpecialKey = (
|
||||
value: string
|
||||
): value is DomesticSpecialKey =>
|
||||
DOMESTIC_SPECIAL_KEYS.includes(value as DomesticSpecialKey);
|
||||
|
||||
export class DomesticSpecialLoader {
|
||||
private readonly cache = new Map<
|
||||
DomesticSpecialKey,
|
||||
Promise<DomesticSpecialModule>
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly importers: Record<
|
||||
DomesticSpecialKey,
|
||||
DomesticSpecialImporter
|
||||
> = defaultImporters
|
||||
) {}
|
||||
|
||||
async load(key: DomesticSpecialKey): Promise<DomesticSpecialModule> {
|
||||
const cached = this.cache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const importer = this.importers[key];
|
||||
if (!importer) {
|
||||
throw new Error(`Unknown domestic special key: ${key}`);
|
||||
}
|
||||
const loading = importer().then((module) => {
|
||||
if (!('specialModule' in module)) {
|
||||
throw new Error(`Missing specialModule for domestic special: ${key}`);
|
||||
}
|
||||
const resolved = module.specialModule;
|
||||
if (resolved.key !== key) {
|
||||
throw new Error(
|
||||
`Domestic special key mismatch: expected ${key}, got ${resolved.key}`
|
||||
);
|
||||
}
|
||||
if (resolved.kind !== 'domestic') {
|
||||
throw new Error(
|
||||
`Domestic special kind mismatch: ${resolved.key}`
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
});
|
||||
this.cache.set(key, loading);
|
||||
return loading;
|
||||
}
|
||||
}
|
||||
|
||||
export const loadDomesticSpecialModules = async (
|
||||
keys: DomesticSpecialKey[],
|
||||
loader: DomesticSpecialLoader = new DomesticSpecialLoader()
|
||||
): Promise<DomesticSpecialModule[]> => {
|
||||
const modules: DomesticSpecialModule[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const key of keys) {
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
modules.push(await loader.load(key));
|
||||
}
|
||||
return modules;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './types.js';
|
||||
export * from './registry.js';
|
||||
export * from './domestic/index.js';
|
||||
export * from './war/index.js';
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { GeneralTriggerState } from '../../domain/entities.js';
|
||||
import type { GeneralActionContext } from '../general.js';
|
||||
import type { GeneralActionModule } from '../general-action.js';
|
||||
import type { WarActionContext, WarActionModule } from '../../war/actions.js';
|
||||
import type { WarUnit } from '../../war/units.js';
|
||||
import type { WarTriggerCaller } from '../../war/triggers.js';
|
||||
import type {
|
||||
SpecialActionKind,
|
||||
SpecialActionModule,
|
||||
SpecialActionModuleRegistry,
|
||||
} from './types.js';
|
||||
|
||||
const resolveSpecialKey = (
|
||||
context: { general: { role: { specialDomestic: string | null; specialWar: string | null } } },
|
||||
kind: SpecialActionKind
|
||||
): string | null =>
|
||||
kind === 'domestic'
|
||||
? context.general.role.specialDomestic
|
||||
: context.general.role.specialWar;
|
||||
|
||||
const resolveModule = <
|
||||
TriggerState extends GeneralTriggerState
|
||||
>(
|
||||
registry: SpecialActionModuleRegistry<TriggerState>,
|
||||
kind: SpecialActionKind,
|
||||
key: string | null
|
||||
): SpecialActionModule<TriggerState> | null => {
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
const bucket = kind === 'domestic' ? registry.domestic : registry.war;
|
||||
return bucket.get(key) ?? null;
|
||||
};
|
||||
|
||||
// General 파이프라인에서 특기 모듈을 선택해 위임하는 라우터.
|
||||
export class SpecialGeneralActionRouter<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> implements GeneralActionModule<TriggerState> {
|
||||
constructor(
|
||||
private readonly kind: SpecialActionKind,
|
||||
private readonly registry: SpecialActionModuleRegistry<TriggerState>
|
||||
) {}
|
||||
|
||||
private getModule(
|
||||
context: GeneralActionContext<TriggerState>
|
||||
): SpecialActionModule<TriggerState> | null {
|
||||
const key = resolveSpecialKey(context, this.kind);
|
||||
return resolveModule(this.registry, this.kind, key);
|
||||
}
|
||||
|
||||
getPreTurnExecuteTriggerList(
|
||||
context: GeneralActionContext<TriggerState>
|
||||
) {
|
||||
const module = this.getModule(context);
|
||||
return module?.getPreTurnExecuteTriggerList?.(context) ?? null;
|
||||
}
|
||||
|
||||
onCalcDomestic(
|
||||
context: GeneralActionContext<TriggerState>,
|
||||
turnType: string,
|
||||
varType: string,
|
||||
value: number,
|
||||
aux?: unknown
|
||||
): number {
|
||||
const module = this.getModule(context);
|
||||
return module?.onCalcDomestic?.(context, turnType, varType, value, aux) ?? value;
|
||||
}
|
||||
|
||||
onCalcStat(
|
||||
context: GeneralActionContext<TriggerState>,
|
||||
statName: string,
|
||||
value: number,
|
||||
aux?: unknown
|
||||
): number {
|
||||
const module = this.getModule(context);
|
||||
return module?.onCalcStat?.(context, statName, value, aux) ?? value;
|
||||
}
|
||||
|
||||
onCalcOpposeStat(
|
||||
context: GeneralActionContext<TriggerState>,
|
||||
statName: string,
|
||||
value: number,
|
||||
aux?: unknown
|
||||
): number {
|
||||
const module = this.getModule(context);
|
||||
return (
|
||||
module?.onCalcOpposeStat?.(context, statName, value, aux) ?? value
|
||||
);
|
||||
}
|
||||
|
||||
onCalcStrategic(
|
||||
context: GeneralActionContext<TriggerState>,
|
||||
turnType: string,
|
||||
varType: string,
|
||||
value: number
|
||||
): number {
|
||||
const module = this.getModule(context);
|
||||
return module?.onCalcStrategic?.(context, turnType, varType, value) ?? value;
|
||||
}
|
||||
|
||||
onCalcNationalIncome(
|
||||
context: GeneralActionContext<TriggerState>,
|
||||
type: string,
|
||||
amount: number
|
||||
): number {
|
||||
const module = this.getModule(context);
|
||||
return module?.onCalcNationalIncome?.(context, type, amount) ?? amount;
|
||||
}
|
||||
|
||||
onArbitraryAction(
|
||||
context: GeneralActionContext<TriggerState>,
|
||||
actionType: string,
|
||||
phase?: string | null,
|
||||
aux?: Record<string, unknown> | null
|
||||
): Record<string, unknown> | null {
|
||||
const module = this.getModule(context);
|
||||
const result = module?.onArbitraryAction?.(
|
||||
context,
|
||||
actionType,
|
||||
phase,
|
||||
aux
|
||||
);
|
||||
return result === undefined ? aux ?? null : result;
|
||||
}
|
||||
}
|
||||
|
||||
// 전투 파이프라인에서 특기 모듈을 선택해 위임하는 라우터.
|
||||
export class SpecialWarActionRouter<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> implements WarActionModule<TriggerState> {
|
||||
constructor(
|
||||
private readonly kind: SpecialActionKind,
|
||||
private readonly registry: SpecialActionModuleRegistry<TriggerState>
|
||||
) {}
|
||||
|
||||
private getModule(
|
||||
context: WarActionContext<TriggerState>
|
||||
): SpecialActionModule<TriggerState> | null {
|
||||
const key = resolveSpecialKey(context, this.kind);
|
||||
return resolveModule(this.registry, this.kind, key);
|
||||
}
|
||||
|
||||
getBattleInitTriggerList(
|
||||
context: WarActionContext<TriggerState>
|
||||
): WarTriggerCaller | null {
|
||||
const module = this.getModule(context);
|
||||
return module?.getBattleInitTriggerList?.(context) ?? null;
|
||||
}
|
||||
|
||||
getBattlePhaseTriggerList(
|
||||
context: WarActionContext<TriggerState>
|
||||
): WarTriggerCaller | null {
|
||||
const module = this.getModule(context);
|
||||
return module?.getBattlePhaseTriggerList?.(context) ?? null;
|
||||
}
|
||||
|
||||
onCalcStat(
|
||||
context: WarActionContext<TriggerState>,
|
||||
statName: string,
|
||||
value: number | [number, number],
|
||||
aux?: unknown
|
||||
): number | [number, number] {
|
||||
const module = this.getModule(context);
|
||||
return module?.onCalcStat?.(context, statName, value, aux) ?? value;
|
||||
}
|
||||
|
||||
onCalcOpposeStat(
|
||||
context: WarActionContext<TriggerState>,
|
||||
statName: string,
|
||||
value: number | [number, number],
|
||||
aux?: unknown
|
||||
): number | [number, number] {
|
||||
const module = this.getModule(context);
|
||||
return module?.onCalcOpposeStat?.(context, statName, value, aux) ?? value;
|
||||
}
|
||||
|
||||
getWarPowerMultiplier(
|
||||
context: WarActionContext<TriggerState>,
|
||||
unit: WarUnit<TriggerState>,
|
||||
oppose: WarUnit<TriggerState>
|
||||
): [number, number] {
|
||||
const module = this.getModule(context);
|
||||
return module?.getWarPowerMultiplier?.(context, unit, oppose) ?? [1, 1];
|
||||
}
|
||||
}
|
||||
|
||||
export interface SpecialActionModuleSet<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> {
|
||||
general: GeneralActionModule<TriggerState>[];
|
||||
war: WarActionModule<TriggerState>[];
|
||||
}
|
||||
|
||||
export const createSpecialActionModuleRegistry = <
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
>(options: {
|
||||
domestic?: SpecialActionModule<TriggerState>[];
|
||||
war?: SpecialActionModule<TriggerState>[];
|
||||
}): SpecialActionModuleRegistry<TriggerState> => {
|
||||
const domestic = new Map<string, SpecialActionModule<TriggerState>>();
|
||||
const war = new Map<string, SpecialActionModule<TriggerState>>();
|
||||
|
||||
for (const module of options.domestic ?? []) {
|
||||
domestic.set(module.key, module);
|
||||
}
|
||||
for (const module of options.war ?? []) {
|
||||
war.set(module.key, module);
|
||||
}
|
||||
|
||||
return { domestic, war };
|
||||
};
|
||||
|
||||
// 특기 레지스트리를 General/전투 파이프라인용 모듈 목록으로 변환한다.
|
||||
export const createSpecialActionModules = <
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
>(
|
||||
registry: SpecialActionModuleRegistry<TriggerState>
|
||||
): SpecialActionModuleSet<TriggerState> => ({
|
||||
general: [
|
||||
new SpecialGeneralActionRouter<TriggerState>('domestic', registry),
|
||||
new SpecialGeneralActionRouter<TriggerState>('war', registry),
|
||||
],
|
||||
war: [
|
||||
new SpecialWarActionRouter<TriggerState>('domestic', registry),
|
||||
new SpecialWarActionRouter<TriggerState>('war', registry),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { GeneralTriggerState } from '../../domain/entities.js';
|
||||
import type { GeneralActionModule } from '../general-action.js';
|
||||
import type { WarActionModule } from '../../war/actions.js';
|
||||
|
||||
export type SpecialActionKind = 'domestic' | 'war';
|
||||
|
||||
export interface SpecialActionSpec {
|
||||
key: string;
|
||||
name: string;
|
||||
info: string;
|
||||
kind: SpecialActionKind;
|
||||
}
|
||||
|
||||
export type SpecialActionModule<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> = SpecialActionSpec &
|
||||
GeneralActionModule<TriggerState> &
|
||||
WarActionModule<TriggerState>;
|
||||
|
||||
export interface SpecialActionModuleExport<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> {
|
||||
specialModule: SpecialActionModule<TriggerState>;
|
||||
}
|
||||
|
||||
export interface SpecialActionModuleRegistry<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> {
|
||||
domestic: Map<string, SpecialActionModule<TriggerState>>;
|
||||
war: Map<string, SpecialActionModule<TriggerState>>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { GeneralTriggerCaller } from '../../general.js';
|
||||
import type { WarActionContext } from '../../../war/actions.js';
|
||||
import { CheUisulCityHealTrigger } from '../../generalTriggers/che_도시치료.js';
|
||||
import { triggerModule as cheUisulTriggerModule } from '../../../war/triggers/che_의술.js';
|
||||
import type { SpecialActionModule } from '../types.js';
|
||||
|
||||
// 전투 특기: 의술
|
||||
export const specialModule: SpecialActionModule = {
|
||||
key: 'che_의술',
|
||||
name: '의술',
|
||||
info: '[군사] 매 턴마다 자신(100%)과 소속 도시 장수(적 포함 50%) 부상 회복<br>[전투] 페이즈마다 40% 확률로 치료 발동(아군 피해 30% 감소, 부상 회복)',
|
||||
kind: 'war',
|
||||
getName: () => '의술',
|
||||
getInfo: () =>
|
||||
'[군사] 매 턴마다 자신(100%)과 소속 도시 장수(적 포함 50%) 부상 회복<br>[전투] 페이즈마다 40% 확률로 치료 발동(아군 피해 30% 감소, 부상 회복)',
|
||||
getPreTurnExecuteTriggerList: (context) =>
|
||||
new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general)),
|
||||
getBattlePhaseTriggerList: (context: WarActionContext) => {
|
||||
const unit = context.unit;
|
||||
if (!unit) {
|
||||
return null;
|
||||
}
|
||||
return cheUisulTriggerModule.createTriggerList(unit);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { GeneralActionContext } from '../../general.js';
|
||||
import type { WarActionContext } from '../../../war/actions.js';
|
||||
import type { SpecialActionModule } from '../types.js';
|
||||
|
||||
const RECRUIT_TRAIN = 70;
|
||||
const CONSCRIPT_TRAIN = 84;
|
||||
|
||||
const resolveLeadershipBonus = (
|
||||
context: GeneralActionContext | WarActionContext,
|
||||
value: number | [number, number]
|
||||
): number | [number, number] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
const base = context.general.stats.leadership;
|
||||
return value + base * 0.25;
|
||||
};
|
||||
|
||||
function onCalcStat(
|
||||
context: GeneralActionContext,
|
||||
statName: string,
|
||||
value: number,
|
||||
aux?: unknown
|
||||
): number;
|
||||
function onCalcStat(
|
||||
context: WarActionContext,
|
||||
statName: string,
|
||||
value: number | [number, number],
|
||||
aux?: unknown
|
||||
): number | [number, number];
|
||||
function onCalcStat(
|
||||
context: GeneralActionContext | WarActionContext,
|
||||
statName: string,
|
||||
value: number | [number, number]
|
||||
): number | [number, number] {
|
||||
if (statName !== 'leadership') {
|
||||
return value;
|
||||
}
|
||||
return resolveLeadershipBonus(context, value);
|
||||
}
|
||||
|
||||
// 전투 특기: 징병
|
||||
export const specialModule: SpecialActionModule = {
|
||||
key: 'che_징병',
|
||||
name: '징병',
|
||||
info: '[군사] 징병/모병 시 훈사 70/84 제공<br>[기타] 통솔 순수 능력치 보정 +25%, 징병/모병/소집해제 시 인구 변동 없음',
|
||||
kind: 'war',
|
||||
getName: () => '징병',
|
||||
getInfo: () =>
|
||||
'[군사] 징병/모병 시 훈사 70/84 제공<br>[기타] 통솔 순수 능력치 보정 +25%, 징병/모병/소집해제 시 인구 변동 없음',
|
||||
onCalcDomestic: (_context, turnType, varType, value) => {
|
||||
if (turnType === '징병' || turnType === '모병') {
|
||||
if (varType === 'train' || varType === 'atmos') {
|
||||
return turnType === '징병' ? RECRUIT_TRAIN : CONSCRIPT_TRAIN;
|
||||
}
|
||||
}
|
||||
if (turnType === '징집인구' && varType === 'score') {
|
||||
return 0;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
onCalcStat,
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import type {
|
||||
SpecialActionModule,
|
||||
SpecialActionModuleExport,
|
||||
} from '../types.js';
|
||||
|
||||
export const WAR_SPECIAL_KEYS = [
|
||||
'che_의술',
|
||||
'che_징병',
|
||||
] as const;
|
||||
|
||||
export type WarSpecialKey =
|
||||
(typeof WAR_SPECIAL_KEYS)[number];
|
||||
|
||||
export type WarSpecialModule = SpecialActionModule;
|
||||
|
||||
export type WarSpecialImporter = () => Promise<SpecialActionModuleExport>;
|
||||
|
||||
const defaultImporters: Record<
|
||||
WarSpecialKey,
|
||||
WarSpecialImporter
|
||||
> = {
|
||||
che_의술: async () => import('./che_의술.js'),
|
||||
che_징병: async () => import('./che_징병.js'),
|
||||
};
|
||||
|
||||
export const isWarSpecialKey = (
|
||||
value: string
|
||||
): value is WarSpecialKey =>
|
||||
WAR_SPECIAL_KEYS.includes(value as WarSpecialKey);
|
||||
|
||||
export class WarSpecialLoader {
|
||||
private readonly cache = new Map<
|
||||
WarSpecialKey,
|
||||
Promise<WarSpecialModule>
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly importers: Record<
|
||||
WarSpecialKey,
|
||||
WarSpecialImporter
|
||||
> = defaultImporters
|
||||
) {}
|
||||
|
||||
async load(key: WarSpecialKey): Promise<WarSpecialModule> {
|
||||
const cached = this.cache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const importer = this.importers[key];
|
||||
if (!importer) {
|
||||
throw new Error(`Unknown war special key: ${key}`);
|
||||
}
|
||||
const loading = importer().then((module) => {
|
||||
if (!('specialModule' in module)) {
|
||||
throw new Error(`Missing specialModule for war special: ${key}`);
|
||||
}
|
||||
const resolved = module.specialModule;
|
||||
if (resolved.key !== key) {
|
||||
throw new Error(
|
||||
`War special key mismatch: expected ${key}, got ${resolved.key}`
|
||||
);
|
||||
}
|
||||
if (resolved.kind !== 'war') {
|
||||
throw new Error(`War special kind mismatch: ${resolved.key}`);
|
||||
}
|
||||
return resolved;
|
||||
});
|
||||
this.cache.set(key, loading);
|
||||
return loading;
|
||||
}
|
||||
}
|
||||
|
||||
export const loadWarSpecialModules = async (
|
||||
keys: WarSpecialKey[],
|
||||
loader: WarSpecialLoader = new WarSpecialLoader()
|
||||
): Promise<WarSpecialModule[]> => {
|
||||
const modules: WarSpecialModule[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const key of keys) {
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
modules.push(await loader.load(key));
|
||||
}
|
||||
return modules;
|
||||
};
|
||||
@@ -16,6 +16,7 @@ export interface WarActionContext<TriggerState extends GeneralTriggerState = Gen
|
||||
city?: City;
|
||||
log?: ActionLogger;
|
||||
rng?: RandUtil;
|
||||
unit?: WarUnit<TriggerState>;
|
||||
}
|
||||
|
||||
export interface WarActionModule<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||
|
||||
@@ -14,10 +14,6 @@ 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,
|
||||
@@ -84,7 +80,12 @@ const appendCrewTypeTriggers = (
|
||||
continue;
|
||||
}
|
||||
const trigger = factory(unit);
|
||||
if (trigger) {
|
||||
if (!trigger) {
|
||||
continue;
|
||||
}
|
||||
if (trigger instanceof WarTriggerCaller) {
|
||||
caller.merge(trigger);
|
||||
} else {
|
||||
caller.append(trigger);
|
||||
}
|
||||
}
|
||||
@@ -109,8 +110,7 @@ const buildBattlePhaseTriggers = (
|
||||
): WarTriggerCaller => {
|
||||
const caller = new WarTriggerCaller();
|
||||
if (unit instanceof WarUnitGeneral) {
|
||||
caller.append(new ChePilsalAttemptTrigger(unit));
|
||||
caller.append(new ChePilsalActivateTrigger(unit));
|
||||
appendCrewTypeTriggers(caller, unit, ['che_필살'], registry);
|
||||
const context = unit.getActionContext();
|
||||
caller.merge(unit.getActionPipeline().getBattlePhaseTriggerList(context));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@ export * from './engine.js';
|
||||
export * from './aftermath.js';
|
||||
export * from './units.js';
|
||||
export * from './triggers.js';
|
||||
export * from './triggersChePilsal.js';
|
||||
export * from './triggers/index.js';
|
||||
export * from './crewType.js';
|
||||
export * from './utils.js';
|
||||
|
||||
@@ -24,7 +24,9 @@ export const createWarTriggerEnv = (): WarTriggerEnv => ({
|
||||
|
||||
export class WarTriggerCaller extends TriggerCaller<WarTriggerContext, WarTriggerEnv> {}
|
||||
|
||||
export type WarTriggerFactory = (unit: WarUnit) => WarTrigger | null;
|
||||
export type WarTriggerFactory = (
|
||||
unit: WarUnit
|
||||
) => WarTrigger | WarTriggerCaller | null;
|
||||
|
||||
export type WarTriggerRegistry = Record<string, WarTriggerFactory>;
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { LogFormat } from '../../logging/types.js';
|
||||
import { TriggerPriority } from '../../triggers/core.js';
|
||||
import { BaseWarUnitTrigger, WarTriggerCaller } from '../triggers.js';
|
||||
import { WarUnitGeneral, type WarUnit } from '../units.js';
|
||||
import type { WarTriggerModule } from './types.js';
|
||||
|
||||
// 의술: 치료 시도
|
||||
class AttemptTrigger extends BaseWarUnitTrigger {
|
||||
constructor(unit: WarUnit) {
|
||||
super(unit, TriggerPriority.Pre + 350);
|
||||
}
|
||||
|
||||
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(0.4)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
self.activateSkill('치료');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 의술: 치료 발동
|
||||
class ActivateTrigger extends BaseWarUnitTrigger {
|
||||
constructor(unit: WarUnit) {
|
||||
super(unit, TriggerPriority.Post + 550);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
oppose.multiplyWarPowerMultiply(0.7);
|
||||
if (self instanceof WarUnitGeneral) {
|
||||
self.getGeneral().injury = 0;
|
||||
}
|
||||
|
||||
this.processConsumableItem();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const triggerModule: WarTriggerModule = {
|
||||
key: 'che_의술',
|
||||
name: '의술',
|
||||
info: '[전투] 페이즈마다 치료 발동(아군 피해 30% 감소, 부상 회복)',
|
||||
createTriggerList: (unit) =>
|
||||
new WarTriggerCaller(
|
||||
new AttemptTrigger(unit),
|
||||
new ActivateTrigger(unit)
|
||||
),
|
||||
};
|
||||
+18
-6
@@ -1,10 +1,11 @@
|
||||
import { LogFormat } from '../logging/types.js';
|
||||
import { TriggerPriority } from '../triggers/core.js';
|
||||
import { BaseWarUnitTrigger } from './triggers.js';
|
||||
import { WarUnitGeneral, type WarUnit } from './units.js';
|
||||
import { LogFormat } from '../../logging/types.js';
|
||||
import { TriggerPriority } from '../../triggers/core.js';
|
||||
import { BaseWarUnitTrigger, WarTriggerCaller } from '../triggers.js';
|
||||
import { WarUnitGeneral, type WarUnit } from '../units.js';
|
||||
import type { WarTriggerModule } from './types.js';
|
||||
|
||||
// 기본 필살: 시도 단계
|
||||
export class ChePilsalAttemptTrigger extends BaseWarUnitTrigger {
|
||||
class AttemptTrigger extends BaseWarUnitTrigger {
|
||||
constructor(unit: WarUnit) {
|
||||
super(unit, TriggerPriority.Pre + 120);
|
||||
}
|
||||
@@ -33,7 +34,7 @@ export class ChePilsalAttemptTrigger extends BaseWarUnitTrigger {
|
||||
}
|
||||
|
||||
// 기본 필살: 발동 단계
|
||||
export class ChePilsalActivateTrigger extends BaseWarUnitTrigger {
|
||||
class ActivateTrigger extends BaseWarUnitTrigger {
|
||||
constructor(unit: WarUnit) {
|
||||
super(unit, TriggerPriority.Post + 400);
|
||||
}
|
||||
@@ -63,3 +64,14 @@ export class ChePilsalActivateTrigger extends BaseWarUnitTrigger {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const triggerModule: WarTriggerModule = {
|
||||
key: 'che_필살',
|
||||
name: '필살',
|
||||
info: '[전투] 페이즈마다 확률로 필살 발동',
|
||||
createTriggerList: (unit) =>
|
||||
new WarTriggerCaller(
|
||||
new AttemptTrigger(unit),
|
||||
new ActivateTrigger(unit)
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { WarTriggerModule, WarTriggerModuleExport } from './types.js';
|
||||
import type { WarTriggerRegistry } from '../triggers.js';
|
||||
|
||||
export const WAR_TRIGGER_KEYS = [
|
||||
'che_필살',
|
||||
'che_의술',
|
||||
] as const;
|
||||
|
||||
export type WarTriggerKey =
|
||||
(typeof WAR_TRIGGER_KEYS)[number];
|
||||
|
||||
export type WarTriggerImporter = () => Promise<WarTriggerModuleExport>;
|
||||
|
||||
const defaultImporters: Record<WarTriggerKey, WarTriggerImporter> = {
|
||||
che_필살: async () => import('./che_필살.js'),
|
||||
che_의술: async () => import('./che_의술.js'),
|
||||
};
|
||||
|
||||
export const isWarTriggerKey = (value: string): value is WarTriggerKey =>
|
||||
WAR_TRIGGER_KEYS.includes(value as WarTriggerKey);
|
||||
|
||||
export class WarTriggerLoader {
|
||||
private readonly cache = new Map<WarTriggerKey, Promise<WarTriggerModule>>();
|
||||
|
||||
constructor(
|
||||
private readonly importers: Record<WarTriggerKey, WarTriggerImporter> = defaultImporters
|
||||
) {}
|
||||
|
||||
async load(key: WarTriggerKey): Promise<WarTriggerModule> {
|
||||
const cached = this.cache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const importer = this.importers[key];
|
||||
if (!importer) {
|
||||
throw new Error(`Unknown war trigger key: ${key}`);
|
||||
}
|
||||
const loading = importer().then((module) => {
|
||||
if (!('triggerModule' in module)) {
|
||||
throw new Error(`Missing triggerModule for war trigger: ${key}`);
|
||||
}
|
||||
const resolved = module.triggerModule;
|
||||
if (resolved.key !== key) {
|
||||
throw new Error(
|
||||
`War trigger key mismatch: expected ${key}, got ${resolved.key}`
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
});
|
||||
this.cache.set(key, loading);
|
||||
return loading;
|
||||
}
|
||||
}
|
||||
|
||||
export const loadWarTriggerModules = async (
|
||||
keys: WarTriggerKey[],
|
||||
loader: WarTriggerLoader = new WarTriggerLoader()
|
||||
): Promise<WarTriggerModule[]> => {
|
||||
const modules: WarTriggerModule[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const key of keys) {
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
modules.push(await loader.load(key));
|
||||
}
|
||||
return modules;
|
||||
};
|
||||
|
||||
export const createWarTriggerRegistry = (
|
||||
modules: WarTriggerModule[]
|
||||
): WarTriggerRegistry => {
|
||||
const registry: WarTriggerRegistry = {};
|
||||
for (const module of modules) {
|
||||
registry[module.key] = (unit) => module.createTriggerList(unit);
|
||||
}
|
||||
return registry;
|
||||
};
|
||||
|
||||
export type { WarTriggerModule, WarTriggerModuleExport } from './types.js';
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { WarTriggerCaller } from '../triggers.js';
|
||||
import type { WarUnit } from '../units.js';
|
||||
|
||||
export interface WarTriggerModule {
|
||||
key: string;
|
||||
name: string;
|
||||
info: string;
|
||||
createTriggerList(unit: WarUnit): WarTriggerCaller | null;
|
||||
}
|
||||
|
||||
export interface WarTriggerModuleExport {
|
||||
triggerModule: WarTriggerModule;
|
||||
}
|
||||
@@ -435,6 +435,7 @@ export class WarUnitGeneral<
|
||||
city: this.city,
|
||||
log: this.logger,
|
||||
rng: this.rng,
|
||||
unit: this,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
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 { RandomGenerator } from '../src/index.js';
|
||||
import type { UnitSetDefinition } from '../src/world/types.js';
|
||||
import { GeneralActionPipeline } from '../src/triggers/general-action.js';
|
||||
import { createGeneralTriggerContext } from '../src/triggers/general.js';
|
||||
import {
|
||||
createSpecialActionModuleRegistry,
|
||||
createSpecialActionModules,
|
||||
loadDomesticSpecialModules,
|
||||
loadWarSpecialModules,
|
||||
} from '../src/triggers/special/index.js';
|
||||
import { ActionLogger } from '../src/logging/actionLogger.js';
|
||||
import { WarActionPipeline } from '../src/war/actions.js';
|
||||
import { WarCrewType } from '../src/war/crewType.js';
|
||||
import { createWarTriggerEnv } from '../src/war/triggers.js';
|
||||
import type { WarEngineConfig } from '../src/war/types.js';
|
||||
import { WarUnitCity, WarUnitGeneral } from '../src/war/units.js';
|
||||
|
||||
const buildGeneral = (overrides: Partial<General> = {}): General => ({
|
||||
id: 1,
|
||||
name: 'Tester',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: {
|
||||
leadership: 80,
|
||||
strength: 70,
|
||||
intelligence: 60,
|
||||
},
|
||||
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: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
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 buildNation = (): Nation => ({
|
||||
id: 1,
|
||||
name: 'TestNation',
|
||||
color: '#000000',
|
||||
capitalCityId: 1,
|
||||
chiefGeneralId: null,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
power: 0,
|
||||
level: 1,
|
||||
typeCode: 'test',
|
||||
meta: {},
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('special action modules', () => {
|
||||
it('loads special modules by key', async () => {
|
||||
const domestic = await loadDomesticSpecialModules([
|
||||
'che_인덕',
|
||||
'che_발명',
|
||||
]);
|
||||
const war = await loadWarSpecialModules(['che_의술', 'che_징병']);
|
||||
|
||||
expect(domestic.map((module) => module.key)).toEqual([
|
||||
'che_인덕',
|
||||
'che_발명',
|
||||
]);
|
||||
expect(war.map((module) => module.key)).toEqual([
|
||||
'che_의술',
|
||||
'che_징병',
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies domestic and war modifiers in general pipeline', async () => {
|
||||
const domestic = await loadDomesticSpecialModules([
|
||||
'che_인덕',
|
||||
'che_발명',
|
||||
]);
|
||||
const war = await loadWarSpecialModules(['che_의술', 'che_징병']);
|
||||
const registry = createSpecialActionModuleRegistry({ domestic, war });
|
||||
const specialModules = createSpecialActionModules(registry);
|
||||
|
||||
const pipeline = new GeneralActionPipeline(specialModules.general);
|
||||
const general = buildGeneral({
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: 'che_인덕',
|
||||
specialWar: 'che_징병',
|
||||
items: {
|
||||
horse: null,
|
||||
weapon: null,
|
||||
book: null,
|
||||
item: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const context = { general };
|
||||
expect(
|
||||
pipeline.onCalcDomestic(context, '민심', 'score', 100)
|
||||
).toBeCloseTo(110);
|
||||
expect(
|
||||
pipeline.onCalcDomestic(context, '징병', 'train', 40)
|
||||
).toBe(70);
|
||||
expect(pipeline.onCalcStat(context, 'leadership', 80)).toBe(100);
|
||||
});
|
||||
|
||||
it('heals city generals with 의술 pre-turn trigger', async () => {
|
||||
const domestic = await loadDomesticSpecialModules([
|
||||
'che_인덕',
|
||||
'che_발명',
|
||||
]);
|
||||
const war = await loadWarSpecialModules(['che_의술', 'che_징병']);
|
||||
const registry = createSpecialActionModuleRegistry({ domestic, war });
|
||||
const specialModules = createSpecialActionModules(registry);
|
||||
const pipeline = new GeneralActionPipeline(specialModules.general);
|
||||
|
||||
const general = buildGeneral({
|
||||
injury: 20,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: 'che_의술',
|
||||
items: {
|
||||
horse: null,
|
||||
weapon: null,
|
||||
book: null,
|
||||
item: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const patient = buildGeneral({
|
||||
id: 2,
|
||||
name: 'Patient',
|
||||
injury: 15,
|
||||
});
|
||||
const worldView = {
|
||||
listGeneralsByCity: (_cityId: number) => [general, patient],
|
||||
listGenerals: () => [general, patient],
|
||||
};
|
||||
const rng: RandomGenerator = {
|
||||
nextFloat: () => 0,
|
||||
nextBool: () => true,
|
||||
nextInt: (minInclusive: number, _maxExclusive: number) => minInclusive,
|
||||
};
|
||||
|
||||
const caller = pipeline.getPreTurnExecuteTriggerList({
|
||||
general,
|
||||
worldView,
|
||||
});
|
||||
const triggerContext = createGeneralTriggerContext({
|
||||
general,
|
||||
rng,
|
||||
worldView,
|
||||
log: { push: () => {} },
|
||||
});
|
||||
caller.fire(triggerContext, {});
|
||||
|
||||
expect(general.injury).toBe(0);
|
||||
expect(patient.injury).toBe(0);
|
||||
expect(general.triggerState.flags['pre.치료']).toBe(true);
|
||||
});
|
||||
|
||||
it('activates 의술 battle trigger and reduces damage', async () => {
|
||||
const domestic = await loadDomesticSpecialModules([
|
||||
'che_인덕',
|
||||
'che_발명',
|
||||
]);
|
||||
const war = await loadWarSpecialModules(['che_의술', 'che_징병']);
|
||||
const registry = createSpecialActionModuleRegistry({ domestic, war });
|
||||
const specialModules = createSpecialActionModules(registry);
|
||||
|
||||
const rng = new RandUtil(new ConstantRNG(0));
|
||||
const config = buildConfig();
|
||||
const unitSet = buildUnitSet();
|
||||
const crewType = new WarCrewType(unitSet.crewTypes?.[0]!);
|
||||
const city = buildCity();
|
||||
const nation = buildNation();
|
||||
const general = buildGeneral({
|
||||
injury: 10,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: 'che_의술',
|
||||
items: {
|
||||
horse: null,
|
||||
weapon: null,
|
||||
book: null,
|
||||
item: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const attacker = new WarUnitGeneral(
|
||||
rng,
|
||||
config,
|
||||
general,
|
||||
city,
|
||||
nation,
|
||||
true,
|
||||
crewType,
|
||||
new ActionLogger({ generalId: 1, nationId: 1 }),
|
||||
new WarActionPipeline(specialModules.war)
|
||||
);
|
||||
const defender = new WarUnitCity(
|
||||
rng,
|
||||
config,
|
||||
city,
|
||||
nation,
|
||||
new WarCrewType(unitSet.crewTypes?.[1]!),
|
||||
new ActionLogger({}),
|
||||
200,
|
||||
180
|
||||
);
|
||||
|
||||
attacker.setOppose(defender);
|
||||
defender.setOppose(attacker);
|
||||
attacker.beginPhase();
|
||||
|
||||
const caller = attacker
|
||||
.getActionPipeline()
|
||||
.getBattlePhaseTriggerList(attacker.getActionContext());
|
||||
caller.fire({ rng, attacker, defender }, createWarTriggerEnv());
|
||||
|
||||
expect(defender.getWarPowerMultiply()).toBeCloseTo(0.7);
|
||||
expect(general.injury).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ 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 { loadWarTriggerModules } from '../src/war/triggers/index.js';
|
||||
import { WarUnitCity, WarUnitGeneral } from '../src/war/units.js';
|
||||
|
||||
const buildConfig = (): WarEngineConfig => ({
|
||||
@@ -157,7 +157,7 @@ const buildGeneral = (strength: number): General => ({
|
||||
});
|
||||
|
||||
describe('war triggers', () => {
|
||||
it('activates and applies critical damage', () => {
|
||||
it('activates and applies critical damage', async () => {
|
||||
const rng = new RandUtil(new ConstantRNG(0));
|
||||
const config = buildConfig();
|
||||
const crewType = new WarCrewType(buildUnitSet().crewTypes?.[0]!);
|
||||
@@ -191,14 +191,20 @@ describe('war triggers', () => {
|
||||
|
||||
attacker.beginPhase();
|
||||
|
||||
const attempt = new ChePilsalAttemptTrigger(attacker);
|
||||
attempt.action({ rng, attacker, defender }, { e_attacker: {}, e_defender: {} });
|
||||
const [module] = await loadWarTriggerModules(['che_필살']);
|
||||
if (!module) {
|
||||
throw new Error('Missing che_필살 trigger module');
|
||||
}
|
||||
const caller = module.createTriggerList(attacker);
|
||||
if (!caller) {
|
||||
throw new Error('Missing che_필살 trigger list');
|
||||
}
|
||||
caller.fire(
|
||||
{ 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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user