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:
2026-01-03 08:01:04 +00:00
parent eb361e1bed
commit 64953e39a5
29 changed files with 1250 additions and 28 deletions
@@ -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),
};
+8
View File
@@ -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
View File
@@ -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;
};
+1
View File
@@ -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> {
+7 -7
View File
@@ -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));
}
+1 -1
View File
@@ -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';
+3 -1
View File
@@ -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)
),
};
@@ -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)
),
};
+81
View File
@@ -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';
+13
View File
@@ -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;
}
+1
View File
@@ -435,6 +435,7 @@ export class WarUnitGeneral<
city: this.city,
log: this.logger,
rng: this.rng,
unit: this,
};
}
+336
View File
@@ -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);
});
});
+14 -8
View File
@@ -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);
});
});