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:
@@ -8,11 +8,16 @@ import {
|
|||||||
LogScope,
|
LogScope,
|
||||||
resolveDefenderOrder,
|
resolveDefenderOrder,
|
||||||
resolveWarBattle,
|
resolveWarBattle,
|
||||||
|
createItemActionModules,
|
||||||
|
createItemModuleRegistry,
|
||||||
|
ITEM_KEYS,
|
||||||
|
loadItemModules,
|
||||||
type City,
|
type City,
|
||||||
type General,
|
type General,
|
||||||
type Nation,
|
type Nation,
|
||||||
type UnitSetDefinition,
|
type UnitSetDefinition,
|
||||||
type WarBattleOutcome,
|
type WarBattleOutcome,
|
||||||
|
type WarActionModule,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +29,10 @@ import { convertLog } from './logFormatter.js';
|
|||||||
|
|
||||||
const DEFAULT_GENERAL_AGE = 20;
|
const DEFAULT_GENERAL_AGE = 20;
|
||||||
|
|
||||||
|
const itemWarModules: WarActionModule[] = createItemActionModules(
|
||||||
|
createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]))
|
||||||
|
).war;
|
||||||
|
|
||||||
const normalizeItemCode = (value: string | null): string | null =>
|
const normalizeItemCode = (value: string | null): string | null =>
|
||||||
value === 'None' ? null : value;
|
value === 'None' ? null : value;
|
||||||
|
|
||||||
@@ -324,11 +333,13 @@ export const processBattleSimJob = (payload: BattleSimJobPayload): BattleSimResu
|
|||||||
general: attackerGeneral,
|
general: attackerGeneral,
|
||||||
city: attackerCity,
|
city: attackerCity,
|
||||||
nation: attackerNation,
|
nation: attackerNation,
|
||||||
|
modules: itemWarModules,
|
||||||
},
|
},
|
||||||
defenders: defenderGenerals.map((general) => ({
|
defenders: defenderGenerals.map((general) => ({
|
||||||
general,
|
general,
|
||||||
city: defenderCity,
|
city: defenderCity,
|
||||||
nation: defenderNation,
|
nation: defenderNation,
|
||||||
|
modules: itemWarModules,
|
||||||
})),
|
})),
|
||||||
defenderCity,
|
defenderCity,
|
||||||
defenderNation,
|
defenderNation,
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import type {
|
|||||||
import {
|
import {
|
||||||
loadGeneralTurnCommandSpecs,
|
loadGeneralTurnCommandSpecs,
|
||||||
loadNationTurnCommandSpecs,
|
loadNationTurnCommandSpecs,
|
||||||
|
createItemActionModules,
|
||||||
|
createItemModuleRegistry,
|
||||||
|
ITEM_KEYS,
|
||||||
|
loadItemModules,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
|
|
||||||
const DEFAULT_GENERAL_GOLD = 1000;
|
const DEFAULT_GENERAL_GOLD = 1000;
|
||||||
@@ -158,6 +162,18 @@ export const buildReservedTurnDefinitions = async (options: {
|
|||||||
general: Map<string, GeneralActionDefinition>;
|
general: Map<string, GeneralActionDefinition>;
|
||||||
nation: 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(
|
const generalSpecs = await loadGeneralTurnCommandSpecs(
|
||||||
options.commandProfile.general
|
options.commandProfile.general
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ Move items into the main docs once they are finalized.
|
|||||||
## Trigger System
|
## Trigger System
|
||||||
|
|
||||||
- Example trigger sets per scenario or rule pack
|
- 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)
|
## Data and Profiles (Lower Priority)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
|
import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
|
||||||
|
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
|
||||||
|
|
||||||
export interface TurnCommandEnv {
|
export interface TurnCommandEnv {
|
||||||
develCost: number;
|
develCost: number;
|
||||||
@@ -24,4 +25,5 @@ export interface TurnCommandEnv {
|
|||||||
baseRice: number;
|
baseRice: number;
|
||||||
maxResourceActionAmount: number;
|
maxResourceActionAmount: number;
|
||||||
generalActionModules?: Array<GeneralActionModule>;
|
generalActionModules?: Array<GeneralActionModule>;
|
||||||
|
warActionModules?: Array<WarActionModule>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import type {
|
|||||||
} from '@sammo-ts/logic/war/types.js';
|
} from '@sammo-ts/logic/war/types.js';
|
||||||
import { resolveWarAftermath } from '@sammo-ts/logic/war/aftermath.js';
|
import { resolveWarAftermath } from '@sammo-ts/logic/war/aftermath.js';
|
||||||
import { resolveWarBattle } from '@sammo-ts/logic/war/engine.js';
|
import { resolveWarBattle } from '@sammo-ts/logic/war/engine.js';
|
||||||
|
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
|
||||||
import {
|
import {
|
||||||
increaseMetaNumber,
|
increaseMetaNumber,
|
||||||
simpleSerialize,
|
simpleSerialize,
|
||||||
@@ -268,6 +269,11 @@ export class ActionDefinition<
|
|||||||
> {
|
> {
|
||||||
public readonly key = 'che_출병';
|
public readonly key = 'che_출병';
|
||||||
public readonly name = ACTION_NAME;
|
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 {
|
parseArgs(raw: unknown): DispatchArgs | null {
|
||||||
const data = raw as { destCityId?: unknown };
|
const data = raw as { destCityId?: unknown };
|
||||||
@@ -439,11 +445,13 @@ export class ActionDefinition<
|
|||||||
general: context.general,
|
general: context.general,
|
||||||
city: attackerCity,
|
city: attackerCity,
|
||||||
nation: attackerNation,
|
nation: attackerNation,
|
||||||
|
modules: this.warModules,
|
||||||
},
|
},
|
||||||
defenders: defenderGenerals.map((general) => ({
|
defenders: defenderGenerals.map((general) => ({
|
||||||
general,
|
general,
|
||||||
city: defenderCity,
|
city: defenderCity,
|
||||||
nation: defenderNation,
|
nation: defenderNation,
|
||||||
|
modules: this.warModules,
|
||||||
})),
|
})),
|
||||||
defenderCity,
|
defenderCity,
|
||||||
defenderNation,
|
defenderNation,
|
||||||
@@ -580,5 +588,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
|||||||
category: '군사',
|
category: '군사',
|
||||||
reqArg: true,
|
reqArg: true,
|
||||||
args: { destCityId: 0 },
|
args: { destCityId: 0 },
|
||||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
createDefinition: (env: TurnCommandEnv) =>
|
||||||
|
new ActionDefinition(env.warActionModules ?? []),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ export * from './actions/index.js';
|
|||||||
export * from './constraints/index.js';
|
export * from './constraints/index.js';
|
||||||
export * from './logging/index.js';
|
export * from './logging/index.js';
|
||||||
export * from './messages/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/world.js';
|
||||||
export * from './ports/worldSnapshot.js';
|
export * from './ports/worldSnapshot.js';
|
||||||
export * from './scenario/index.js';
|
export * from './scenario/index.js';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user