diff --git a/app/game-api/src/battleSim/processor.ts b/app/game-api/src/battleSim/processor.ts index 632540f..13123a5 100644 --- a/app/game-api/src/battleSim/processor.ts +++ b/app/game-api/src/battleSim/processor.ts @@ -8,11 +8,16 @@ import { LogScope, resolveDefenderOrder, resolveWarBattle, + createItemActionModules, + createItemModuleRegistry, + ITEM_KEYS, + loadItemModules, type City, type General, type Nation, type UnitSetDefinition, type WarBattleOutcome, + type WarActionModule, } from '@sammo-ts/logic'; import { @@ -24,6 +29,10 @@ import { convertLog } from './logFormatter.js'; const DEFAULT_GENERAL_AGE = 20; +const itemWarModules: WarActionModule[] = createItemActionModules( + createItemModuleRegistry(await loadItemModules([...ITEM_KEYS])) +).war; + const normalizeItemCode = (value: string | null): string | null => value === 'None' ? null : value; @@ -324,11 +333,13 @@ export const processBattleSimJob = (payload: BattleSimJobPayload): BattleSimResu general: attackerGeneral, city: attackerCity, nation: attackerNation, + modules: itemWarModules, }, defenders: defenderGenerals.map((general) => ({ general, city: defenderCity, nation: defenderNation, + modules: itemWarModules, })), defenderCity, defenderNation, diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index 0b6745f..b07e1fd 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -10,6 +10,10 @@ import type { import { loadGeneralTurnCommandSpecs, loadNationTurnCommandSpecs, + createItemActionModules, + createItemModuleRegistry, + ITEM_KEYS, + loadItemModules, } from '@sammo-ts/logic'; const DEFAULT_GENERAL_GOLD = 1000; @@ -158,6 +162,18 @@ export const buildReservedTurnDefinitions = async (options: { general: Map; nation: Map; }> => { + const itemModules = await loadItemModules([...ITEM_KEYS]); + const itemRegistry = createItemModuleRegistry(itemModules); + const itemActionModules = createItemActionModules(itemRegistry); + options.env.generalActionModules = [ + ...(options.env.generalActionModules ?? []), + ...itemActionModules.general, + ]; + options.env.warActionModules = [ + ...(options.env.warActionModules ?? []), + ...itemActionModules.war, + ]; + const generalSpecs = await loadGeneralTurnCommandSpecs( options.commandProfile.general ); diff --git a/docs/architecture/todo.md b/docs/architecture/todo.md index 7a4dda2..1d48560 100644 --- a/docs/architecture/todo.md +++ b/docs/architecture/todo.md @@ -40,6 +40,7 @@ Move items into the main docs once they are finalized. ## Trigger System - Example trigger sets per scenario or rule pack +- [AI suggestion] Define item/equipment effect modeling for war triggers (equip vs carry, stacking, consumable/breakable) and hook into WarActionPipeline/trigger registry. ## Data and Profiles (Lower Priority) diff --git a/packages/logic/src/actions/turn/commandEnv.ts b/packages/logic/src/actions/turn/commandEnv.ts index bceed4b..a0c16f7 100644 --- a/packages/logic/src/actions/turn/commandEnv.ts +++ b/packages/logic/src/actions/turn/commandEnv.ts @@ -1,4 +1,5 @@ import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js'; +import type { WarActionModule } from '@sammo-ts/logic/war/actions.js'; export interface TurnCommandEnv { develCost: number; @@ -24,4 +25,5 @@ export interface TurnCommandEnv { baseRice: number; maxResourceActionAmount: number; generalActionModules?: Array; + warActionModules?: Array; } diff --git a/packages/logic/src/actions/turn/general/che_출병.ts b/packages/logic/src/actions/turn/general/che_출병.ts index bcba21f..ff39bf6 100644 --- a/packages/logic/src/actions/turn/general/che_출병.ts +++ b/packages/logic/src/actions/turn/general/che_출병.ts @@ -44,6 +44,7 @@ import type { } from '@sammo-ts/logic/war/types.js'; import { resolveWarAftermath } from '@sammo-ts/logic/war/aftermath.js'; import { resolveWarBattle } from '@sammo-ts/logic/war/engine.js'; +import type { WarActionModule } from '@sammo-ts/logic/war/actions.js'; import { increaseMetaNumber, simpleSerialize, @@ -268,6 +269,11 @@ export class ActionDefinition< > { public readonly key = 'che_출병'; public readonly name = ACTION_NAME; + private readonly warModules: Array>; + + constructor(modules: Array | null | undefined> = []) { + this.warModules = modules.filter(Boolean) as Array>; + } parseArgs(raw: unknown): DispatchArgs | null { const data = raw as { destCityId?: unknown }; @@ -439,11 +445,13 @@ export class ActionDefinition< general: context.general, city: attackerCity, nation: attackerNation, + modules: this.warModules, }, defenders: defenderGenerals.map((general) => ({ general, city: defenderCity, nation: defenderNation, + modules: this.warModules, })), defenderCity, defenderNation, @@ -580,5 +588,6 @@ export const commandSpec: GeneralTurnCommandSpec = { category: '군사', reqArg: true, args: { destCityId: 0 }, - createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition(env.warActionModules ?? []), }; diff --git a/packages/logic/src/index.ts b/packages/logic/src/index.ts index fde081e..b0536cf 100644 --- a/packages/logic/src/index.ts +++ b/packages/logic/src/index.ts @@ -4,6 +4,13 @@ export * from './actions/index.js'; export * from './constraints/index.js'; export * from './logging/index.js'; export * from './messages/index.js'; +export * from './items/index.js'; +export { + ITEM_KEYS, + createItemActionModules, + createItemModuleRegistry, + loadItemModules, +} from './items/index.js'; export * from './ports/world.js'; export * from './ports/worldSnapshot.js'; export * from './scenario/index.js'; diff --git a/packages/logic/src/items/base.ts b/packages/logic/src/items/base.ts new file mode 100644 index 0000000..470233f --- /dev/null +++ b/packages/logic/src/items/base.ts @@ -0,0 +1,77 @@ +import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js'; +import type { WarActionContext } from '@sammo-ts/logic/war/actions.js'; +import type { ItemModule, ItemSlot } from './types.js'; + +const resolveStatLabel = (statName: string): string => { + switch (statName) { + case 'leadership': + return '통솔'; + case 'strength': + return '무력'; + case 'intelligence': + return '지력'; + default: + return statName; + } +}; + +export interface StatItemOptions { + key: string; + rawName: string; + slot: ItemSlot; + statName: 'leadership' | 'strength' | 'intelligence'; + statValue: number; + cost: number | null; + buyable: boolean; + reqSecu: number; + unique?: boolean; + extraInfo?: string; +} + +export const createStatItemModule = ( + options: StatItemOptions +): ItemModule => { + const statLabel = resolveStatLabel(options.statName); + const name = `${options.rawName}(+${options.statValue})`; + const baseInfo = `${statLabel} +${options.statValue}`; + const info = options.extraInfo ? `${baseInfo}
${options.extraInfo}` : baseInfo; + 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 !== options.statName) { + return value; + } + if (Array.isArray(value)) { + return value; + } + return value + options.statValue; + } + + return { + key: options.key, + rawName: options.rawName, + name, + info, + slot: options.slot, + cost: options.cost, + buyable: options.buyable, + consumable: false, + reqSecu: options.reqSecu, + unique: options.unique ?? false, + onCalcStat, + }; +}; diff --git a/packages/logic/src/items/che_명마_06_흑색마.ts b/packages/logic/src/items/che_명마_06_흑색마.ts new file mode 100644 index 0000000..84e9554 --- /dev/null +++ b/packages/logic/src/items/che_명마_06_흑색마.ts @@ -0,0 +1,13 @@ +import { createStatItemModule } from './base.js'; +import type { ItemModule } from './types.js'; + +export const itemModule: ItemModule = createStatItemModule({ + key: 'che_명마_06_흑색마', + rawName: '흑색마', + slot: 'horse', + statName: 'leadership', + statValue: 6, + cost: 21000, + buyable: true, + reqSecu: 6000, +}); diff --git a/packages/logic/src/items/che_명마_12_사륜거.ts b/packages/logic/src/items/che_명마_12_사륜거.ts new file mode 100644 index 0000000..f687382 --- /dev/null +++ b/packages/logic/src/items/che_명마_12_사륜거.ts @@ -0,0 +1,27 @@ +import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { CheRetreatNoWoundTrigger } from '@sammo-ts/logic/war/triggers/che_퇴각부상무효.js'; +import { createStatItemModule } from './base.js'; +import type { ItemModule } from './types.js'; + +const baseModule = createStatItemModule({ + key: 'che_명마_12_사륜거', + rawName: '사륜거', + slot: 'horse', + statName: 'leadership', + statValue: 12, + cost: 200, + buyable: false, + reqSecu: 0, + unique: true, + extraInfo: '[전투] 전투 종료로 인한 부상 없음', +}); + +export const itemModule: ItemModule = { + ...baseModule, + getBattleInitTriggerList: (context) => { + if (!context.unit) { + return null; + } + return new WarTriggerCaller(new CheRetreatNoWoundTrigger(context.unit)); + }, +}; diff --git a/packages/logic/src/items/che_무기_02_단궁.ts b/packages/logic/src/items/che_무기_02_단궁.ts new file mode 100644 index 0000000..87bd32a --- /dev/null +++ b/packages/logic/src/items/che_무기_02_단궁.ts @@ -0,0 +1,42 @@ +import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { + CheSnipingActivateTrigger, + CheSnipingAttemptTrigger, +} from '@sammo-ts/logic/war/triggers/che_저격.js'; +import { createStatItemModule } from './base.js'; +import type { ItemModule } from './types.js'; + +const raiseType = + BaseWarUnitTrigger.TYPE_ITEM + + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * 102; + +const baseModule = createStatItemModule({ + key: 'che_무기_02_단궁', + rawName: '단궁', + slot: 'weapon', + statName: 'strength', + statValue: 2, + cost: 3000, + buyable: true, + reqSecu: 2000, + extraInfo: '[전투] 새로운 상대와 전투 시 1% 확률로 저격 발동, 성공 시 사기+20', +}); + +export const itemModule: ItemModule = { + ...baseModule, + getBattlePhaseTriggerList: (context) => { + if (!context.unit) { + return null; + } + return new WarTriggerCaller( + new CheSnipingAttemptTrigger( + context.unit, + raiseType, + 0.01, + 10, + 30 + ), + new CheSnipingActivateTrigger(context.unit, raiseType) + ); + }, +}; diff --git a/packages/logic/src/items/che_무기_09_동호비궁.ts b/packages/logic/src/items/che_무기_09_동호비궁.ts new file mode 100644 index 0000000..34276cd --- /dev/null +++ b/packages/logic/src/items/che_무기_09_동호비궁.ts @@ -0,0 +1,44 @@ +import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { + CheSnipingActivateTrigger, + CheSnipingAttemptTrigger, +} from '@sammo-ts/logic/war/triggers/che_저격.js'; +import { createStatItemModule } from './base.js'; +import type { ItemModule } from './types.js'; + +const raiseType = + BaseWarUnitTrigger.TYPE_ITEM + + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * 109; + +const baseModule = createStatItemModule({ + key: 'che_무기_09_동호비궁', + rawName: '동호비궁', + slot: 'weapon', + statName: 'strength', + statValue: 9, + cost: 200, + buyable: false, + reqSecu: 0, + unique: true, + extraInfo: '[전투] 새로운 상대와 전투 시 20% 확률로 저격 발동, 성공 시 사기+20', +}); + +export const itemModule: ItemModule = { + ...baseModule, + getBattlePhaseTriggerList: (context) => { + if (!context.unit) { + return null; + } + return new WarTriggerCaller( + new CheSnipingAttemptTrigger( + context.unit, + raiseType, + 0.2, + 20, + 40, + 20 + ), + new CheSnipingActivateTrigger(context.unit, raiseType) + ); + }, +}; diff --git a/packages/logic/src/items/che_보물_도기.ts b/packages/logic/src/items/che_보물_도기.ts new file mode 100644 index 0000000..f200fce --- /dev/null +++ b/packages/logic/src/items/che_보물_도기.ts @@ -0,0 +1,65 @@ +import { JosaUtil } from '@sammo-ts/common'; +import type { ItemModule } from './types.js'; + +const ITEM_KEY = 'che_보물_도기'; + +const resolveNumber = (value: unknown, fallback = 0): number => + typeof value === 'number' && Number.isFinite(value) ? value : fallback; + +export const itemModule: ItemModule = { + key: ITEM_KEY, + rawName: '도기', + name: '도기(보물)', + info: + '[개인] 판매 시 장수 소지금과 국고에 금, 쌀 중 하나를 추가 (총 +10,000, 2년마다 +5,000)', + slot: 'item', + cost: 200, + buyable: false, + consumable: false, + reqSecu: 0, + unique: true, + onArbitraryAction: (context, actionType, phase, aux) => { + if (!aux || actionType !== '장비매매' || phase !== '판매') { + return aux ?? null; + } + if (aux['itemKey'] !== ITEM_KEY && aux['itemCode'] !== ITEM_KEY) { + return aux; + } + + const year = resolveNumber(aux['year']); + const startYear = resolveNumber(aux['startYear']); + const relYear = Math.max(0, year - startYear); + const score = Math.round(10000 + 5000 * Math.floor(relYear / 2)); + + const rng = context.rng; + const pick = rng?.nextBool(0.5) ?? true; + const resName = pick ? '금' : '쌀'; + const resKey = pick ? 'gold' : 'rice'; + + const nation = aux['nation']; + if (nation && typeof nation === 'object') { + const cast = nation as { gold?: number; rice?: number }; + const half = Math.floor(score / 2); + if (resKey === 'gold') { + cast.gold = (cast.gold ?? 0) + half; + } else { + cast.rice = (cast.rice ?? 0) + half; + } + } + + const selfGain = score - Math.floor(score / 2); + if (resKey === 'gold') { + context.general.gold += selfGain; + } else { + context.general.rice += selfGain; + } + + const josa = JosaUtil.pick('도기', '을'); + context.log?.push( + `${itemModule.name}${josa} 판매하여 ${resName} ${score.toLocaleString( + 'en-US' + )}을 보충합니다.` + ); + return aux; + }, +}; diff --git a/packages/logic/src/items/che_서적_03_변도론.ts b/packages/logic/src/items/che_서적_03_변도론.ts new file mode 100644 index 0000000..41aebec --- /dev/null +++ b/packages/logic/src/items/che_서적_03_변도론.ts @@ -0,0 +1,51 @@ +import { createStatItemModule } from './base.js'; +import type { ItemModule } from './types.js'; +import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js'; +import type { WarActionContext } from '@sammo-ts/logic/war/actions.js'; + +const STAT_VALUE = 3; + +const baseModule = createStatItemModule({ + key: 'che_서적_03_변도론', + rawName: '변도론', + slot: 'book', + statName: 'intelligence', + statValue: STAT_VALUE, + cost: 6000, + buyable: true, + reqSecu: 3000, + extraInfo: '[전투] 계략 시도 확률 +2%p', +}); + +export const itemModule: ItemModule = { + ...baseModule, + onCalcStat: (() => { + 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] { + let base = value; + if (statName === 'intelligence' && !Array.isArray(base)) { + base = base + STAT_VALUE; + } + if (statName === 'warMagicTrialProb') { + return Array.isArray(base) ? base : base + 0.02; + } + return base; + } + return onCalcStat; + })() as Exclude, +}; diff --git a/packages/logic/src/items/che_서적_08_전론.ts b/packages/logic/src/items/che_서적_08_전론.ts new file mode 100644 index 0000000..1d3c2a5 --- /dev/null +++ b/packages/logic/src/items/che_서적_08_전론.ts @@ -0,0 +1,52 @@ +import { createStatItemModule } from './base.js'; +import type { ItemModule } from './types.js'; +import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js'; +import type { WarActionContext } from '@sammo-ts/logic/war/actions.js'; + +const STAT_VALUE = 8; + +const baseModule = createStatItemModule({ + key: 'che_서적_08_전론', + rawName: '전론', + slot: 'book', + statName: 'intelligence', + statValue: STAT_VALUE, + cost: 200, + buyable: false, + reqSecu: 0, + unique: true, + extraInfo: '[전투] 계략 성공 시 대미지 +20%', +}); + +export const itemModule: ItemModule = { + ...baseModule, + onCalcStat: (() => { + 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] { + let base = value; + if (statName === 'intelligence' && !Array.isArray(base)) { + base = base + STAT_VALUE; + } + if (statName === 'warMagicSuccessDamage') { + return Array.isArray(base) ? base : base * 1.2; + } + return base; + } + return onCalcStat; + })() as Exclude, +}; diff --git a/packages/logic/src/items/che_치료_환약.ts b/packages/logic/src/items/che_치료_환약.ts new file mode 100644 index 0000000..98b1543 --- /dev/null +++ b/packages/logic/src/items/che_치료_환약.ts @@ -0,0 +1,40 @@ +import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js'; +import { CheItemHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_아이템치료.js'; +import { consumeItemRemain, setItemRemain } from './utils.js'; +import type { ItemModule } from './types.js'; + +const ITEM_KEY = 'che_치료_환약'; + +export const itemModule: ItemModule = { + key: ITEM_KEY, + rawName: '환약', + name: '환약(치료)', + info: '[군사] 턴 실행 전 부상 회복. 3회용', + slot: 'item', + cost: 200, + buyable: true, + consumable: true, + reqSecu: 0, + unique: false, + getPreTurnExecuteTriggerList: (context) => { + const target = context.general.triggerState.meta['use_treatment']; + const injuryTarget = + typeof target === 'number' && Number.isFinite(target) ? target : 10; + return new GeneralTriggerCaller( + new CheItemHealTrigger(context.general, { + injuryTarget, + itemKey: ITEM_KEY, + itemName: '환약(치료)', + itemRawName: '환약', + consume: () => consumeItemRemain(context.general, ITEM_KEY, 1), + }) + ); + }, + onArbitraryAction: (context, actionType, phase, aux) => { + if (actionType !== '장비매매' || phase !== '구매') { + return aux ?? null; + } + setItemRemain(context.general, ITEM_KEY, 3); + return aux ?? null; + }, +}; diff --git a/packages/logic/src/items/index.ts b/packages/logic/src/items/index.ts new file mode 100644 index 0000000..3a072be --- /dev/null +++ b/packages/logic/src/items/index.ts @@ -0,0 +1,356 @@ +import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js'; +import { + GeneralTriggerCaller, + type GeneralActionContext, +} from '@sammo-ts/logic/triggers/general.js'; +import type { WarActionContext, WarActionModule } from '@sammo-ts/logic/war/actions.js'; +import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import type { WarUnit } from '@sammo-ts/logic/war/units.js'; +import type { ItemModule, ItemModuleExport } from './types.js'; +import { listEquippedItemKeys } from './utils.js'; + +export const ITEM_KEYS = [ + 'che_명마_06_흑색마', + 'che_무기_02_단궁', + 'che_서적_03_변도론', + 'che_치료_환약', + 'che_명마_12_사륜거', + 'che_무기_09_동호비궁', + 'che_서적_08_전론', + 'che_보물_도기', +] as const; + +export type ItemKey = (typeof ITEM_KEYS)[number]; + +export type ItemImporter = () => Promise; + +const defaultImporters: Record = { + che_명마_06_흑색마: async () => import('./che_명마_06_흑색마.js'), + che_무기_02_단궁: async () => import('./che_무기_02_단궁.js'), + che_서적_03_변도론: async () => import('./che_서적_03_변도론.js'), + che_치료_환약: async () => import('./che_치료_환약.js'), + che_명마_12_사륜거: async () => import('./che_명마_12_사륜거.js'), + che_무기_09_동호비궁: async () => import('./che_무기_09_동호비궁.js'), + che_서적_08_전론: async () => import('./che_서적_08_전론.js'), + che_보물_도기: async () => import('./che_보물_도기.js'), +}; + +export const isItemKey = (value: string): value is ItemKey => + ITEM_KEYS.includes(value as ItemKey); + +export class ItemLoader { + private readonly cache = new Map>(); + + constructor( + private readonly importers: Record = defaultImporters + ) {} + + async load(key: ItemKey): Promise { + const cached = this.cache.get(key); + if (cached) { + return cached; + } + const importer = this.importers[key]; + if (!importer) { + throw new Error(`Unknown item key: ${key}`); + } + const loading = importer().then((module) => { + if (!('itemModule' in module)) { + throw new Error(`Missing itemModule for item: ${key}`); + } + const resolved = module.itemModule; + if (resolved.key !== key) { + throw new Error( + `Item key mismatch: expected ${key}, got ${resolved.key}` + ); + } + return resolved; + }); + this.cache.set(key, loading); + return loading; + } +} + +export const loadItemModules = async ( + keys: ItemKey[], + loader: ItemLoader = new ItemLoader() +): Promise => { + const modules: ItemModule[] = []; + 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 type ItemModuleRegistry< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> = Map>; + +export const createItemModuleRegistry = < + TriggerState extends GeneralTriggerState = GeneralTriggerState +>( + modules: ItemModule[] +): ItemModuleRegistry => { + const registry: ItemModuleRegistry = new Map(); + for (const module of modules) { + registry.set(module.key, module); + } + return registry; +}; + +class ItemGeneralActionRouter< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionModule { + constructor(private readonly registry: ItemModuleRegistry) {} + + private resolveModules( + context: GeneralActionContext + ): Array> { + const keys = listEquippedItemKeys(context.general); + const modules: Array> = []; + for (const key of keys) { + const module = this.registry.get(key); + if (module) { + modules.push(module); + } + } + return modules; + } + + getPreTurnExecuteTriggerList( + context: GeneralActionContext + ): GeneralTriggerCaller | null { + const caller = new GeneralTriggerCaller(); + for (const module of this.resolveModules(context)) { + const triggers = module.getPreTurnExecuteTriggerList?.(context); + if (triggers) { + caller.merge(triggers); + } + } + return caller.isEmpty() ? null : caller; + } + + onCalcDomestic( + context: GeneralActionContext, + turnType: string, + varType: string, + value: number, + aux?: unknown + ): number { + let current = value; + for (const module of this.resolveModules(context)) { + if (!module.onCalcDomestic) { + continue; + } + current = module.onCalcDomestic(context, turnType, varType, current, aux); + } + return current; + } + + onCalcStat( + context: GeneralActionContext, + statName: string, + value: number, + aux?: unknown + ): number { + let current = value; + for (const module of this.resolveModules(context)) { + if (!module.onCalcStat) { + continue; + } + current = module.onCalcStat(context, statName, current, aux); + } + return current; + } + + onCalcOpposeStat( + context: GeneralActionContext, + statName: string, + value: number, + aux?: unknown + ): number { + let current = value; + for (const module of this.resolveModules(context)) { + if (!module.onCalcOpposeStat) { + continue; + } + current = module.onCalcOpposeStat(context, statName, current, aux); + } + return current; + } + + onCalcStrategic( + context: GeneralActionContext, + turnType: string, + varType: string, + value: number + ): number { + let current = value; + for (const module of this.resolveModules(context)) { + if (!module.onCalcStrategic) { + continue; + } + current = module.onCalcStrategic(context, turnType, varType, current); + } + return current; + } + + onCalcNationalIncome( + context: GeneralActionContext, + type: string, + amount: number + ): number { + let current = amount; + for (const module of this.resolveModules(context)) { + if (!module.onCalcNationalIncome) { + continue; + } + current = module.onCalcNationalIncome(context, type, current); + } + return current; + } + + onArbitraryAction( + context: GeneralActionContext, + actionType: string, + phase?: string | null, + aux?: Record | null + ): Record | null { + let current = aux ?? null; + for (const module of this.resolveModules(context)) { + if (!module.onArbitraryAction) { + continue; + } + const result = module.onArbitraryAction(context, actionType, phase, current); + if (result !== undefined) { + current = result; + } + } + return current; + } +} + +class ItemWarActionRouter< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements WarActionModule { + constructor(private readonly registry: ItemModuleRegistry) {} + + private resolveModules( + context: WarActionContext + ): Array> { + const keys = listEquippedItemKeys(context.general); + const modules: Array> = []; + for (const key of keys) { + const module = this.registry.get(key); + if (module) { + modules.push(module); + } + } + return modules; + } + + getBattleInitTriggerList( + context: WarActionContext + ): WarTriggerCaller | null { + const caller = new WarTriggerCaller(); + for (const module of this.resolveModules(context)) { + const triggers = module.getBattleInitTriggerList?.(context); + if (triggers) { + caller.merge(triggers); + } + } + return caller.isEmpty() ? null : caller; + } + + getBattlePhaseTriggerList( + context: WarActionContext + ): WarTriggerCaller | null { + const caller = new WarTriggerCaller(); + for (const module of this.resolveModules(context)) { + const triggers = module.getBattlePhaseTriggerList?.(context); + if (triggers) { + caller.merge(triggers); + } + } + return caller.isEmpty() ? null : caller; + } + + onCalcStat( + context: WarActionContext, + statName: string, + value: number | [number, number], + aux?: unknown + ): number | [number, number] { + let current: number | [number, number] = value; + for (const module of this.resolveModules(context)) { + if (!module.onCalcStat) { + continue; + } + current = module.onCalcStat(context, statName, current, aux); + } + return current; + } + + onCalcOpposeStat( + context: WarActionContext, + statName: string, + value: number | [number, number], + aux?: unknown + ): number | [number, number] { + let current: number | [number, number] = value; + for (const module of this.resolveModules(context)) { + if (!module.onCalcOpposeStat) { + continue; + } + current = module.onCalcOpposeStat(context, statName, current, aux); + } + return current; + } + + getWarPowerMultiplier( + context: WarActionContext, + unit: WarUnit, + oppose: WarUnit + ): [number, number] { + let attack = 1; + let defence = 1; + for (const module of this.resolveModules(context)) { + if (!module.getWarPowerMultiplier) { + continue; + } + const [attMul, defMul] = module.getWarPowerMultiplier( + context, + unit, + oppose + ); + attack *= attMul; + defence *= defMul; + } + return [attack, defence]; + } +} + +export const createItemActionModules = < + TriggerState extends GeneralTriggerState = GeneralTriggerState +>( + registry: ItemModuleRegistry +): { general: GeneralActionModule[]; war: WarActionModule[] } => ({ + general: [new ItemGeneralActionRouter(registry)], + war: [new ItemWarActionRouter(registry)], +}); + +export type { ItemModule, ItemModuleExport, ItemSlot } from './types.js'; +export { + canAcquireItem, + isInventoryEnabled, + listEquippedItemKeys, + consumeItemRemain, + getItemRemain, + setItemRemain, +} from './utils.js'; diff --git a/packages/logic/src/items/types.ts b/packages/logic/src/items/types.ts new file mode 100644 index 0000000..21c420e --- /dev/null +++ b/packages/logic/src/items/types.ts @@ -0,0 +1,107 @@ +import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { + GeneralActionContext, + GeneralTriggerCaller, +} from '@sammo-ts/logic/triggers/general.js'; +import type { WarActionContext } from '@sammo-ts/logic/war/actions.js'; +import type { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import type { WarUnit } from '@sammo-ts/logic/war/units.js'; + +export type ItemSlot = 'horse' | 'weapon' | 'book' | 'item'; + +export interface ItemModule< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + key: string; + name: string; + rawName: string; + info: string; + slot: ItemSlot; + cost: number | null; + buyable: boolean; + consumable: boolean; + reqSecu: number; + unique: boolean; + + getPreTurnExecuteTriggerList?( + context: GeneralActionContext + ): GeneralTriggerCaller | null; + + onCalcDomestic?( + context: GeneralActionContext, + turnType: string, + varType: string, + value: number, + aux?: unknown + ): number; + + onCalcStat?: { + ( + context: GeneralActionContext, + statName: string, + value: number, + aux?: unknown + ): number; + ( + context: WarActionContext, + statName: string, + value: number | [number, number], + aux?: unknown + ): number | [number, number]; + }; + + onCalcOpposeStat?: { + ( + context: GeneralActionContext, + statName: string, + value: number, + aux?: unknown + ): number; + ( + context: WarActionContext, + statName: string, + value: number | [number, number], + aux?: unknown + ): number | [number, number]; + }; + + onCalcStrategic?( + context: GeneralActionContext, + turnType: string, + varType: string, + value: number + ): number; + + onCalcNationalIncome?( + context: GeneralActionContext, + type: string, + amount: number + ): number; + + onArbitraryAction?( + context: GeneralActionContext, + actionType: string, + phase?: string | null, + aux?: Record | null + ): Record | null; + + getBattleInitTriggerList?( + context: WarActionContext + ): WarTriggerCaller | null; + + getBattlePhaseTriggerList?( + context: WarActionContext + ): WarTriggerCaller | null; + + getWarPowerMultiplier?( + context: WarActionContext, + unit: WarUnit, + oppose: WarUnit + ): [number, number]; +} + +export interface ItemModuleExport< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + itemModule: ItemModule; +} diff --git a/packages/logic/src/items/utils.ts b/packages/logic/src/items/utils.ts new file mode 100644 index 0000000..eb0e33d --- /dev/null +++ b/packages/logic/src/items/utils.ts @@ -0,0 +1,123 @@ +import type { ScenarioConfig } from '@sammo-ts/logic/scenario/types.js'; +import type { + General, + GeneralTriggerState, +} from '@sammo-ts/logic/domain/entities.js'; +import type { ItemModule } from './types.js'; + +const ITEM_REMAIN_PREFIX = 'itemRemain:'; + +const toBoolean = (value: unknown): boolean => { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value > 0; + } + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + return normalized === 'true' || normalized === 'yes' || normalized === '1'; + } + return false; +}; + +export const isInventoryEnabled = (config: ScenarioConfig): boolean => { + const constConfig = config.const ?? {}; + return toBoolean( + constConfig['allowInventory'] ?? + constConfig['inventoryEnabled'] ?? + constConfig['enableInventory'] + ); +}; + +export const listEquippedItemKeys = < + TriggerState extends GeneralTriggerState +>( + general: General +): string[] => { + const items = [ + general.role.items.horse, + general.role.items.weapon, + general.role.items.book, + general.role.items.item, + ]; + const seen = new Set(); + const result: string[] = []; + for (const key of items) { + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + result.push(key); + } + return result; +}; + +export const getItemRemain = < + TriggerState extends GeneralTriggerState +>( + general: General, + itemKey: string +): number | null => { + const value = general.triggerState.counters[ + `${ITEM_REMAIN_PREFIX}${itemKey}` + ]; + return typeof value === 'number' && value > 0 ? value : null; +}; + +export const setItemRemain = < + TriggerState extends GeneralTriggerState +>( + general: General, + itemKey: string, + remain: number | null +): void => { + const key = `${ITEM_REMAIN_PREFIX}${itemKey}`; + if (remain === null || remain <= 0) { + delete general.triggerState.counters[key]; + return; + } + general.triggerState.counters[key] = remain; +}; + +export const consumeItemRemain = < + TriggerState extends GeneralTriggerState +>( + general: General, + itemKey: string, + fallbackRemain = 1 +): boolean => { + const remain = getItemRemain(general, itemKey) ?? fallbackRemain; + if (remain > 1) { + setItemRemain(general, itemKey, remain - 1); + return false; + } + setItemRemain(general, itemKey, null); + return true; +}; + +export const canAcquireItem = < + TriggerState extends GeneralTriggerState +>(options: { + general: General; + item: ItemModule; + config: ScenarioConfig; + registry: Map; +}): boolean => { + const { general, item, config, registry } = options; + if (!item.unique) { + return true; + } + if (isInventoryEnabled(config)) { + return true; + } + const slotItemKey = general.role.items[item.slot]; + if (!slotItemKey) { + return true; + } + const slotItem = registry.get(slotItemKey); + if (!slotItem) { + return true; + } + return !slotItem.unique; +}; diff --git a/packages/logic/src/triggers/generalTriggers/che_아이템치료.ts b/packages/logic/src/triggers/generalTriggers/che_아이템치료.ts new file mode 100644 index 0000000..8c9ab51 --- /dev/null +++ b/packages/logic/src/triggers/generalTriggers/che_아이템치료.ts @@ -0,0 +1,53 @@ +import { JosaUtil } from '@sammo-ts/common'; +import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js'; +import { + BaseGeneralTrigger, + type GeneralTriggerContext, +} from '@sammo-ts/logic/triggers/general.js'; +import type { General } from '@sammo-ts/logic/domain/entities.js'; + +interface ItemHealOptions { + itemKey: string; + itemName: string; + itemRawName: string; + injuryTarget: number; + consume: () => boolean; +} + +// 아이템(환약 등)으로 턴 실행 전 부상을 회복하는 트리거. +export class CheItemHealTrigger extends BaseGeneralTrigger { + public readonly priority = TriggerPriority.Begin - 10; + private readonly options: ItemHealOptions; + + constructor(general: General, options: ItemHealOptions) { + super(general); + this.options = options; + } + + action( + context: GeneralTriggerContext, + env: Record + ): Record { + const { general } = context; + if (general.role.items.item !== this.options.itemKey) { + return env; + } + if (general.injury < this.options.injuryTarget) { + return env; + } + + general.injury = 0; + context.skill.activate('pre.부상경감', 'pre.치료'); + + const josa = JosaUtil.pick(this.options.itemRawName, '을'); + context.log?.push( + `${this.options.itemName}${josa} 사용하여 치료합니다!` + ); + + if (this.options.consume()) { + general.role.items.item = null; + } + + return env; + } +} diff --git a/packages/logic/src/war/triggers/che_저격.ts b/packages/logic/src/war/triggers/che_저격.ts new file mode 100644 index 0000000..9b4dd29 --- /dev/null +++ b/packages/logic/src/war/triggers/che_저격.ts @@ -0,0 +1,128 @@ +import { LogFormat } from '@sammo-ts/logic/logging/types.js'; +import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js'; +import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js'; +import { WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js'; +import { clamp } from '@sammo-ts/logic/war/utils.js'; + +// 저격 시도 트리거: 전투 시작 타이밍에 확률 체크. +export class CheSnipingAttemptTrigger extends BaseWarUnitTrigger { + private readonly ratio: number; + private readonly woundMin: number; + private readonly woundMax: number; + private readonly addAtmos: number; + + constructor( + unit: WarUnit, + raiseType: number, + ratio: number, + woundMin: number, + woundMax: number, + addAtmos = 20 + ) { + super(unit, TriggerPriority.Pre + 100, raiseType); + this.ratio = ratio; + this.woundMin = woundMin; + this.woundMax = woundMax; + this.addAtmos = addAtmos; + } + + protected actionWar( + self: WarUnit, + oppose: WarUnit, + selfEnv: Record, + _opposeEnv: Record + ): boolean { + if (!(self instanceof WarUnitGeneral)) { + return true; + } + if (self.getPhase() !== 0 && oppose.getPhase() !== 0) { + return true; + } + if (oppose.getPhase() < 0) { + return true; + } + if (self.hasActivatedSkill('저격')) { + return true; + } + if (self.hasActivatedSkill('저격불가')) { + return true; + } + if (!self.rng.nextBool(this.ratio)) { + return true; + } + + self.activateSkill('저격'); + selfEnv['저격발동자'] = this.raiseType; + selfEnv['woundMin'] = this.woundMin; + selfEnv['woundMax'] = this.woundMax; + selfEnv['addAtmos'] = this.addAtmos; + return true; + } +} + +// 저격 발동 트리거: 성공 시 사기 보정과 부상 판정. +export class CheSnipingActivateTrigger extends BaseWarUnitTrigger { + constructor(unit: WarUnit, raiseType: number) { + super(unit, TriggerPriority.Post + 100, raiseType); + } + + protected actionWar( + self: WarUnit, + oppose: WarUnit, + selfEnv: Record, + _opposeEnv: Record + ): boolean { + if (!self.hasActivatedSkill('저격')) { + return true; + } + if ((selfEnv['저격발동자'] ?? -1) !== this.raiseType) { + return true; + } + if (selfEnv['저격발동']) { + return true; + } + selfEnv['저격발동'] = true; + + if (oppose instanceof WarUnitGeneral) { + self.getLogger().pushGeneralActionLog('상대를 저격했다!', LogFormat.PLAIN); + self.getLogger().pushGeneralBattleDetailLog( + '상대를 저격했다!', + LogFormat.PLAIN + ); + oppose.getLogger().pushGeneralActionLog( + '상대에게 저격당했다!', + LogFormat.PLAIN + ); + oppose.getLogger().pushGeneralBattleDetailLog( + '상대에게 저격당했다!', + LogFormat.PLAIN + ); + } else { + self.getLogger().pushGeneralActionLog( + '성벽 수비대장을 저격했다!', + LogFormat.PLAIN + ); + self.getLogger().pushGeneralBattleDetailLog( + '성벽 수비대장을 저격했다!', + LogFormat.PLAIN + ); + } + + const addAtmos = Number(selfEnv['addAtmos'] ?? 0); + self.addAtmos(addAtmos); + + if (!oppose.hasActivatedSkill('부상무효') && oppose instanceof WarUnitGeneral) { + const woundMin = Number(selfEnv['woundMin'] ?? 10); + const woundMax = Number(selfEnv['woundMax'] ?? 80); + oppose.getGeneral().injury = clamp( + oppose.getGeneral().injury + + self.rng.nextRangeInt(woundMin, woundMax), + 0, + 80 + ); + } + + this.processConsumableItem(); + return true; + } +} diff --git a/packages/logic/src/war/triggers/che_퇴각부상무효.ts b/packages/logic/src/war/triggers/che_퇴각부상무효.ts new file mode 100644 index 0000000..2d22417 --- /dev/null +++ b/packages/logic/src/war/triggers/che_퇴각부상무효.ts @@ -0,0 +1,23 @@ +import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js'; +import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js'; +import { WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js'; + +// 전투 종료 부상을 무효화하는 상태를 부여한다. +export class CheRetreatNoWoundTrigger extends BaseWarUnitTrigger { + constructor(unit: WarUnit) { + super(unit, TriggerPriority.Begin + 300); + } + + protected actionWar( + self: WarUnit, + _oppose: WarUnit, + _selfEnv: Record, + _opposeEnv: Record + ): boolean { + if (!(self instanceof WarUnitGeneral)) { + return true; + } + self.activateSkill('퇴각부상무효'); + return true; + } +}