diff --git a/packages/logic/src/actions/turn/commandEnv.ts b/packages/logic/src/actions/turn/commandEnv.ts index e99ee6bc..d9764d5a 100644 --- a/packages/logic/src/actions/turn/commandEnv.ts +++ b/packages/logic/src/actions/turn/commandEnv.ts @@ -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; } diff --git a/packages/logic/src/actions/turn/general/che_상업투자.ts b/packages/logic/src/actions/turn/general/che_상업투자.ts index 8f250584..00a4fc29 100644 --- a/packages/logic/src/actions/turn/general/che_상업투자.ts +++ b/packages/logic/src/actions/turn/general/che_상업투자.ts @@ -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), }; diff --git a/packages/logic/src/actions/turn/general/che_인재탐색.ts b/packages/logic/src/actions/turn/general/che_인재탐색.ts index 89575cfe..590a7114 100644 --- a/packages/logic/src/actions/turn/general/che_인재탐색.ts +++ b/packages/logic/src/actions/turn/general/che_인재탐색.ts @@ -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), }; diff --git a/packages/logic/src/actions/turn/general/che_징병.ts b/packages/logic/src/actions/turn/general/che_징병.ts index e0679c6c..65faa2bf 100644 --- a/packages/logic/src/actions/turn/general/che_징병.ts +++ b/packages/logic/src/actions/turn/general/che_징병.ts @@ -614,5 +614,6 @@ export const commandSpec: GeneralTurnCommandSpec = { category: '내정', reqArg: true, args: {}, - createDefinition: (_env: TurnCommandEnv) => new ActionDefinition([], {}), + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition(env.generalActionModules ?? [], {}), }; diff --git a/packages/logic/src/actions/turn/general/che_화계.ts b/packages/logic/src/actions/turn/general/che_화계.ts index 87400d63..88445d81 100644 --- a/packages/logic/src/actions/turn/general/che_화계.ts +++ b/packages/logic/src/actions/turn/general/che_화계.ts @@ -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), }; diff --git a/packages/logic/src/actions/turn/nation/che_의병모집.ts b/packages/logic/src/actions/turn/nation/che_의병모집.ts index 7bb1aa01..b2a15b86 100644 --- a/packages/logic/src/actions/turn/nation/che_의병모집.ts +++ b/packages/logic/src/actions/turn/nation/che_의병모집.ts @@ -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), }; diff --git a/packages/logic/src/triggers/general.ts b/packages/logic/src/triggers/general.ts index 16848e30..4514f6f2 100644 --- a/packages/logic/src/triggers/general.ts +++ b/packages/logic/src/triggers/general.ts @@ -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[]; + listGeneralsByCity?(cityId: number): General[]; +} + export interface GeneralActionLogSink { push(message: string): void; } @@ -28,6 +35,7 @@ export const createGeneralSkillActivation = < export interface GeneralActionContext { general: General; world?: WorldStateRepository; + worldView?: GeneralWorldView; log?: GeneralActionLogSink; rng?: RandomGenerator; } diff --git a/packages/logic/src/triggers/generalTriggers/che_도시치료.ts b/packages/logic/src/triggers/generalTriggers/che_도시치료.ts new file mode 100644 index 00000000..c66ee327 --- /dev/null +++ b/packages/logic/src/triggers/generalTriggers/che_도시치료.ts @@ -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 = ( + general: General, + context: GeneralTriggerContext +): General[] => { + 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 { + public readonly priority = TriggerPriority.Begin + 10; + + public constructor(general: General) { + super(general); + } + + action( + context: GeneralTriggerContext, + env: Record + ): Record { + 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('의술을 펼쳐 스스로 치료합니다!'); + } + + 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( + `의술을 펼쳐 도시의 장수 ${firstName}${josa} 치료합니다!` + ); + } else { + const otherCount = healed.length - 1; + logger?.push( + `의술을 펼쳐 도시의 장수들 ${firstName} 외 ${otherCount}명을 치료합니다!` + ); + } + + return env; + } +} diff --git a/packages/logic/src/triggers/index.ts b/packages/logic/src/triggers/index.ts index 03cfb216..ccbc2fa5 100644 --- a/packages/logic/src/triggers/index.ts +++ b/packages/logic/src/triggers/index.ts @@ -1,3 +1,4 @@ export * from './core.js'; export * from './general.js'; export * from './general-action.js'; +export * from './special/index.js'; diff --git a/packages/logic/src/triggers/special/domestic/che_발명.ts b/packages/logic/src/triggers/special/domestic/che_발명.ts new file mode 100644 index 00000000..37028c9c --- /dev/null +++ b/packages/logic/src/triggers/special/domestic/che_발명.ts @@ -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; + }, +}; diff --git a/packages/logic/src/triggers/special/domestic/che_인덕.ts b/packages/logic/src/triggers/special/domestic/che_인덕.ts new file mode 100644 index 00000000..22b962aa --- /dev/null +++ b/packages/logic/src/triggers/special/domestic/che_인덕.ts @@ -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; + }, +}; diff --git a/packages/logic/src/triggers/special/domestic/index.ts b/packages/logic/src/triggers/special/domestic/index.ts new file mode 100644 index 00000000..35896b7b --- /dev/null +++ b/packages/logic/src/triggers/special/domestic/index.ts @@ -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; + +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 + >(); + + constructor( + private readonly importers: Record< + DomesticSpecialKey, + DomesticSpecialImporter + > = defaultImporters + ) {} + + async load(key: DomesticSpecialKey): Promise { + 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 => { + const modules: DomesticSpecialModule[] = []; + const seen = new Set(); + for (const key of keys) { + if (seen.has(key)) { + continue; + } + seen.add(key); + modules.push(await loader.load(key)); + } + return modules; +}; diff --git a/packages/logic/src/triggers/special/index.ts b/packages/logic/src/triggers/special/index.ts new file mode 100644 index 00000000..aafab9db --- /dev/null +++ b/packages/logic/src/triggers/special/index.ts @@ -0,0 +1,4 @@ +export * from './types.js'; +export * from './registry.js'; +export * from './domestic/index.js'; +export * from './war/index.js'; diff --git a/packages/logic/src/triggers/special/registry.ts b/packages/logic/src/triggers/special/registry.ts new file mode 100644 index 00000000..23219503 --- /dev/null +++ b/packages/logic/src/triggers/special/registry.ts @@ -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, + kind: SpecialActionKind, + key: string | null +): SpecialActionModule | 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 { + constructor( + private readonly kind: SpecialActionKind, + private readonly registry: SpecialActionModuleRegistry + ) {} + + private getModule( + context: GeneralActionContext + ): SpecialActionModule | null { + const key = resolveSpecialKey(context, this.kind); + return resolveModule(this.registry, this.kind, key); + } + + getPreTurnExecuteTriggerList( + context: GeneralActionContext + ) { + const module = this.getModule(context); + return module?.getPreTurnExecuteTriggerList?.(context) ?? null; + } + + onCalcDomestic( + context: GeneralActionContext, + 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, + statName: string, + value: number, + aux?: unknown + ): number { + const module = this.getModule(context); + return module?.onCalcStat?.(context, statName, value, aux) ?? value; + } + + onCalcOpposeStat( + context: GeneralActionContext, + statName: string, + value: number, + aux?: unknown + ): number { + const module = this.getModule(context); + return ( + module?.onCalcOpposeStat?.(context, statName, value, aux) ?? value + ); + } + + onCalcStrategic( + context: GeneralActionContext, + turnType: string, + varType: string, + value: number + ): number { + const module = this.getModule(context); + return module?.onCalcStrategic?.(context, turnType, varType, value) ?? value; + } + + onCalcNationalIncome( + context: GeneralActionContext, + type: string, + amount: number + ): number { + const module = this.getModule(context); + return module?.onCalcNationalIncome?.(context, type, amount) ?? amount; + } + + onArbitraryAction( + context: GeneralActionContext, + actionType: string, + phase?: string | null, + aux?: Record | null + ): Record | 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 { + constructor( + private readonly kind: SpecialActionKind, + private readonly registry: SpecialActionModuleRegistry + ) {} + + private getModule( + context: WarActionContext + ): SpecialActionModule | null { + const key = resolveSpecialKey(context, this.kind); + return resolveModule(this.registry, this.kind, key); + } + + getBattleInitTriggerList( + context: WarActionContext + ): WarTriggerCaller | null { + const module = this.getModule(context); + return module?.getBattleInitTriggerList?.(context) ?? null; + } + + getBattlePhaseTriggerList( + context: WarActionContext + ): WarTriggerCaller | null { + const module = this.getModule(context); + return module?.getBattlePhaseTriggerList?.(context) ?? null; + } + + onCalcStat( + context: WarActionContext, + 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, + 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, + unit: WarUnit, + oppose: WarUnit + ): [number, number] { + const module = this.getModule(context); + return module?.getWarPowerMultiplier?.(context, unit, oppose) ?? [1, 1]; + } +} + +export interface SpecialActionModuleSet< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + general: GeneralActionModule[]; + war: WarActionModule[]; +} + +export const createSpecialActionModuleRegistry = < + TriggerState extends GeneralTriggerState = GeneralTriggerState +>(options: { + domestic?: SpecialActionModule[]; + war?: SpecialActionModule[]; +}): SpecialActionModuleRegistry => { + const domestic = new Map>(); + const war = new Map>(); + + 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 +): SpecialActionModuleSet => ({ + general: [ + new SpecialGeneralActionRouter('domestic', registry), + new SpecialGeneralActionRouter('war', registry), + ], + war: [ + new SpecialWarActionRouter('domestic', registry), + new SpecialWarActionRouter('war', registry), + ], +}); diff --git a/packages/logic/src/triggers/special/types.ts b/packages/logic/src/triggers/special/types.ts new file mode 100644 index 00000000..9ad3a25a --- /dev/null +++ b/packages/logic/src/triggers/special/types.ts @@ -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 & + WarActionModule; + +export interface SpecialActionModuleExport< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + specialModule: SpecialActionModule; +} + +export interface SpecialActionModuleRegistry< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + domestic: Map>; + war: Map>; +} diff --git a/packages/logic/src/triggers/special/war/che_의술.ts b/packages/logic/src/triggers/special/war/che_의술.ts new file mode 100644 index 00000000..20076c1d --- /dev/null +++ b/packages/logic/src/triggers/special/war/che_의술.ts @@ -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%) 부상 회복
[전투] 페이즈마다 40% 확률로 치료 발동(아군 피해 30% 감소, 부상 회복)', + kind: 'war', + getName: () => '의술', + getInfo: () => + '[군사] 매 턴마다 자신(100%)과 소속 도시 장수(적 포함 50%) 부상 회복
[전투] 페이즈마다 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); + }, +}; diff --git a/packages/logic/src/triggers/special/war/che_징병.ts b/packages/logic/src/triggers/special/war/che_징병.ts new file mode 100644 index 00000000..08c4df46 --- /dev/null +++ b/packages/logic/src/triggers/special/war/che_징병.ts @@ -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 제공
[기타] 통솔 순수 능력치 보정 +25%, 징병/모병/소집해제 시 인구 변동 없음', + kind: 'war', + getName: () => '징병', + getInfo: () => + '[군사] 징병/모병 시 훈사 70/84 제공
[기타] 통솔 순수 능력치 보정 +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, +}; diff --git a/packages/logic/src/triggers/special/war/index.ts b/packages/logic/src/triggers/special/war/index.ts new file mode 100644 index 00000000..d0949e92 --- /dev/null +++ b/packages/logic/src/triggers/special/war/index.ts @@ -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; + +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 + >(); + + constructor( + private readonly importers: Record< + WarSpecialKey, + WarSpecialImporter + > = defaultImporters + ) {} + + async load(key: WarSpecialKey): Promise { + 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 => { + const modules: WarSpecialModule[] = []; + const seen = new Set(); + for (const key of keys) { + if (seen.has(key)) { + continue; + } + seen.add(key); + modules.push(await loader.load(key)); + } + return modules; +}; diff --git a/packages/logic/src/war/actions.ts b/packages/logic/src/war/actions.ts index d9875ee1..45b7e6b0 100644 --- a/packages/logic/src/war/actions.ts +++ b/packages/logic/src/war/actions.ts @@ -16,6 +16,7 @@ export interface WarActionContext; } export interface WarActionModule { diff --git a/packages/logic/src/war/engine.ts b/packages/logic/src/war/engine.ts index fe0f38cc..a60ded3a 100644 --- a/packages/logic/src/war/engine.ts +++ b/packages/logic/src/war/engine.ts @@ -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)); } diff --git a/packages/logic/src/war/index.ts b/packages/logic/src/war/index.ts index 67b037cf..eed6aa6f 100644 --- a/packages/logic/src/war/index.ts +++ b/packages/logic/src/war/index.ts @@ -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'; diff --git a/packages/logic/src/war/triggers.ts b/packages/logic/src/war/triggers.ts index b122fedc..53797308 100644 --- a/packages/logic/src/war/triggers.ts +++ b/packages/logic/src/war/triggers.ts @@ -24,7 +24,9 @@ export const createWarTriggerEnv = (): WarTriggerEnv => ({ export class WarTriggerCaller extends TriggerCaller {} -export type WarTriggerFactory = (unit: WarUnit) => WarTrigger | null; +export type WarTriggerFactory = ( + unit: WarUnit +) => WarTrigger | WarTriggerCaller | null; export type WarTriggerRegistry = Record; diff --git a/packages/logic/src/war/triggers/che_의술.ts b/packages/logic/src/war/triggers/che_의술.ts new file mode 100644 index 00000000..bb707982 --- /dev/null +++ b/packages/logic/src/war/triggers/che_의술.ts @@ -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, + _opposeEnv: Record + ): 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, + _opposeEnv: Record + ): boolean { + if (!self.hasActivatedSkill('치료')) { + return true; + } + if (selfEnv['치료발동']) { + return true; + } + selfEnv['치료발동'] = true; + + oppose + .getLogger() + .pushGeneralBattleDetailLog( + '상대가 치료했다!', + LogFormat.PLAIN + ); + self + .getLogger() + .pushGeneralBattleDetailLog('치료했다!', 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) + ), +}; diff --git a/packages/logic/src/war/triggersChePilsal.ts b/packages/logic/src/war/triggers/che_필살.ts similarity index 69% rename from packages/logic/src/war/triggersChePilsal.ts rename to packages/logic/src/war/triggers/che_필살.ts index af00416d..1db4391e 100644 --- a/packages/logic/src/war/triggersChePilsal.ts +++ b/packages/logic/src/war/triggers/che_필살.ts @@ -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) + ), +}; diff --git a/packages/logic/src/war/triggers/index.ts b/packages/logic/src/war/triggers/index.ts new file mode 100644 index 00000000..05acbb62 --- /dev/null +++ b/packages/logic/src/war/triggers/index.ts @@ -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; + +const defaultImporters: Record = { + 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>(); + + constructor( + private readonly importers: Record = defaultImporters + ) {} + + async load(key: WarTriggerKey): Promise { + 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 => { + const modules: WarTriggerModule[] = []; + const seen = new Set(); + 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'; diff --git a/packages/logic/src/war/triggers/types.ts b/packages/logic/src/war/triggers/types.ts new file mode 100644 index 00000000..e9e8ca0a --- /dev/null +++ b/packages/logic/src/war/triggers/types.ts @@ -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; +} diff --git a/packages/logic/src/war/units.ts b/packages/logic/src/war/units.ts index 292e615b..437d38a8 100644 --- a/packages/logic/src/war/units.ts +++ b/packages/logic/src/war/units.ts @@ -435,6 +435,7 @@ export class WarUnitGeneral< city: this.city, log: this.logger, rng: this.rng, + unit: this, }; } diff --git a/packages/logic/test/specialActions.test.ts b/packages/logic/test/specialActions.test.ts new file mode 100644 index 00000000..a7b90ebc --- /dev/null +++ b/packages/logic/test/specialActions.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/packages/logic/test/warEngine.test.ts b/packages/logic/test/warEngine.test.ts index 7f96f0fd..9f8b89b3 100644 --- a/packages/logic/test/warEngine.test.ts +++ b/packages/logic/test/warEngine.test.ts @@ -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); }); });