feat(items): add new item modules and enhance item functionality

- Introduced various item modules including '흑색마', '사륜거', '단궁', '동호비궁', '도기', '변도론', '전론', and '환약'.
- Implemented stat item modules to enhance general and war action contexts.
- Added triggers for item healing and sniping actions during battles.
- Enhanced item management with new utility functions for inventory handling.
- Updated the item registry and loader to support new items and their functionalities.
- Improved the overall structure of item-related code for better maintainability and scalability.
This commit is contained in:
2026-01-03 09:28:46 +00:00
parent e10bcba49b
commit d1522ff450
21 changed files with 1248 additions and 1 deletions
+11
View File
@@ -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,
@@ -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<string, GeneralActionDefinition>;
nation: Map<string, GeneralActionDefinition>;
}> => {
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
);
+1
View File
@@ -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)
@@ -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<GeneralActionModule>;
warActionModules?: Array<WarActionModule>;
}
@@ -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<WarActionModule<TriggerState>>;
constructor(modules: Array<WarActionModule<TriggerState> | null | undefined> = []) {
this.warModules = modules.filter(Boolean) as Array<WarActionModule<TriggerState>>;
}
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 ?? []),
};
+7
View File
@@ -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';
+77
View File
@@ -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}<br>${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,
};
};
@@ -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,
});
@@ -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));
},
};
@@ -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)
);
},
};
@@ -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)
);
},
};
@@ -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(
`<C>${itemModule.name}</>${josa} 판매하여 ${resName} <C>${score.toLocaleString(
'en-US'
)}</>을 보충합니다.`
);
return aux;
},
};
@@ -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<ItemModule['onCalcStat'], undefined>,
};
@@ -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<ItemModule['onCalcStat'], undefined>,
};
@@ -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;
},
};
+356
View File
@@ -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<ItemModuleExport>;
const defaultImporters: Record<ItemKey, ItemImporter> = {
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<ItemKey, Promise<ItemModule>>();
constructor(
private readonly importers: Record<ItemKey, ItemImporter> = defaultImporters
) {}
async load(key: ItemKey): Promise<ItemModule> {
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<ItemModule[]> => {
const modules: ItemModule[] = [];
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 type ItemModuleRegistry<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> = Map<string, ItemModule<TriggerState>>;
export const createItemModuleRegistry = <
TriggerState extends GeneralTriggerState = GeneralTriggerState
>(
modules: ItemModule<TriggerState>[]
): ItemModuleRegistry<TriggerState> => {
const registry: ItemModuleRegistry<TriggerState> = new Map();
for (const module of modules) {
registry.set(module.key, module);
}
return registry;
};
class ItemGeneralActionRouter<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionModule<TriggerState> {
constructor(private readonly registry: ItemModuleRegistry<TriggerState>) {}
private resolveModules(
context: GeneralActionContext<TriggerState>
): Array<ItemModule<TriggerState>> {
const keys = listEquippedItemKeys(context.general);
const modules: Array<ItemModule<TriggerState>> = [];
for (const key of keys) {
const module = this.registry.get(key);
if (module) {
modules.push(module);
}
}
return modules;
}
getPreTurnExecuteTriggerList(
context: GeneralActionContext<TriggerState>
): GeneralTriggerCaller<TriggerState> | null {
const caller = new GeneralTriggerCaller<TriggerState>();
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<TriggerState>,
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<TriggerState>,
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<TriggerState>,
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<TriggerState>,
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<TriggerState>,
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<TriggerState>,
actionType: string,
phase?: string | null,
aux?: Record<string, unknown> | null
): Record<string, unknown> | 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<TriggerState> {
constructor(private readonly registry: ItemModuleRegistry<TriggerState>) {}
private resolveModules(
context: WarActionContext<TriggerState>
): Array<ItemModule<TriggerState>> {
const keys = listEquippedItemKeys(context.general);
const modules: Array<ItemModule<TriggerState>> = [];
for (const key of keys) {
const module = this.registry.get(key);
if (module) {
modules.push(module);
}
}
return modules;
}
getBattleInitTriggerList(
context: WarActionContext<TriggerState>
): 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<TriggerState>
): 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<TriggerState>,
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<TriggerState>,
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<TriggerState>,
unit: WarUnit<TriggerState>,
oppose: WarUnit<TriggerState>
): [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<TriggerState>
): { general: GeneralActionModule<TriggerState>[]; war: WarActionModule<TriggerState>[] } => ({
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';
+107
View File
@@ -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<TriggerState>
): GeneralTriggerCaller<TriggerState> | null;
onCalcDomestic?(
context: GeneralActionContext<TriggerState>,
turnType: string,
varType: string,
value: number,
aux?: unknown
): number;
onCalcStat?: {
(
context: GeneralActionContext<TriggerState>,
statName: string,
value: number,
aux?: unknown
): number;
(
context: WarActionContext<TriggerState>,
statName: string,
value: number | [number, number],
aux?: unknown
): number | [number, number];
};
onCalcOpposeStat?: {
(
context: GeneralActionContext<TriggerState>,
statName: string,
value: number,
aux?: unknown
): number;
(
context: WarActionContext<TriggerState>,
statName: string,
value: number | [number, number],
aux?: unknown
): number | [number, number];
};
onCalcStrategic?(
context: GeneralActionContext<TriggerState>,
turnType: string,
varType: string,
value: number
): number;
onCalcNationalIncome?(
context: GeneralActionContext<TriggerState>,
type: string,
amount: number
): number;
onArbitraryAction?(
context: GeneralActionContext<TriggerState>,
actionType: string,
phase?: string | null,
aux?: Record<string, unknown> | null
): Record<string, unknown> | null;
getBattleInitTriggerList?(
context: WarActionContext<TriggerState>
): WarTriggerCaller | null;
getBattlePhaseTriggerList?(
context: WarActionContext<TriggerState>
): WarTriggerCaller | null;
getWarPowerMultiplier?(
context: WarActionContext<TriggerState>,
unit: WarUnit<TriggerState>,
oppose: WarUnit<TriggerState>
): [number, number];
}
export interface ItemModuleExport<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
itemModule: ItemModule<TriggerState>;
}
+123
View File
@@ -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<TriggerState>
): string[] => {
const items = [
general.role.items.horse,
general.role.items.weapon,
general.role.items.book,
general.role.items.item,
];
const seen = new Set<string>();
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<TriggerState>,
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<TriggerState>,
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<TriggerState>,
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<TriggerState>;
item: ItemModule;
config: ScenarioConfig;
registry: Map<string, ItemModule>;
}): 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;
};
@@ -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<string, unknown>
): Record<string, unknown> {
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(
`<C>${this.options.itemName}</>${josa} 사용하여 치료합니다!`
);
if (this.options.consume()) {
general.role.items.item = null;
}
return env;
}
}
@@ -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<string, unknown>,
_opposeEnv: Record<string, unknown>
): 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<string, unknown>,
_opposeEnv: Record<string, unknown>
): 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('상대를 <C>저격</>했다!', LogFormat.PLAIN);
self.getLogger().pushGeneralBattleDetailLog(
'상대를 <C>저격</>했다!',
LogFormat.PLAIN
);
oppose.getLogger().pushGeneralActionLog(
'상대에게 <R>저격</>당했다!',
LogFormat.PLAIN
);
oppose.getLogger().pushGeneralBattleDetailLog(
'상대에게 <R>저격</>당했다!',
LogFormat.PLAIN
);
} else {
self.getLogger().pushGeneralActionLog(
'성벽 수비대장을 <C>저격</>했다!',
LogFormat.PLAIN
);
self.getLogger().pushGeneralBattleDetailLog(
'성벽 수비대장을 <C>저격</>했다!',
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;
}
}
@@ -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<string, unknown>,
_opposeEnv: Record<string, unknown>
): boolean {
if (!(self instanceof WarUnitGeneral)) {
return true;
}
self.activateSkill('퇴각부상무효');
return true;
}
}