refactor(logic): replace arbitrary action hooks with typed events

This commit is contained in:
2026-07-30 03:31:31 +00:00
parent 96b41ef4b4
commit 7d3cb50a5b
168 changed files with 1554 additions and 631 deletions
+132
View File
@@ -0,0 +1,132 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import { compileCrewTypeCatalog } from '@sammo-ts/logic/crewType/catalog.js';
import { createCrewTypeWarTriggerRegistry } from '@sammo-ts/logic/war/crewTypeTriggers.js';
import { createInheritBuffModules } from '@sammo-ts/logic/inheritance/inheritBuff.js';
import {
createItemActionModules,
createItemModuleRegistry,
ITEM_KEYS,
loadItemModules,
type ItemModule,
} from '@sammo-ts/logic/items/index.js';
import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
import type { GeneralActionModule } from './general.js';
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
import { createOfficerLevelActionModules } from './officerLevel.js';
import {
createTraitCatalog,
DOMESTIC_TRAIT_KEYS,
loadDomesticTraitModules,
loadNationTraitModules,
loadPersonalityTraitModules,
loadWarTraitModules,
NATION_TRAIT_KEYS,
PERSONALITY_TRAIT_KEYS,
TraitGeneralActionRouter,
TraitWarActionRouter,
WAR_TRAIT_KEYS,
} from './traits/index.js';
import type { NationTraitModule } from './traits/nation/index.js';
export interface ActionModuleBundle<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
general: RefOrderedActionStack<GeneralActionModule<TriggerState>>;
war: RefOrderedActionStack<WarActionModule<TriggerState>>;
itemModules: ItemModule<TriggerState>[];
nationTraitModules: NationTraitModule[];
}
const refActionOrderBrand: unique symbol = Symbol('RefOrderedActionStack');
export type RefOrderedActionStack<Module> = ReadonlyArray<Module> & {
readonly [refActionOrderBrand]: true;
};
interface RefActionSlots<Module> {
nation: Module;
officer: Module;
domestic: Module;
war: Module;
personality: Module;
crewType: Module | null;
inheritance: Module;
scenario: Module | null;
items: readonly Module[];
}
const markRefOrderedActionStack: <Module>(
stack: Module[]
) => asserts stack is Module[] & { readonly [refActionOrderBrand]: true } = (stack) => {
Object.defineProperty(stack, refActionOrderBrand, {
value: true,
enumerable: false,
writable: false,
});
};
// ref General::getActionList()의 소유권 순서를 한 곳에서만 조립합니다.
export const createRefOrderedActionStack = <Module>(slots: RefActionSlots<Module>): RefOrderedActionStack<Module> => {
const stack = [
slots.nation,
slots.officer,
slots.domestic,
slots.war,
slots.personality,
...(slots.crewType ? [slots.crewType] : []),
slots.inheritance,
...(slots.scenario ? [slots.scenario] : []),
...slots.items,
];
markRefOrderedActionStack(stack);
return stack;
};
// General::getActionList와 같은 소유권 순서로 실제 턴과 시뮬레이터의 모듈을 조립한다.
export const loadActionModuleBundle = async <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
unitSet?: UnitSetDefinition
): Promise<ActionModuleBundle<TriggerState>> => {
const [domestic, war, personality, nation, itemModules] = await Promise.all([
loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS]),
loadWarTraitModules([...WAR_TRAIT_KEYS]),
loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]),
loadNationTraitModules([...NATION_TRAIT_KEYS]),
loadItemModules([...ITEM_KEYS]) as Promise<ItemModule<TriggerState>[]>,
]);
const traitCatalog = createTraitCatalog<TriggerState>({ domestic, war, personality, nation });
const officer = createOfficerLevelActionModules<TriggerState>();
const items = createItemActionModules(createItemModuleRegistry(itemModules));
const inherit = createInheritBuffModules();
const crewTypeCatalog = unitSet?.crewTypes?.length
? compileCrewTypeCatalog(unitSet, createCrewTypeWarTriggerRegistry())
: null;
return {
general: createRefOrderedActionStack<GeneralActionModule<TriggerState>>({
nation: new TraitGeneralActionRouter('nation', traitCatalog),
officer: officer.general,
domestic: new TraitGeneralActionRouter('domestic', traitCatalog),
war: new TraitGeneralActionRouter('war', traitCatalog),
personality: new TraitGeneralActionRouter('personality', traitCatalog),
crewType: crewTypeCatalog
? (crewTypeCatalog.generalActionModule as GeneralActionModule<TriggerState>)
: null,
inheritance: inherit.general as GeneralActionModule<TriggerState>,
// scenarioEffect는 현재 core runtime module이 없어 명시적으로 빈 slot입니다.
scenario: null,
items: items.general,
}),
war: createRefOrderedActionStack<WarActionModule<TriggerState>>({
nation: new TraitWarActionRouter('nation', traitCatalog),
officer: officer.war,
domestic: new TraitWarActionRouter('domestic', traitCatalog),
war: new TraitWarActionRouter('war', traitCatalog),
personality: new TraitWarActionRouter('personality', traitCatalog),
crewType: crewTypeCatalog ? (crewTypeCatalog.warActionModule as WarActionModule<TriggerState>) : null,
inheritance: inherit.war as WarActionModule<TriggerState>,
// ref의 scenarioEffect 위치를 보존하되 미이식 module은 별도 gap으로 남깁니다.
scenario: null,
items: items.war,
}),
itemModules,
nationTraitModules: nation,
};
};
@@ -0,0 +1,88 @@
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { RandomGenerator } from '@sammo-ts/common';
const generalActionEventBrand: unique symbol = Symbol('GeneralActionEvent');
export interface GeneralActionEventPayloadMap {
'item.purchased': {
itemKey: string;
slot: 'horse' | 'weapon' | 'book' | 'item';
};
'item.sold': {
itemKey: string;
slot: 'horse' | 'weapon' | 'book' | 'item';
};
'strategy.succeeded': {
consumedItems: readonly string[];
};
'city.conquered': {
attacker: General;
};
}
export type GeneralActionEventType = keyof GeneralActionEventPayloadMap;
export type GeneralActionEventContext<
K extends GeneralActionEventType,
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> = K extends 'item.sold' | 'city.conquered'
? GeneralActionContext<TriggerState> & { rng: RandomGenerator } & (K extends 'item.sold'
? { time: NonNullable<GeneralActionContext<TriggerState>['time']> }
: object)
: GeneralActionContext<TriggerState>;
/**
* 레거시 문자열/phase/aux 삼중항을 닫힌 이벤트 계약으로 투영합니다.
*
* unique-symbol 필드는 런타임 분기용이 아니라 서로 다른 이벤트 payload가
* 구조적으로 우연히 호환되는 것을 막는 nominal shadow type입니다. 이벤트는
* 반드시 createGeneralActionEvent()로 만들며, handler는 같은 K만 반환합니다.
*/
export type GeneralActionEvent<
K extends GeneralActionEventType,
_TriggerState extends GeneralTriggerState = GeneralTriggerState,
> = Readonly<{
type: K;
payload: Readonly<GeneralActionEventPayloadMap[K]>;
[generalActionEventBrand]: K;
}>;
export type GeneralActionEventHandler<TriggerState extends GeneralTriggerState, K extends GeneralActionEventType> = {
bivarianceHack(
context: GeneralActionEventContext<K, TriggerState>,
event: GeneralActionEvent<K, TriggerState>
): GeneralActionEvent<K, TriggerState> | void;
}['bivarianceHack'];
export type GeneralActionEventHandlers<TriggerState extends GeneralTriggerState = GeneralTriggerState> = Partial<{
[K in GeneralActionEventType]: GeneralActionEventHandler<TriggerState, K>;
}>;
export const createGeneralActionEvent = <
TriggerState extends GeneralTriggerState = GeneralTriggerState,
K extends GeneralActionEventType = GeneralActionEventType,
>(
type: K,
payload: GeneralActionEventPayloadMap[K]
): GeneralActionEvent<K, TriggerState> => ({
type,
payload,
[generalActionEventBrand]: type,
});
export const dispatchGeneralActionEventHandlers = <
TriggerState extends GeneralTriggerState,
K extends GeneralActionEventType,
>(
handlers: GeneralActionEventHandlers<TriggerState> | null | undefined,
context: GeneralActionEventContext<K, TriggerState>,
event: GeneralActionEvent<K, TriggerState>
): GeneralActionEvent<K, TriggerState> => {
// Mapped-type lookup preserves K, but TypeScript cannot currently retain
// that correlation through a generic indexed access. This single local
// assertion is the proof boundary; module authors and call sites remain
// fully checked without unknown/any casts.
const handler = handlers?.[event.type] as GeneralActionEventHandler<TriggerState, K> | undefined;
return handler?.(context, event) ?? event;
};
+211
View File
@@ -0,0 +1,211 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import { type GeneralActionContext, GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js';
import type {
GeneralStatName,
TriggerDomesticActionType,
TriggerDomesticVarType,
TriggerNationalIncomeType,
TriggerStrategicActionType,
TriggerStrategicVarType,
} from './types.js';
import {
dispatchGeneralActionEventHandlers,
type GeneralActionEvent,
type GeneralActionEventContext,
type GeneralActionEventHandlers,
type GeneralActionEventType,
} from './events.js';
interface GeneralActionModuleBase<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
getName?: (() => string) | undefined;
getInfo?: (() => string) | undefined;
getPreTurnExecuteTriggerList?:
((context: GeneralActionContext<TriggerState>) => GeneralTriggerCaller<TriggerState> | null) | undefined;
onCalcDomestic?:
| ((
context: GeneralActionContext<TriggerState>,
turnType: TriggerDomesticActionType,
varType: TriggerDomesticVarType,
value: number,
aux?: unknown
) => number)
| undefined;
onCalcStat?:
| ((
context: GeneralActionContext<TriggerState>,
statName: GeneralStatName,
value: number,
aux?: unknown
) => number)
| undefined;
onCalcOpposeStat?:
| ((
context: GeneralActionContext<TriggerState>,
statName: GeneralStatName,
value: number,
aux?: unknown
) => number)
| undefined;
onCalcStrategic?:
| ((
context: GeneralActionContext<TriggerState>,
turnType: TriggerStrategicActionType,
varType: TriggerStrategicVarType,
value: number
) => number)
| undefined;
onCalcNationalIncome?:
| ((context: GeneralActionContext<TriggerState>, type: TriggerNationalIncomeType, amount: number) => number)
| undefined;
}
interface GeneralActionLeafModule<TriggerState extends GeneralTriggerState> {
eventHandlers?: GeneralActionEventHandlers<TriggerState> | undefined;
handleEvent?: never;
}
interface GeneralActionCompositeModule<TriggerState extends GeneralTriggerState> {
eventHandlers?: never;
handleEvent<K extends GeneralActionEventType>(
context: GeneralActionEventContext<K, TriggerState>,
event: GeneralActionEvent<K, TriggerState>
): GeneralActionEvent<K, TriggerState>;
}
/**
* leaf handler와 합성 router는 상호 배타적입니다. 한 module이 같은 이벤트를
* 두 경로로 중복 처리할 수 없습니다.
*/
export type GeneralActionModule<TriggerState extends GeneralTriggerState = GeneralTriggerState> =
GeneralActionModuleBase<TriggerState> &
(GeneralActionLeafModule<TriggerState> | GeneralActionCompositeModule<TriggerState>);
export const dispatchGeneralActionModuleEvent = <
TriggerState extends GeneralTriggerState,
K extends GeneralActionEventType,
>(
module: GeneralActionModule<TriggerState>,
context: GeneralActionEventContext<K, TriggerState>,
event: GeneralActionEvent<K, TriggerState>
): GeneralActionEvent<K, TriggerState> =>
module.handleEvent
? module.handleEvent(context, event)
: dispatchGeneralActionEventHandlers(module.eventHandlers, context, event);
export class GeneralActionPipeline<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
private readonly modules: ReadonlyArray<GeneralActionModule<TriggerState>>;
constructor(modules: ReadonlyArray<GeneralActionModule<TriggerState> | null | undefined>) {
this.modules = modules.filter(Boolean) as ReadonlyArray<GeneralActionModule<TriggerState>>;
}
getPreTurnExecuteTriggerList(context: GeneralActionContext<TriggerState>): GeneralTriggerCaller<TriggerState> {
const triggerCaller = new GeneralTriggerCaller<TriggerState>();
for (const module of this.modules) {
const triggers = module.getPreTurnExecuteTriggerList?.(context);
if (triggers) {
triggerCaller.merge(triggers);
}
}
return triggerCaller;
}
onCalcDomestic(
context: GeneralActionContext<TriggerState>,
turnType: TriggerDomesticActionType,
varType: TriggerDomesticVarType,
value: number,
aux?: unknown
): number {
let current = value;
for (const module of this.modules) {
if (!module.onCalcDomestic) {
continue;
}
current = module.onCalcDomestic(context, turnType, varType, current, aux);
}
return current;
}
onCalcStat(
context: GeneralActionContext<TriggerState>,
statName: GeneralStatName,
value: number,
aux?: unknown
): number {
let current = value;
for (const module of this.modules) {
if (!module.onCalcStat) {
continue;
}
current = module.onCalcStat(context, statName, current, aux);
}
return current;
}
onCalcOpposeStat(
context: GeneralActionContext<TriggerState>,
statName: GeneralStatName,
value: number,
aux?: unknown
): number {
let current = value;
for (const module of this.modules) {
if (!module.onCalcOpposeStat) {
continue;
}
current = module.onCalcOpposeStat(context, statName, current, aux);
}
return current;
}
onCalcStrategic(
context: GeneralActionContext<TriggerState>,
turnType: TriggerStrategicActionType,
varType: TriggerStrategicVarType,
value: number
): number {
let current = value;
for (const module of this.modules) {
if (!module.onCalcStrategic) {
continue;
}
current = module.onCalcStrategic(context, turnType, varType, current);
}
return current;
}
onCalcNationalIncome(
context: GeneralActionContext<TriggerState>,
type: TriggerNationalIncomeType,
amount: number
): number {
let current = amount;
for (const module of this.modules) {
if (!module.onCalcNationalIncome) {
continue;
}
current = module.onCalcNationalIncome(context, type, current);
}
return current;
}
dispatch<K extends GeneralActionEventType>(
context: GeneralActionEventContext<K, TriggerState>,
event: GeneralActionEvent<K, TriggerState>
): GeneralActionEvent<K, TriggerState> {
let current = event;
for (const module of this.modules) {
current = dispatchGeneralActionModuleEvent(module, context, current);
}
return current;
}
}
@@ -0,0 +1,6 @@
export * from './events.js';
export * from './general.js';
export * from './types.js';
export * from './officerLevel.js';
export * from './bundle.js';
export * from './traits/index.js';
@@ -0,0 +1,74 @@
import { asRecord } from '@sammo-ts/common';
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { GeneralActionModule } from './general.js';
import type { WarActionContext, WarActionModule } from '@sammo-ts/logic/war/actions.js';
const resolveOfficerLevel = (context: {
general: { officerLevel: number; cityId: number; meta: Record<string, unknown> };
}): number => {
const level = context.general.officerLevel;
if (level < 2 || level > 4) {
return level;
}
const meta = asRecord(context.general.meta);
const officerCity = meta.officerCity ?? meta.officer_city;
return officerCity === context.general.cityId ? level : 1;
};
const resolveLeadershipBonus = (officerLevel: number, nationLevel: number): number => {
if (officerLevel === 12) {
return nationLevel * 2;
}
return officerLevel >= 5 ? nationLevel : 0;
};
const officerGeneralModule: GeneralActionModule = {
onCalcDomestic: (context, turnType, varType, value) => {
if (varType !== 'score') {
return value;
}
const level = resolveOfficerLevel(context);
if (
((turnType === '농업' || turnType === '상업') && [12, 11, 9, 7, 5, 3].includes(level)) ||
(turnType === '기술' && [12, 11, 9, 7, 5].includes(level)) ||
((turnType === '민심' || turnType === '인구') && [12, 11, 2].includes(level)) ||
((turnType === '수비' || turnType === '성벽' || turnType === '치안') &&
[12, 11, 10, 8, 6, 4].includes(level))
) {
return value * 1.05;
}
return value;
},
onCalcStat: (context, statName, value) => {
if (statName !== 'leadership') {
return value;
}
return value + resolveLeadershipBonus(resolveOfficerLevel(context), context.nation?.level ?? 0);
},
};
const officerWarModule: WarActionModule = {
onCalcStat: (context: WarActionContext, statName, value) => {
if (statName !== 'leadership' || typeof value !== 'number') {
return value;
}
return value + resolveLeadershipBonus(resolveOfficerLevel(context), context.nation?.level ?? 0);
},
getWarPowerMultiplier: (context: WarActionContext) => {
const level = resolveOfficerLevel(context);
if (level === 12) return [1.07, 0.93];
if (level === 11) return [1.05, 0.95];
if ([10, 8, 6].includes(level)) return [1.1, 1];
if ([9, 7, 5].includes(level)) return [1, 0.9];
if ([4, 3, 2].includes(level)) return [1.05, 0.95];
return [1, 1];
},
};
export const createOfficerLevelActionModules = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(): {
general: GeneralActionModule<TriggerState>;
war: WarActionModule<TriggerState>;
} => ({
general: officerGeneralModule as GeneralActionModule<TriggerState>,
war: officerWarModule as WarActionModule<TriggerState>,
});
@@ -0,0 +1,264 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import { dispatchGeneralActionModuleEvent, type GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
import {
type GeneralActionEvent,
type GeneralActionEventContext,
type GeneralActionEventType,
} from '@sammo-ts/logic/actionModules/events.js';
import type {
GeneralStatName,
TriggerDomesticActionType,
TriggerDomesticVarType,
TriggerNationalIncomeType,
TriggerStrategicActionType,
TriggerStrategicVarType,
WarStatName,
} from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext, WarActionModule } from '@sammo-ts/logic/war/actions.js';
import type { WarUnit } from '@sammo-ts/logic/war/units.js';
import type { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import type { TraitCatalog, TraitKind, TraitModule } from './types.js';
const resolveTraitKey = (
context: {
general: {
role: {
personality: string | null;
specialDomestic: string | null;
specialWar: string | null;
};
};
nation?: { typeCode: string } | null;
},
kind: TraitKind
): string | null => {
if (kind === 'domestic') {
return context.general.role.specialDomestic;
}
if (kind === 'war') {
return context.general.role.specialWar;
}
if (kind === 'nation') {
return context.nation?.typeCode ?? null;
}
return context.general.role.personality;
};
const resolveModule = <TriggerState extends GeneralTriggerState>(
catalog: TraitCatalog<TriggerState>,
kind: TraitKind,
key: string | null
): TraitModule<TriggerState> | null => {
if (!key) {
return null;
}
let bucket: Map<string, TraitModule<TriggerState>>;
if (kind === 'domestic') {
bucket = catalog.domestic;
} else if (kind === 'war') {
bucket = catalog.war;
} else if (kind === 'nation') {
bucket = catalog.nation;
} else {
bucket = catalog.personality;
}
return bucket.get(key) ?? null;
};
// General 파이프라인에서 특성(특기/성격) 모듈을 선택해 위임하는 라우터.
export class TraitGeneralActionRouter<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
constructor(
private readonly kind: TraitKind,
private readonly catalog: TraitCatalog<TriggerState>
) {}
private getModule(context: GeneralActionContext<TriggerState>): TraitModule<TriggerState> | null {
const key = resolveTraitKey(context, this.kind);
return resolveModule(this.catalog, this.kind, key);
}
getPreTurnExecuteTriggerList(context: GeneralActionContext<TriggerState>) {
const module = this.getModule(context);
return module?.getPreTurnExecuteTriggerList?.(context) ?? null;
}
onCalcDomestic(
context: GeneralActionContext<TriggerState>,
turnType: TriggerDomesticActionType,
varType: TriggerDomesticVarType,
value: number,
aux?: unknown
): number {
const module = this.getModule(context);
return module?.onCalcDomestic?.(context, turnType, varType, value, aux) ?? value;
}
onCalcStat(
context: GeneralActionContext<TriggerState>,
statName: GeneralStatName,
value: number,
aux?: unknown
): number {
const module = this.getModule(context);
return module?.onCalcStat?.(context, statName, value, aux) ?? value;
}
onCalcOpposeStat(
context: GeneralActionContext<TriggerState>,
statName: GeneralStatName,
value: number,
aux?: unknown
): number {
const module = this.getModule(context);
return module?.onCalcOpposeStat?.(context, statName, value, aux) ?? value;
}
onCalcStrategic(
context: GeneralActionContext<TriggerState>,
turnType: TriggerStrategicActionType,
varType: TriggerStrategicVarType,
value: number
): number {
const module = this.getModule(context);
return module?.onCalcStrategic?.(context, turnType, varType, value) ?? value;
}
onCalcNationalIncome(
context: GeneralActionContext<TriggerState>,
type: TriggerNationalIncomeType,
amount: number
): number {
const module = this.getModule(context);
return module?.onCalcNationalIncome?.(context, type, amount) ?? amount;
}
handleEvent<K extends GeneralActionEventType>(
context: GeneralActionEventContext<K, TriggerState>,
event: GeneralActionEvent<K, TriggerState>
): GeneralActionEvent<K, TriggerState> {
const module = this.getModule(context);
if (!module) {
return event;
}
return dispatchGeneralActionModuleEvent(module, context, event);
}
}
// 전투 파이프라인에서 특성(특기/성격) 모듈을 선택해 위임하는 라우터.
export class TraitWarActionRouter<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements WarActionModule<TriggerState> {
constructor(
private readonly kind: TraitKind,
private readonly catalog: TraitCatalog<TriggerState>
) {}
private getModule(context: WarActionContext<TriggerState>): TraitModule<TriggerState> | null {
const key = resolveTraitKey(context, this.kind);
return resolveModule(this.catalog, this.kind, key);
}
getBattleInitTriggerList(context: WarActionContext<TriggerState>): WarTriggerCaller | null {
const module = this.getModule(context);
return module?.getBattleInitTriggerList?.(context) ?? null;
}
getBattlePhaseTriggerList(context: WarActionContext<TriggerState>): WarTriggerCaller | null {
const module = this.getModule(context);
return module?.getBattlePhaseTriggerList?.(context) ?? null;
}
onCalcStat(
context: WarActionContext<TriggerState>,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number] {
const module = this.getModule(context);
return module?.onCalcStat?.(context, statName, value, aux) ?? value;
}
onCalcOpposeStat(
context: WarActionContext<TriggerState>,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number] {
const module = this.getModule(context);
return module?.onCalcOpposeStat?.(context, statName, value, aux) ?? value;
}
getWarPowerMultiplier(
context: WarActionContext<TriggerState>,
unit: WarUnit<TriggerState>,
oppose: WarUnit<TriggerState>
): [number, number] {
const module = this.getModule(context);
return module?.getWarPowerMultiplier?.(context, unit, oppose) ?? [1, 1];
}
}
export interface TraitModuleSet<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
general: ReadonlyArray<GeneralActionModule<TriggerState>>;
war: WarActionModule<TriggerState>[];
}
export const createTraitCatalog = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(options: {
domestic?: TraitModule<TriggerState>[];
war?: TraitModule<TriggerState>[];
personality?: TraitModule<TriggerState>[];
nation?: TraitModule<TriggerState>[];
}): TraitCatalog<TriggerState> => {
const domestic = new Map<string, TraitModule<TriggerState>>();
const war = new Map<string, TraitModule<TriggerState>>();
const personality = new Map<string, TraitModule<TriggerState>>();
const nation = new Map<string, TraitModule<TriggerState>>();
const insert = (
bucket: Map<string, TraitModule<TriggerState>>,
kind: TraitKind,
module: TraitModule<TriggerState>
): void => {
if (module.kind !== kind) {
throw new Error(`Trait ${module.key} declared kind ${module.kind}, expected ${kind}`);
}
if (bucket.has(module.key)) {
throw new Error(`Duplicate ${kind} trait key: ${module.key}`);
}
bucket.set(module.key, module);
};
for (const module of options.domestic ?? []) {
insert(domestic, 'domestic', module);
}
for (const module of options.war ?? []) {
insert(war, 'war', module);
}
for (const module of options.personality ?? []) {
insert(personality, 'personality', module);
}
for (const module of options.nation ?? []) {
insert(nation, 'nation', module);
}
return { domestic, war, personality, nation };
};
// 특성 레지스트리를 General/전투 파이프라인용 모듈 목록으로 변환한다.
export const createTraitModules = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
catalog: TraitCatalog<TriggerState>
): TraitModuleSet<TriggerState> => ({
general: [
new TraitGeneralActionRouter<TriggerState>('domestic', catalog),
new TraitGeneralActionRouter<TriggerState>('war', catalog),
new TraitGeneralActionRouter<TriggerState>('personality', catalog),
new TraitGeneralActionRouter<TriggerState>('nation', catalog),
],
war: [
new TraitWarActionRouter<TriggerState>('domestic', catalog),
new TraitWarActionRouter<TriggerState>('war', catalog),
new TraitWarActionRouter<TriggerState>('personality', catalog),
new TraitWarActionRouter<TriggerState>('nation', catalog),
],
});
@@ -0,0 +1,31 @@
import { TraitRequirement, TraitWeightType } from '@sammo-ts/logic/actionModules/traits/requirements.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 내정 특기: 경작
export const traitModule: TraitModule = {
key: 'che_경작',
name: '경작',
info: '[내정] 농지 개간 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
kind: 'domestic',
selection: {
requirements: [TraitRequirement.STAT_INTEL],
weight: 1,
weightType: TraitWeightType.NORM,
},
getName: () => '경작',
getInfo: () => '[내정] 농지 개간 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '농업') {
if (varType === 'score') {
return value * 1.1;
}
if (varType === 'cost') {
return value * 0.8;
}
if (varType === 'success') {
return value + 0.1;
}
}
return value;
},
};
@@ -0,0 +1,25 @@
import { TraitRequirement, TraitWeightType } from '@sammo-ts/logic/actionModules/traits/requirements.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 내정 특기: 귀모
export const traitModule: TraitModule = {
key: 'che_귀모',
name: '귀모',
info: '[계략] 화계·탈취·파괴·선동 : 성공률 +20%p',
kind: 'domestic',
selection: {
requirements: [TraitRequirement.STAT_LEADERSHIP, TraitRequirement.STAT_STRENGTH, TraitRequirement.STAT_INTEL],
weight: 2.5,
weightType: TraitWeightType.PERCENT,
},
getName: () => '귀모',
getInfo: () => '[계략] 화계·탈취·파괴·선동 : 성공률 +20%p',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '계략') {
if (varType === 'success') {
return value + 0.2;
}
}
return value;
},
};
@@ -0,0 +1,31 @@
import { TraitRequirement, TraitWeightType } from '@sammo-ts/logic/actionModules/traits/requirements.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 내정 특기: 발명
export const traitModule: TraitModule = {
key: 'che_발명',
name: '발명',
info: '[내정] 기술 연구 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
kind: 'domestic',
selection: {
requirements: [TraitRequirement.STAT_INTEL],
weight: 1,
weightType: TraitWeightType.NORM,
},
getName: () => '발명',
getInfo: () => '[내정] 기술 연구 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '기술') {
if (varType === 'score') {
return value * 1.1;
}
if (varType === 'cost') {
return value * 0.8;
}
if (varType === 'success') {
return value + 0.1;
}
}
return value;
},
};
@@ -0,0 +1,31 @@
import { TraitRequirement, TraitWeightType } from '@sammo-ts/logic/actionModules/traits/requirements.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 내정 특기: 상재
export const traitModule: TraitModule = {
key: 'che_상재',
name: '상재',
info: '[내정] 상업 투자 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
kind: 'domestic',
selection: {
requirements: [TraitRequirement.STAT_INTEL],
weight: 1,
weightType: TraitWeightType.NORM,
},
getName: () => '상재',
getInfo: () => '[내정] 상업 투자 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '상업') {
if (varType === 'score') {
return value * 1.1;
}
if (varType === 'cost') {
return value * 0.8;
}
if (varType === 'success') {
return value + 0.1;
}
}
return value;
},
};
@@ -0,0 +1,31 @@
import { TraitRequirement, TraitWeightType } from '@sammo-ts/logic/actionModules/traits/requirements.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 내정 특기: 수비
export const traitModule: TraitModule = {
key: 'che_수비',
name: '수비',
info: '[내정] 수비 강화 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
kind: 'domestic',
selection: {
requirements: [TraitRequirement.STAT_STRENGTH],
weight: 1,
weightType: TraitWeightType.NORM,
},
getName: () => '수비',
getInfo: () => '[내정] 수비 강화 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '수비') {
if (varType === 'score') {
return value * 1.1;
}
if (varType === 'cost') {
return value * 0.8;
}
if (varType === 'success') {
return value + 0.1;
}
}
return value;
},
};
@@ -0,0 +1,31 @@
import { TraitRequirement, TraitWeightType } from '@sammo-ts/logic/actionModules/traits/requirements.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 내정 특기: 인덕
export const traitModule: TraitModule = {
key: 'che_인덕',
name: '인덕',
info: '[내정] 주민 선정·정착 장려 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
kind: 'domestic',
selection: {
requirements: [TraitRequirement.STAT_LEADERSHIP],
weight: 1,
weightType: TraitWeightType.NORM,
},
getName: () => '인덕',
getInfo: () => '[내정] 주민 선정·정착 장려 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '민심' || turnType === '인구') {
if (varType === 'score') {
return value * 1.1;
}
if (varType === 'cost') {
return value * 0.8;
}
if (varType === 'success') {
return value + 0.1;
}
}
return value;
},
};
@@ -0,0 +1,31 @@
import { TraitRequirement, TraitWeightType } from '@sammo-ts/logic/actionModules/traits/requirements.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 내정 특기: 축성
export const traitModule: TraitModule = {
key: 'che_축성',
name: '축성',
info: '[내정] 성벽 보수 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
kind: 'domestic',
selection: {
requirements: [TraitRequirement.STAT_STRENGTH],
weight: 1,
weightType: TraitWeightType.NORM,
},
getName: () => '축성',
getInfo: () => '[내정] 성벽 보수 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '성벽') {
if (varType === 'score') {
return value * 1.1;
}
if (varType === 'cost') {
return value * 0.8;
}
if (varType === 'success') {
return value + 0.1;
}
}
return value;
},
};
@@ -0,0 +1,31 @@
import { TraitRequirement, TraitWeightType } from '@sammo-ts/logic/actionModules/traits/requirements.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 내정 특기: 통찰
export const traitModule: TraitModule = {
key: 'che_통찰',
name: '통찰',
info: '[내정] 치안 강화 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
kind: 'domestic',
selection: {
requirements: [TraitRequirement.STAT_STRENGTH],
weight: 1,
weightType: TraitWeightType.NORM,
},
getName: () => '통찰',
getInfo: () => '[내정] 치안 강화 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '치안') {
if (varType === 'score') {
return value * 1.1;
}
if (varType === 'cost') {
return value * 0.8;
}
if (varType === 'success') {
return value + 0.1;
}
}
return value;
},
};
@@ -0,0 +1,80 @@
import type { TraitModule, TraitModuleExport } from '@sammo-ts/logic/actionModules/traits/types.js';
export const DOMESTIC_TRAIT_KEYS = [
'che_인덕',
'che_발명',
'che_경작',
'che_상재',
'che_축성',
'che_수비',
'che_통찰',
'che_귀모',
] as const;
export type DomesticTraitKey = (typeof DOMESTIC_TRAIT_KEYS)[number];
export type DomesticTraitModule = TraitModule;
export type DomesticTraitImporter = () => Promise<TraitModuleExport>;
const defaultImporters: Record<DomesticTraitKey, DomesticTraitImporter> = {
che_인덕: async () => import('./che_인덕.js'),
che_발명: async () => import('./che_발명.js'),
che_경작: async () => import('./che_경작.js'),
che_상재: async () => import('./che_상재.js'),
che_축성: async () => import('./che_축성.js'),
che_수비: async () => import('./che_수비.js'),
che_통찰: async () => import('./che_통찰.js'),
che_귀모: async () => import('./che_귀모.js'),
};
export const isDomesticTraitKey = (value: string): value is DomesticTraitKey =>
DOMESTIC_TRAIT_KEYS.includes(value as DomesticTraitKey);
export class DomesticTraitLoader {
private readonly cache = new Map<DomesticTraitKey, Promise<DomesticTraitModule>>();
constructor(private readonly importers: Record<DomesticTraitKey, DomesticTraitImporter> = defaultImporters) {}
async load(key: DomesticTraitKey): Promise<DomesticTraitModule> {
const cached = this.cache.get(key);
if (cached) {
return cached;
}
const importer = this.importers[key];
if (!importer) {
throw new Error(`Unknown domestic trait key: ${key}`);
}
const loading = importer().then((module) => {
if (!('traitModule' in module)) {
throw new Error(`Missing traitModule for domestic trait: ${key}`);
}
const resolved = module.traitModule;
if (resolved.key !== key) {
throw new Error(`Domestic trait key mismatch: expected ${key}, got ${resolved.key}`);
}
if (resolved.kind !== 'domestic') {
throw new Error(`Domestic trait kind mismatch: ${resolved.key}`);
}
return resolved;
});
this.cache.set(key, loading);
return loading;
}
}
export const loadDomesticTraitModules = async (
keys: DomesticTraitKey[],
loader: DomesticTraitLoader = new DomesticTraitLoader()
): Promise<DomesticTraitModule[]> => {
const modules: DomesticTraitModule[] = [];
const seen = new Set<string>();
for (const key of keys) {
if (seen.has(key)) {
continue;
}
seen.add(key);
modules.push(await loader.load(key));
}
return modules;
};
@@ -0,0 +1,8 @@
export * from './types.js';
export * from './requirements.js';
export * from './selector.js';
export * from './catalog.js';
export * from './domestic/index.js';
export * from './war/index.js';
export * from './personality/index.js';
export * from './nation/index.js';
@@ -0,0 +1,26 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 국가 성향: 덕가
export const traitModule: TraitModule = {
key: 'che_덕가',
name: '덕가',
info: '치안↑ 인구↑ 민심↑ 쌀수입↓ 수성↓',
kind: 'nation',
getName: () => '덕가',
getInfo: () => '치안↑ 인구↑ 민심↑ 쌀수입↓ 수성↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '치안' || turnType === '민심' || turnType === '인구') {
if (varType === 'score') return value * 1.1;
if (varType === 'cost') return value * 0.8;
} else if (turnType === '수비' || turnType === '성벽') {
if (varType === 'score') return value * 0.9;
if (varType === 'cost') return value * 1.2;
}
return value;
},
onCalcNationalIncome: (_context, type, amount) => {
if (type === 'rice') return amount * 0.9;
if (type === 'pop' && amount > 0) return amount * 1.2;
return amount;
},
};
@@ -0,0 +1,22 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 국가 성향: 도가
export const traitModule: TraitModule = {
key: 'che_도가',
name: '도가',
info: '인구↑ 기술↓ 치안↓',
kind: 'nation',
getName: () => '도가',
getInfo: () => '인구↑ 기술↓ 치안↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '기술' || turnType === '치안') {
if (varType === 'score') return value * 0.9;
if (varType === 'cost') return value * 1.2;
}
return value;
},
onCalcNationalIncome: (_context, type, amount) => {
if (type === 'pop' && amount > 0) return amount * 1.2;
return amount;
},
};
@@ -0,0 +1,24 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 국가 성향: 도적
export const traitModule: TraitModule = {
key: 'che_도적',
name: '도적',
info: '계략↑ 금수입↓ 치안↓ 민심↓',
kind: 'nation',
getName: () => '도적',
getInfo: () => '계략↑ 금수입↓ 치안↓ 민심↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '치안' || turnType === '민심' || turnType === '인구') {
if (varType === 'score') return value * 0.9;
if (varType === 'cost') return value * 1.2;
} else if (turnType === '계략') {
if (varType === 'success') return value + 0.1;
}
return value;
},
onCalcNationalIncome: (_context, type, amount) => {
if (type === 'gold') return amount * 0.9;
return amount;
},
};
@@ -0,0 +1,26 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 국가 성향: 명가
export const traitModule: TraitModule = {
key: 'che_명가',
name: '명가',
info: '기술↑ 인구↑ 쌀수입↓ 수성↓',
kind: 'nation',
getName: () => '명가',
getInfo: () => '기술↑ 인구↑ 쌀수입↓ 수성↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '기술') {
if (varType === 'score') return value * 1.1;
if (varType === 'cost') return value * 0.8;
} else if (turnType === '수비' || turnType === '성벽') {
if (varType === 'score') return value * 0.9;
if (varType === 'cost') return value * 1.2;
}
return value;
},
onCalcNationalIncome: (_context, type, amount) => {
if (type === 'rice') return amount * 0.9;
if (type === 'pop' && amount > 0) return amount * 1.2;
return amount;
},
};
@@ -0,0 +1,21 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 국가 성향: 묵가
export const traitModule: TraitModule = {
key: 'che_묵가',
name: '묵가',
info: '수성↑ 기술↓',
kind: 'nation',
getName: () => '묵가',
getInfo: () => '수성↑ 기술↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '수비' || turnType === '성벽') {
if (varType === 'score') return value * 1.1;
if (varType === 'cost') return value * 0.8;
} else if (turnType === '기술') {
if (varType === 'score') return value * 0.9;
if (varType === 'cost') return value * 1.2;
}
return value;
},
};
@@ -0,0 +1,26 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 국가 성향: 법가
export const traitModule: TraitModule = {
key: 'che_법가',
name: '법가',
info: '금수입↑ 치안↑ 인구↓ 민심↓',
kind: 'nation',
getName: () => '법가',
getInfo: () => '금수입↑ 치안↑ 인구↓ 민심↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '치안') {
if (varType === 'score') return value * 1.1;
if (varType === 'cost') return value * 0.8;
} else if (turnType === '민심' || turnType === '인구') {
if (varType === 'score') return value * 0.9;
if (varType === 'cost') return value * 1.2;
}
return value;
},
onCalcNationalIncome: (_context, type, amount) => {
if (type === 'gold') return amount * 1.1;
if (type === 'pop' && amount > 0) return amount * 0.8;
return amount;
},
};
@@ -0,0 +1,25 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 국가 성향: 병가
export const traitModule: TraitModule = {
key: 'che_병가',
name: '병가',
info: '기술↑ 수성↑ 인구↓ 민심↓',
kind: 'nation',
getName: () => '병가',
getInfo: () => '기술↑ 수성↑ 인구↓ 민심↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '기술' || turnType === '수비' || turnType === '성벽') {
if (varType === 'score') return value * 1.1;
if (varType === 'cost') return value * 0.8;
} else if (turnType === '민심' || turnType === '인구') {
if (varType === 'score') return value * 0.9;
if (varType === 'cost') return value * 1.2;
}
return value;
},
onCalcNationalIncome: (_context, type, amount) => {
if (type === 'pop' && amount > 0) return amount * 0.8;
return amount;
},
};
@@ -0,0 +1,22 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 국가 성향: 불가
export const traitModule: TraitModule = {
key: 'che_불가',
name: '불가',
info: '민심↑ 수성↑ 금수입↓',
kind: 'nation',
getName: () => '불가',
getInfo: () => '민심↑ 수성↑ 금수입↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '민심' || turnType === '인구' || turnType === '수비' || turnType === '성벽') {
if (varType === 'score') return value * 1.1;
if (varType === 'cost') return value * 0.8;
}
return value;
},
onCalcNationalIncome: (_context, type, amount) => {
if (type === 'gold') return amount * 0.9;
return amount;
},
};
@@ -0,0 +1,29 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 국가 성향: 오두미도
export const traitModule: TraitModule = {
key: 'che_오두미도',
name: '오두미도',
info: '쌀수입↑ 인구↑ 기술↓ 수성↓ 농상↓',
kind: 'nation',
getName: () => '오두미도',
getInfo: () => '쌀수입↑ 인구↑ 기술↓ 수성↓ 농상↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (
turnType === '기술' ||
turnType === '수비' ||
turnType === '성벽' ||
turnType === '농업' ||
turnType === '상업'
) {
if (varType === 'score') return value * 0.9;
if (varType === 'cost') return value * 1.2;
}
return value;
},
onCalcNationalIncome: (_context, type, amount) => {
if (type === 'rice') return amount * 1.1;
if (type === 'pop' && amount > 0) return amount * 1.2;
return amount;
},
};
@@ -0,0 +1,22 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 국가 성향: 유가
export const traitModule: TraitModule = {
key: 'che_유가',
name: '유가',
info: '농상↑ 민심↑ 쌀수입↓',
kind: 'nation',
getName: () => '유가',
getInfo: () => '농상↑ 민심↑ 쌀수입↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '농업' || turnType === '상업' || turnType === '민심' || turnType === '인구') {
if (varType === 'score') return value * 1.1;
if (varType === 'cost') return value * 0.8;
}
return value;
},
onCalcNationalIncome: (_context, type, amount) => {
if (type === 'rice') return amount * 0.9;
return amount;
},
};
@@ -0,0 +1,31 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 국가 성향: 음양가
export const traitModule: TraitModule = {
key: 'che_음양가',
name: '음양가',
info: '농상↑ 인구↑ 기술↓ 전략↓',
kind: 'nation',
getName: () => '음양가',
getInfo: () => '농상↑ 인구↑ 기술↓ 전략↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '농업' || turnType === '상업') {
if (varType === 'score') return value * 1.1;
if (varType === 'cost') return value * 0.8;
} else if (turnType === '기술') {
if (varType === 'score') return value * 0.9;
if (varType === 'cost') return value * 1.2;
}
return value;
},
onCalcNationalIncome: (_context, type, amount) => {
if (type === 'pop' && amount > 0) return amount * 1.2;
return amount;
},
onCalcStrategic: (_context, _turnType, varType, value) => {
if (varType === 'delay') {
return Math.round((value * 4) / 3);
}
return value;
},
};
@@ -0,0 +1,34 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 국가 성향: 종횡가
export const traitModule: TraitModule = {
key: 'che_종횡가',
name: '종횡가',
info: '전략↑ 수성↑ 금수입↓ 농상↓',
kind: 'nation',
getName: () => '종횡가',
getInfo: () => '전략↑ 수성↑ 금수입↓ 농상↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '수비' || turnType === '성벽') {
if (varType === 'score') return value * 1.1;
if (varType === 'cost') return value * 0.8;
} else if (turnType === '농업' || turnType === '상업') {
if (varType === 'score') return value * 0.9;
if (varType === 'cost') return value * 1.2;
}
return value;
},
onCalcNationalIncome: (_context, type, amount) => {
if (type === 'gold') return amount * 0.9;
return amount;
},
onCalcStrategic: (_context, _turnType, varType, value) => {
if (varType === 'delay') {
return Math.round((value * 3) / 4);
}
if (varType === 'globalDelay') {
return Math.round(value / 2);
}
return value;
},
};
@@ -0,0 +1,11 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 국가 성향: 중립
export const traitModule: TraitModule = {
key: 'che_중립',
name: '-',
info: ' ',
kind: 'nation',
getName: () => '-',
getInfo: () => ' ',
};
@@ -0,0 +1,25 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
// 국가 성향: 태평도
export const traitModule: TraitModule = {
key: 'che_태평도',
name: '태평도',
info: '인구↑ 민심↑ 기술↓ 수성↓',
kind: 'nation',
getName: () => '태평도',
getInfo: () => '인구↑ 민심↑ 기술↓ 수성↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '민심' || turnType === '인구') {
if (varType === 'score') return value * 1.1;
if (varType === 'cost') return value * 0.8;
} else if (turnType === '기술' || turnType === '수비' || turnType === '성벽') {
if (varType === 'score') return value * 0.9;
if (varType === 'cost') return value * 1.2;
}
return value;
},
onCalcNationalIncome: (_context, type, amount) => {
if (type === 'pop' && amount > 0) return amount * 1.2;
return amount;
},
};
@@ -0,0 +1,92 @@
import type { TraitModule, TraitModuleExport } from '@sammo-ts/logic/actionModules/traits/types.js';
export const NATION_TRAIT_KEYS = [
'che_중립',
'che_도적',
'che_명가',
'che_음양가',
'che_종횡가',
'che_불가',
'che_오두미도',
'che_태평도',
'che_도가',
'che_묵가',
'che_덕가',
'che_병가',
'che_유가',
'che_법가',
] as const;
export type NationTraitKey = (typeof NATION_TRAIT_KEYS)[number];
export type NationTraitModule = TraitModule;
export type NationTraitImporter = () => Promise<TraitModuleExport>;
const defaultImporters: Record<NationTraitKey, NationTraitImporter> = {
che_중립: async () => import('./che_중립.js'),
che_도적: async () => import('./che_도적.js'),
che_명가: async () => import('./che_명가.js'),
che_음양가: async () => import('./che_음양가.js'),
che_종횡가: async () => import('./che_종횡가.js'),
che_불가: async () => import('./che_불가.js'),
che_오두미도: async () => import('./che_오두미도.js'),
che_태평도: async () => import('./che_태평도.js'),
che_도가: async () => import('./che_도가.js'),
che_묵가: async () => import('./che_묵가.js'),
che_덕가: async () => import('./che_덕가.js'),
che_병가: async () => import('./che_병가.js'),
che_유가: async () => import('./che_유가.js'),
che_법가: async () => import('./che_법가.js'),
};
export const isNationTraitKey = (value: string): value is NationTraitKey =>
NATION_TRAIT_KEYS.includes(value as NationTraitKey);
export class NationTraitLoader {
private readonly cache = new Map<NationTraitKey, Promise<NationTraitModule>>();
constructor(private readonly importers: Record<NationTraitKey, NationTraitImporter> = defaultImporters) {}
async load(key: NationTraitKey): Promise<NationTraitModule> {
const cached = this.cache.get(key);
if (cached) {
return cached;
}
const importer = this.importers[key];
if (!importer) {
throw new Error(`Unknown nation trait key: ${key}`);
}
const loading = importer().then((module) => {
if (!('traitModule' in module)) {
throw new Error(`Missing traitModule for nation trait: ${key}`);
}
const resolved = module.traitModule;
if (resolved.key !== key) {
throw new Error(`Nation trait key mismatch: expected ${key}, got ${resolved.key}`);
}
if (resolved.kind !== 'nation') {
throw new Error(`Nation trait kind mismatch: ${resolved.key}`);
}
return resolved;
});
this.cache.set(key, loading);
return loading;
}
}
export const loadNationTraitModules = async (
keys: NationTraitKey[],
loader: NationTraitLoader = new NationTraitLoader()
): Promise<NationTraitModule[]> => {
const modules: NationTraitModule[] = [];
const seen = new Set<string>();
for (const key of keys) {
if (seen.has(key)) {
continue;
}
seen.add(key);
modules.push(await loader.load(key));
}
return modules;
};
@@ -0,0 +1,43 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
export const traitModule: TraitModule = {
key: 'che_대의',
name: '대의',
info: '명성 +10%, 훈련 -5',
kind: 'personality',
getName: () => '대의',
getInfo: () => '명성 +10%, 훈련 -5',
onCalcStat: (() => {
function onCalcStat(
_context: GeneralActionContext,
statName: GeneralStatName,
value: number,
_aux?: unknown
): number;
function onCalcStat(
_context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number]
): number | [number, number] {
if (typeof value === 'number') {
if (statName === 'experience') {
return value * 1.1;
}
if (statName === 'bonusTrain') {
return value - 5;
}
}
return value;
}
return onCalcStat;
})() as Exclude<TraitModule['onCalcStat'], undefined>,
};
@@ -0,0 +1,44 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
export const traitModule: TraitModule = {
key: 'che_안전',
name: '안전',
info: '사기 -5, 징·모병 비용 -20%',
kind: 'personality',
getName: () => '안전',
getInfo: () => '사기 -5, 징·모병 비용 -20%',
onCalcDomestic: (_context, turnType, varType, value) => {
if ((turnType === '징병' || turnType === '모병') && varType === 'cost') {
return value * 0.8;
}
return value;
},
onCalcStat: (() => {
function onCalcStat(
_context: GeneralActionContext,
statName: GeneralStatName,
value: number,
_aux?: unknown
): number;
function onCalcStat(
_context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number]
): number | [number, number] {
if (statName === 'bonusAtmos' && typeof value === 'number') {
return value - 5;
}
return value;
}
return onCalcStat;
})() as Exclude<TraitModule['onCalcStat'], undefined>,
};
@@ -0,0 +1,43 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
export const traitModule: TraitModule = {
key: 'che_왕좌',
name: '왕좌',
info: '명성 +10%, 사기 -5',
kind: 'personality',
getName: () => '왕좌',
getInfo: () => '명성 +10%, 사기 -5',
onCalcStat: (() => {
function onCalcStat(
_context: GeneralActionContext,
statName: GeneralStatName,
value: number,
_aux?: unknown
): number;
function onCalcStat(
_context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number]
): number | [number, number] {
if (typeof value === 'number') {
if (statName === 'experience') {
return value * 1.1;
}
if (statName === 'bonusAtmos') {
return value - 5;
}
}
return value;
}
return onCalcStat;
})() as Exclude<TraitModule['onCalcStat'], undefined>,
};
@@ -0,0 +1,44 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
export const traitModule: TraitModule = {
key: 'che_유지',
name: '유지',
info: '훈련 -5, 징·모병 비용 -20%',
kind: 'personality',
getName: () => '유지',
getInfo: () => '훈련 -5, 징·모병 비용 -20%',
onCalcDomestic: (_context, turnType, varType, value) => {
if ((turnType === '징병' || turnType === '모병') && varType === 'cost') {
return value * 0.8;
}
return value;
},
onCalcStat: (() => {
function onCalcStat(
_context: GeneralActionContext,
statName: GeneralStatName,
value: number,
_aux?: unknown
): number;
function onCalcStat(
_context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number]
): number | [number, number] {
if (statName === 'bonusTrain' && typeof value === 'number') {
return value - 5;
}
return value;
}
return onCalcStat;
})() as Exclude<TraitModule['onCalcStat'], undefined>,
};
@@ -0,0 +1,55 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
export const traitModule: TraitModule = {
key: 'che_은둔',
name: '은둔',
info: '명성 -10%, 계급 -10%, 사기 -5, 훈련 -5, 단련 성공률 +10%',
kind: 'personality',
getName: () => '은둔',
getInfo: () => '명성 -10%, 계급 -10%, 사기 -5, 훈련 -5, 단련 성공률 +10%',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '단련' && varType === 'success') {
return value + 0.1;
}
return value;
},
onCalcStat: (() => {
function onCalcStat(
_context: GeneralActionContext,
statName: GeneralStatName,
value: number,
_aux?: unknown
): number;
function onCalcStat(
_context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number]
): number | [number, number] {
if (typeof value === 'number') {
if (statName === 'experience') {
return value * 0.9;
}
if (statName === 'dedication') {
return value * 0.9;
}
if (statName === 'bonusAtmos') {
return value - 5;
}
if (statName === 'bonusTrain') {
return value - 5;
}
}
return value;
}
return onCalcStat;
})() as Exclude<TraitModule['onCalcStat'], undefined>,
};
@@ -0,0 +1,44 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
export const traitModule: TraitModule = {
key: 'che_의협',
name: '의협',
info: '사기 +5, 징·모병 비용 +20%',
kind: 'personality',
getName: () => '의협',
getInfo: () => '사기 +5, 징·모병 비용 +20%',
onCalcDomestic: (_context, turnType, varType, value) => {
if ((turnType === '징병' || turnType === '모병') && varType === 'cost') {
return value * 1.2;
}
return value;
},
onCalcStat: (() => {
function onCalcStat(
_context: GeneralActionContext,
statName: GeneralStatName,
value: number,
_aux?: unknown
): number;
function onCalcStat(
_context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number]
): number | [number, number] {
if (statName === 'bonusAtmos' && typeof value === 'number') {
return value + 5;
}
return value;
}
return onCalcStat;
})() as Exclude<TraitModule['onCalcStat'], undefined>,
};
@@ -0,0 +1,44 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
export const traitModule: TraitModule = {
key: 'che_재간',
name: '재간',
info: '명성 -10%, 징·모병 비용 -20%',
kind: 'personality',
getName: () => '재간',
getInfo: () => '명성 -10%, 징·모병 비용 -20%',
onCalcDomestic: (_context, turnType, varType, value) => {
if ((turnType === '징병' || turnType === '모병') && varType === 'cost') {
return value * 0.8;
}
return value;
},
onCalcStat: (() => {
function onCalcStat(
_context: GeneralActionContext,
statName: GeneralStatName,
value: number,
_aux?: unknown
): number;
function onCalcStat(
_context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number]
): number | [number, number] {
if (statName === 'experience' && typeof value === 'number') {
return value * 0.9;
}
return value;
}
return onCalcStat;
})() as Exclude<TraitModule['onCalcStat'], undefined>,
};
@@ -0,0 +1,43 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
export const traitModule: TraitModule = {
key: 'che_정복',
name: '정복',
info: '명성 -10%, 사기 +5',
kind: 'personality',
getName: () => '정복',
getInfo: () => '명성 -10%, 사기 +5',
onCalcStat: (() => {
function onCalcStat(
_context: GeneralActionContext,
statName: GeneralStatName,
value: number,
_aux?: unknown
): number;
function onCalcStat(
_context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number]
): number | [number, number] {
if (typeof value === 'number') {
if (statName === 'experience') {
return value * 0.9;
}
if (statName === 'bonusAtmos') {
return value + 5;
}
}
return value;
}
return onCalcStat;
})() as Exclude<TraitModule['onCalcStat'], undefined>,
};
@@ -0,0 +1,44 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
export const traitModule: TraitModule = {
key: 'che_출세',
name: '출세',
info: '명성 +10%, 징·모병 비용 +20%',
kind: 'personality',
getName: () => '출세',
getInfo: () => '명성 +10%, 징·모병 비용 +20%',
onCalcDomestic: (_context, turnType, varType, value) => {
if ((turnType === '징병' || turnType === '모병') && varType === 'cost') {
return value * 1.2;
}
return value;
},
onCalcStat: (() => {
function onCalcStat(
_context: GeneralActionContext,
statName: GeneralStatName,
value: number,
_aux?: unknown
): number;
function onCalcStat(
_context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number]
): number | [number, number] {
if (statName === 'experience' && typeof value === 'number') {
return value * 1.1;
}
return value;
}
return onCalcStat;
})() as Exclude<TraitModule['onCalcStat'], undefined>,
};
@@ -0,0 +1,44 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
export const traitModule: TraitModule = {
key: 'che_패권',
name: '패권',
info: '훈련 +5, 징·모병 비용 +20%',
kind: 'personality',
getName: () => '패권',
getInfo: () => '훈련 +5, 징·모병 비용 +20%',
onCalcDomestic: (_context, turnType, varType, value) => {
if ((turnType === '징병' || turnType === '모병') && varType === 'cost') {
return value * 1.2;
}
return value;
},
onCalcStat: (() => {
function onCalcStat(
_context: GeneralActionContext,
statName: GeneralStatName,
value: number,
_aux?: unknown
): number;
function onCalcStat(
_context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number]
): number | [number, number] {
if (statName === 'bonusTrain' && typeof value === 'number') {
return value + 5;
}
return value;
}
return onCalcStat;
})() as Exclude<TraitModule['onCalcStat'], undefined>,
};
@@ -0,0 +1,43 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
export const traitModule: TraitModule = {
key: 'che_할거',
name: '할거',
info: '명성 -10%, 훈련 +5',
kind: 'personality',
getName: () => '할거',
getInfo: () => '명성 -10%, 훈련 +5',
onCalcStat: (() => {
function onCalcStat(
_context: GeneralActionContext,
statName: GeneralStatName,
value: number,
_aux?: unknown
): number;
function onCalcStat(
_context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number]
): number | [number, number] {
if (typeof value === 'number') {
if (statName === 'experience') {
return value * 0.9;
}
if (statName === 'bonusTrain') {
return value + 5;
}
}
return value;
}
return onCalcStat;
})() as Exclude<TraitModule['onCalcStat'], undefined>,
};
@@ -0,0 +1,86 @@
import type { TraitModule, TraitModuleExport } from '@sammo-ts/logic/actionModules/traits/types.js';
export const PERSONALITY_TRAIT_KEYS = [
'che_안전',
'che_유지',
'che_재간',
'che_출세',
'che_할거',
'che_정복',
'che_패권',
'che_의협',
'che_대의',
'che_왕좌',
'che_은둔',
] as const;
export type PersonalityTraitKey = (typeof PERSONALITY_TRAIT_KEYS)[number];
export type PersonalityTraitModule = TraitModule;
export type PersonalityTraitImporter = () => Promise<TraitModuleExport>;
const defaultImporters: Record<PersonalityTraitKey, PersonalityTraitImporter> = {
che_안전: async () => import('./che_안전.js'),
che_유지: async () => import('./che_유지.js'),
che_재간: async () => import('./che_재간.js'),
che_출세: async () => import('./che_출세.js'),
che_할거: async () => import('./che_할거.js'),
che_정복: async () => import('./che_정복.js'),
che_패권: async () => import('./che_패권.js'),
che_의협: async () => import('./che_의협.js'),
che_대의: async () => import('./che_대의.js'),
che_왕좌: async () => import('./che_왕좌.js'),
che_은둔: async () => import('./che_은둔.js'),
};
export const isPersonalityTraitKey = (value: string): value is PersonalityTraitKey =>
PERSONALITY_TRAIT_KEYS.includes(value as PersonalityTraitKey);
export class PersonalityTraitLoader {
private readonly cache = new Map<PersonalityTraitKey, Promise<PersonalityTraitModule>>();
constructor(private readonly importers: Record<PersonalityTraitKey, PersonalityTraitImporter> = defaultImporters) {}
async load(key: PersonalityTraitKey): Promise<PersonalityTraitModule> {
const cached = this.cache.get(key);
if (cached) {
return cached;
}
const importer = this.importers[key];
if (!importer) {
throw new Error(`Unknown personality trait key: ${key}`);
}
const loading = importer().then((module) => {
if (!('traitModule' in module)) {
throw new Error(`Missing traitModule for personality trait: ${key}`);
}
const resolved = module.traitModule;
if (resolved.key !== key) {
throw new Error(`Personality trait key mismatch: expected ${key}, got ${resolved.key}`);
}
if (resolved.kind !== 'personality') {
throw new Error(`Personality trait kind mismatch: ${resolved.key}`);
}
return resolved;
});
this.cache.set(key, loading);
return loading;
}
}
export const loadPersonalityTraitModules = async (
keys: PersonalityTraitKey[],
loader: PersonalityTraitLoader = new PersonalityTraitLoader()
): Promise<PersonalityTraitModule[]> => {
const modules: PersonalityTraitModule[] = [];
const seen = new Set<string>();
for (const key of keys) {
if (seen.has(key)) {
continue;
}
seen.add(key);
modules.push(await loader.load(key));
}
return modules;
};
@@ -0,0 +1,30 @@
export enum TraitRequirement {
DISABLED = 0x1,
STAT_LEADERSHIP = 0x2,
STAT_STRENGTH = 0x4,
STAT_INTEL = 0x8,
ARMY_FOOTMAN = 0x100,
ARMY_ARCHER = 0x200,
ARMY_CAVALRY = 0x400,
ARMY_WIZARD = 0x800,
ARMY_SIEGE = 0x1000,
REQ_DEXTERITY = 0x4000,
STAT_NOT_LEADERSHIP = 0x20000,
STAT_NOT_STRENGTH = 0x40000,
STAT_NOT_INTEL = 0x80000,
}
export enum TraitWeightType {
NORM = 1,
PERCENT = 2,
}
export interface TraitSelection {
weightType: TraitWeightType;
weight: number;
requirements: TraitRequirement[];
}
@@ -0,0 +1,189 @@
import type { RandUtil } from '@sammo-ts/common';
import { TraitRequirement, TraitWeightType } from './requirements.js';
import type { TraitModule } from './types.js';
import type { ScenarioStatBlock } from '../../scenario/types.js';
export class TraitSelector {
/**
* 기초 스탯 기반 선택 조건 계산 (calcCondGeneric)
*/
static calcCondGeneric(
stats: { leadership: number; strength: number; intelligence: number },
scenarioStat: ScenarioStatBlock
): number {
const { leadership, strength, intelligence } = stats;
const chiefMin = scenarioStat.chiefMin;
let myCond = 0;
if (leadership > chiefMin) {
myCond |= TraitRequirement.STAT_LEADERSHIP;
}
if (strength >= intelligence * 0.95 && strength > chiefMin) {
myCond |= TraitRequirement.STAT_STRENGTH;
}
if (intelligence >= strength * 0.95 && intelligence > chiefMin) {
myCond |= TraitRequirement.STAT_INTEL;
}
if (myCond !== 0) {
if (leadership < chiefMin) myCond |= TraitRequirement.STAT_NOT_LEADERSHIP;
if (strength < chiefMin) myCond |= TraitRequirement.STAT_NOT_STRENGTH;
if (intelligence < chiefMin) myCond |= TraitRequirement.STAT_NOT_INTEL;
}
if (myCond === 0) {
if (leadership * 0.9 > strength && leadership * 0.9 > intelligence) {
myCond |= TraitRequirement.STAT_LEADERSHIP;
} else if (strength >= intelligence) {
myCond |= TraitRequirement.STAT_STRENGTH;
} else {
myCond |= TraitRequirement.STAT_INTEL;
}
}
return myCond;
}
/**
* 숙련도 기반 선택 조건 계산 (calcCondDexterity)
*/
static calcCondDexterity(rng: RandUtil, dex: number[]): number {
const dexMap: Record<number, number> = {
[TraitRequirement.ARMY_FOOTMAN]: dex[0] || 0,
[TraitRequirement.ARMY_ARCHER]: dex[1] || 0,
[TraitRequirement.ARMY_CAVALRY]: dex[2] || 0,
[TraitRequirement.ARMY_WIZARD]: dex[3] || 0,
[TraitRequirement.ARMY_SIEGE]: dex[4] || 0,
};
const dexSum = Object.values(dexMap).reduce((a, b) => a + b, 0);
// 루트(합)/4 확률 기반 로직 (Legacy: sqrt(dexSum)/4)
const dexBase = Math.round(Math.sqrt(dexSum) / 4);
// Legacy: 80% 확률로 0 반환 (이전 연도에 이미 얻었거나 기타 이유로 제한하는 인지)
// 실제로는 pickSpecialWar에서 이 메서드 호출 전후에 별도 확률을 둘 수도 있으나,
// Legacy SpecialityHelper.php의 로직을 그대로 따름.
if (rng.nextBool(0.8)) {
return 0;
}
if (rng.nextRangeInt(0, 99) < dexBase) {
return 0;
}
if (dexSum === 0) {
return rng.choice(Object.values(dexMap));
}
const maxDex = Math.max(...Object.values(dexMap));
const candidates = Object.keys(dexMap)
.map(Number)
.filter((k) => dexMap[k] === maxDex);
return Number(rng.choice(candidates));
}
/**
* 사용 가능한 특기 목록에서 하나를 무작위로 선택 (pickTrait)
*/
private static pickTraitOnce(
rng: RandUtil,
myCond: number,
traits: TraitModule[],
prevTraitKeys: string[],
preferDexterity: boolean
): string | null {
const dexterityPool: Array<[string, number]> = [];
const normPool: Array<[string, number]> = [];
const percentPool: Array<[string | null, number]> = [];
for (const trait of traits) {
if (!trait.selection) continue;
if (prevTraitKeys.includes(trait.key)) continue;
let matchedRequirement: number | null = null;
for (const req of trait.selection.requirements) {
if (req === (req & myCond)) {
matchedRequirement = req;
break;
}
}
if (matchedRequirement === null) continue;
if (preferDexterity && (matchedRequirement & TraitRequirement.REQ_DEXTERITY) !== 0) {
dexterityPool.push([trait.key, trait.selection.weight]);
} else if (trait.selection.weightType === TraitWeightType.PERCENT) {
percentPool.push([trait.key, trait.selection.weight]);
} else {
normPool.push([trait.key, trait.selection.weight]);
}
}
if (dexterityPool.length > 0) {
return rng.choiceUsingWeightPair(dexterityPool);
}
if (percentPool.length > 0) {
if (normPool.length > 0) {
const totalPercent = percentPool.reduce((sum, [, weight]) => sum + weight, 0);
percentPool.push([null, Math.max(0, 100 - totalPercent)]);
}
const selected = rng.choiceUsingWeightPair(percentPool);
if (selected !== null) {
return selected;
}
}
if (normPool.length > 0) {
return rng.choiceUsingWeightPair(normPool);
}
return null;
}
/**
* 전투 특기 선택 통합 로직
*/
static pickWarTrait(
rng: RandUtil,
stats: { leadership: number; strength: number; intelligence: number },
dex: number[],
traits: TraitModule[],
prevTraitKeys: string[],
scenarioStat: ScenarioStatBlock
): string | null {
const myCond =
this.calcCondGeneric(stats, scenarioStat) |
this.calcCondDexterity(rng, dex) |
TraitRequirement.REQ_DEXTERITY;
const selected = this.pickTraitOnce(rng, myCond, traits, prevTraitKeys, true);
if (selected !== null) {
return selected;
}
if (prevTraitKeys.length > 0) {
return this.pickWarTrait(rng, stats, dex, traits, [], scenarioStat);
}
return null;
}
/**
* 내정 특기 선택 통합 로직
*/
static pickDomesticTrait(
rng: RandUtil,
stats: { leadership: number; strength: number; intelligence: number },
traits: TraitModule[],
prevTraitKeys: string[],
scenarioStat: ScenarioStatBlock
): string | null {
const myCond = this.calcCondGeneric(stats, scenarioStat);
const selected = this.pickTraitOnce(rng, myCond, traits, prevTraitKeys, false);
if (selected !== null) {
return selected;
}
if (prevTraitKeys.length > 0) {
return this.pickDomesticTrait(rng, stats, traits, [], scenarioStat);
}
return null;
}
}
@@ -0,0 +1,29 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
import type { TraitSelection } from './requirements.js';
export type TraitKind = 'domestic' | 'war' | 'personality' | 'nation';
export interface TraitSpec {
key: string;
name: string;
info: string;
kind: TraitKind;
selection?: TraitSelection;
}
export type TraitModule<TriggerState extends GeneralTriggerState = GeneralTriggerState> = TraitSpec &
GeneralActionModule<TriggerState> &
WarActionModule<TriggerState>;
export interface TraitModuleExport<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
traitModule: TraitModule<TriggerState>;
}
export interface TraitCatalog<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
domestic: Map<string, TraitModule<TriggerState>>;
war: Map<string, TraitModule<TriggerState>>;
personality: Map<string, TraitModule<TriggerState>>;
nation: Map<string, TraitModule<TriggerState>>;
}
@@ -0,0 +1,35 @@
import { isRecord } from '@sammo-ts/common';
export interface WarDexAux {
isAttacker?: boolean;
opposeType?: { armType: number };
}
export const parseWarDexAux = (aux: unknown): WarDexAux => {
if (!isRecord(aux)) {
return {};
}
const isAttacker = typeof aux.isAttacker === 'boolean' ? aux.isAttacker : undefined;
const opposeRaw = aux.opposeType;
if (!isRecord(opposeRaw)) {
return isAttacker === undefined ? {} : { isAttacker };
}
const armType = opposeRaw.armType;
if (typeof armType !== 'number') {
return isAttacker === undefined ? {} : { isAttacker };
}
const opposeType = { armType };
return isAttacker === undefined ? { opposeType } : { isAttacker, opposeType };
};
export const getAuxArmType = (aux: unknown): number | undefined => {
if (!isRecord(aux)) {
return undefined;
}
const armType = aux.armType;
return typeof armType === 'number' ? armType : undefined;
};
@@ -0,0 +1,47 @@
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { TraitRequirement, TraitWeightType } from '../requirements.js';
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { che_격노발동, che_격노시도 } from '@sammo-ts/logic/war/triggers/che_격노.js';
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
_statName: GeneralStatName | WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number] {
return value;
}
export const traitModule: TraitModule = {
key: 'che_격노',
name: '격노',
info: '[전투] 상대방 필살 시 격노(필살) 발동, 회피 시도시 25% 확률로 격노 발동, 공격 시 일정 확률로 진노(1페이즈 추가), 격노마다 대미지 20% 추가 중첩',
kind: 'war',
getName: () => '격노',
getInfo: () =>
'[전투] 상대방 필살 시 격노(필살) 발동, 회피 시도시 25% 확률로 격노 발동, 공격 시 일정 확률로 진노(1페이즈 추가), 격노마다 대미지 20% 추가 중첩',
selection: {
requirements: [TraitRequirement.STAT_STRENGTH],
weight: 1,
weightType: TraitWeightType.NORM,
},
getWarPowerMultiplier: (_context, unit, _oppose) => {
const activatedCnt = unit.hasActivatedSkillOnLog('격노');
return [1 + 0.2 * activatedCnt, 1];
},
getBattlePhaseTriggerList: (_context) => {
if (!_context.unit) return null;
return new WarTriggerCaller(new che_격노시도(_context.unit), new che_격노발동(_context.unit));
},
onCalcStat,
};
@@ -0,0 +1,58 @@
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { TraitRequirement, TraitWeightType } from '../requirements.js';
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { che_부상무효 } from '@sammo-ts/logic/war/triggers/che_견고.js';
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
_statName: GeneralStatName | WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number] {
return value;
}
export const traitModule: TraitModule = {
key: 'che_견고',
name: '견고',
info: '[전투] 상대 필살 확률 -20%p, 상대 계략 시도시 성공 확률 -10%p, 부상 없음, 아군 피해 -10%',
kind: 'war',
getName: () => '견고',
getInfo: () => '[전투] 상대 필살 확률 -20%p, 상대 계략 시도시 성공 확률 -10%p, 부상 없음, 아군 피해 -10%',
selection: {
requirements: [TraitRequirement.STAT_STRENGTH],
weight: 1,
weightType: TraitWeightType.NORM,
},
onCalcOpposeStat: ((_context, statName, value, _aux) => {
if (statName === 'warMagicSuccessProb' && typeof value === 'number') {
return value - 0.1;
}
if (statName === 'warCriticalRatio' && typeof value === 'number') {
return value - 0.2;
}
return value;
}) as TraitModule['onCalcOpposeStat'],
getBattleInitTriggerList: (_context) => {
if (!_context.unit) return null;
return new WarTriggerCaller(new che_부상무효(_context.unit));
},
getBattlePhaseTriggerList: (_context) => {
if (!_context.unit) return null;
return new WarTriggerCaller(new che_부상무효(_context.unit));
},
getWarPowerMultiplier: (_context, _unit, _oppose) => {
return [1, 0.9];
},
onCalcStat,
};
@@ -0,0 +1,77 @@
import { TraitRequirement, TraitWeightType } from '../requirements.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { getMetaNumber } from '@sammo-ts/logic/war/utils.js';
import { WarUnitCity } from '@sammo-ts/logic/war/units.js';
import { getAuxArmType, parseWarDexAux } from './aux.js';
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number] {
if (!('unit' in context) || !context.unit) {
return value;
}
const unit = context.unit;
const siegeType = unit.getGameConfig().armTypes.siege;
if (siegeType === undefined) {
return value;
}
if (statName.startsWith('dex')) {
const myDex = getMetaNumber(context.general.meta, `dex${siegeType}`);
const { isAttacker, opposeType } = parseWarDexAux(aux);
if (isAttacker && opposeType && statName === `dex${opposeType.armType}`) {
return (value as number) + myDex;
}
if (!isAttacker && statName === `dex${siegeType}`) {
return (value as number) + myDex;
}
}
return value;
}
// 전투 특기: 공성
export const traitModule: TraitModule = {
key: 'che_공성',
name: '공성',
info: '[군사] 차병 계통 징·모병비 -10%<br>[전투] 성벽 공격 시 대미지 +100%,<br>공격시 상대 병종에/수비시 자신 병종 숙련에 차병 숙련을 가산',
kind: 'war',
selection: {
weight: 1,
weightType: TraitWeightType.NORM,
requirements: [TraitRequirement.STAT_LEADERSHIP | TraitRequirement.REQ_DEXTERITY | TraitRequirement.ARMY_SIEGE],
},
getName: () => '공성',
getInfo: () =>
'[군사] 차병 계통 징·모병비 -10%<br>[전투] 성벽 공격 시 대미지 +100%,<br>공격시 상대 병종에/수비시 자신 병종 숙련에 차병 숙련을 가산',
onCalcDomestic: (_context, turnType, varType, value, aux) => {
if (turnType === '징병' || turnType === '모병') {
const armType = getAuxArmType(aux);
if (varType === 'cost' && armType === 5) {
return value * 0.9;
}
}
return value;
},
getWarPowerMultiplier: (_context, _unit, oppose) => {
if (oppose instanceof WarUnitCity) {
return [2, 1];
}
return [1, 1];
},
onCalcStat,
};
@@ -0,0 +1,82 @@
import { TraitRequirement, TraitWeightType } from '../requirements.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { getMetaNumber } from '@sammo-ts/logic/war/utils.js';
import { getAuxArmType, parseWarDexAux } from './aux.js';
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number] {
if (statName === 'warAvoidRatio') {
return (value as number) + 0.2;
}
if (!('unit' in context) || !context.unit) {
return value;
}
const unit = context.unit;
const archerType = unit.getGameConfig().armTypes.archer;
if (archerType === undefined) {
return value;
}
if (statName.startsWith('dex')) {
const myDex = getMetaNumber(context.general.meta, `dex${archerType}`);
const { isAttacker, opposeType } = parseWarDexAux(aux);
if (isAttacker && opposeType && statName === `dex${opposeType.armType}`) {
return (value as number) + myDex;
}
if (!isAttacker && statName === `dex${archerType}`) {
return (value as number) + myDex;
}
}
return value;
}
// 전투 특기: 궁병
export const traitModule: TraitModule = {
key: 'che_궁병',
name: '궁병',
info: '[군사] 궁병 계통 징·모병비 -10%<br>[전투] 회피 확률 +20%p,<br>공격시 상대 병종에/수비시 자신 병종 숙련에 궁병 숙련을 가산',
kind: 'war',
selection: {
weight: 1,
weightType: TraitWeightType.NORM,
requirements: [
TraitRequirement.STAT_LEADERSHIP |
TraitRequirement.REQ_DEXTERITY |
TraitRequirement.ARMY_ARCHER |
TraitRequirement.STAT_NOT_INTEL,
TraitRequirement.STAT_STRENGTH | TraitRequirement.REQ_DEXTERITY | TraitRequirement.ARMY_ARCHER,
],
},
getName: () => '궁병',
getInfo: () =>
'[군사] 궁병 계통 징·모병비 -10%<br>[전투] 회피 확률 +20%p,<br>공격시 상대 병종에/수비시 자신 병종 숙련에 궁병 숙련을 가산',
onCalcDomestic: (_context, turnType, varType, value, aux) => {
if (turnType === '징병' || turnType === '모병') {
const armType = getAuxArmType(aux);
// Note: In a real scenario, we should check if aux.armType is archer.
// Since we don't have easy access to config here, we might need to assume legacy ID 2 or similar.
if (varType === 'cost' && armType === 2) {
return value * 0.9;
}
}
return value;
},
onCalcStat,
};
@@ -0,0 +1,79 @@
import { TraitRequirement, TraitWeightType } from '../requirements.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { getMetaNumber } from '@sammo-ts/logic/war/utils.js';
import { getAuxArmType, parseWarDexAux } from './aux.js';
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number] {
if (statName === 'warMagicSuccessProb') {
return (value as number) + 0.2;
}
if (!('unit' in context) || !context.unit) {
return value;
}
const unit = context.unit;
const wizardType = unit.getGameConfig().armTypes.wizard;
if (wizardType === undefined) {
return value;
}
if (statName.startsWith('dex')) {
const myDex = getMetaNumber(context.general.meta, `dex${wizardType}`);
const { isAttacker, opposeType } = parseWarDexAux(aux);
if (isAttacker && opposeType && statName === `dex${opposeType.armType}`) {
return (value as number) + myDex;
}
if (!isAttacker && statName === `dex${wizardType}`) {
return (value as number) + myDex;
}
}
return value;
}
// 전투 특기: 귀병
export const traitModule: TraitModule = {
key: 'che_귀병',
name: '귀병',
info: '[군사] 귀병 계통 징·모병비 -10%<br>[전투] 계략 성공 확률 +20%p,<br>공격시 상대 병종에/수비시 자신 병종 숙련에 귀병 숙련을 가산',
kind: 'war',
selection: {
weight: 1,
weightType: TraitWeightType.NORM,
requirements: [
TraitRequirement.STAT_INTEL |
TraitRequirement.ARMY_WIZARD |
TraitRequirement.REQ_DEXTERITY |
TraitRequirement.STAT_NOT_STRENGTH,
],
},
getName: () => '귀병',
getInfo: () =>
'[군사] 귀병 계통 징·모병비 -10%<br>[전투] 계략 성공 확률 +20%p,<br>공격시 상대 병종에/수비시 자신 병종 숙련에 귀병 숙련을 가산',
onCalcDomestic: (_context, turnType, varType, value, aux) => {
if (turnType === '징병' || turnType === '모병') {
const armType = getAuxArmType(aux);
if (varType === 'cost' && armType === 4) {
return value * 0.9;
}
}
return value;
},
onCalcStat,
};
@@ -0,0 +1,82 @@
import { TraitRequirement, TraitWeightType } from '../requirements.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { getMetaNumber } from '@sammo-ts/logic/war/utils.js';
import { getAuxArmType, parseWarDexAux } from './aux.js';
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number] {
if (!('unit' in context) || !context.unit) {
return value;
}
const unit = context.unit;
const cavalryType = unit.getGameConfig().armTypes.cavalry;
if (cavalryType === undefined) {
return value;
}
if (statName.startsWith('dex')) {
const myDex = getMetaNumber(context.general.meta, `dex${cavalryType}`);
const { isAttacker, opposeType } = parseWarDexAux(aux);
if (isAttacker && opposeType && statName === `dex${opposeType.armType}`) {
return (value as number) + myDex;
}
if (!isAttacker && statName === `dex${cavalryType}`) {
return (value as number) + myDex;
}
}
return value;
}
// 전투 특기: 기병
export const traitModule: TraitModule = {
key: 'che_기병',
name: '기병',
info: '[군사] 기병 계통 징·모병비 -10%<br>[전투] 수비 시 대미지 +10%, 공격 시 대미지 +20%,<br>공격시 상대 병종에/수비시 자신 병종 숙련에 기병 숙련을 가산',
kind: 'war',
selection: {
weight: 1,
weightType: TraitWeightType.NORM,
requirements: [
TraitRequirement.STAT_LEADERSHIP |
TraitRequirement.REQ_DEXTERITY |
TraitRequirement.ARMY_CAVALRY |
TraitRequirement.STAT_NOT_INTEL,
TraitRequirement.STAT_STRENGTH | TraitRequirement.REQ_DEXTERITY | TraitRequirement.ARMY_CAVALRY,
],
},
getName: () => '기병',
getInfo: () =>
'[군사] 기병 계통 징·모병비 -10%<br>[전투] 수비 시 대미지 +10%, 공격 시 대미지 +20%,<br>공격시 상대 병종에/수비시 자신 병종 숙련에 기병 숙련을 가산',
onCalcDomestic: (_context, turnType, varType, value, aux) => {
if (turnType === '징병' || turnType === '모병') {
const armType = getAuxArmType(aux);
if (varType === 'cost' && armType === 3) {
return value * 0.9;
}
}
return value;
},
getWarPowerMultiplier: (_context, unit, _oppose) => {
if (unit.isAttacker()) {
return [1.2, 1];
}
return [1.1, 1];
},
onCalcStat,
};
@@ -0,0 +1,51 @@
import { TraitRequirement, TraitWeightType } from '../requirements.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { che_돌격지속 } from '@sammo-ts/logic/war/triggers/che_돌격.js';
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number] {
if (statName === 'initWarPhase') {
return (value as number) + 2;
}
return value;
}
export const traitModule: TraitModule = {
key: 'che_돌격',
name: '돌격',
info: '[전투] 공격 시 대등/유리한 병종에게는 퇴각 전까지 전투, 공격 시 페이즈 + 2, 공격 시 대미지 +5%',
kind: 'war',
selection: {
weight: 1,
weightType: TraitWeightType.NORM,
requirements: [TraitRequirement.STAT_STRENGTH],
},
getName: () => '돌격',
getInfo: () => '[전투] 공격 시 대등/유리한 병종에게는 퇴각 전까지 전투, 공격 시 페이즈 + 2, 공격 시 대미지 +5%',
getWarPowerMultiplier: (_context, unit, _oppose) => {
if (unit.isAttacker()) {
return [1.05, 1];
}
return [1, 1];
},
getBattlePhaseTriggerList: (_context) => {
if (!_context.unit) return null;
return new WarTriggerCaller(new che_돌격지속(_context.unit));
},
onCalcStat,
};
@@ -0,0 +1,64 @@
import type { TriggerValue } from '@sammo-ts/logic/domain/entities.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { TraitRequirement, TraitWeightType } from '../requirements.js';
import { getMetaNumber } from '@sammo-ts/logic/war/utils.js';
import type { WarUnit } from '@sammo-ts/logic/war/units.js';
type WarUnitWithGeneral = WarUnit & { getGeneral: () => { meta: Record<string, unknown> } };
const hasGeneral = (unit: WarUnit): unit is WarUnitWithGeneral =>
'getGeneral' in unit && typeof (unit as { getGeneral?: unknown }).getGeneral === 'function';
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number] {
const isAttacker = typeof aux === 'object' && aux !== null && (aux as { isAttacker?: unknown }).isAttacker === true;
if (statName === 'warCriticalRatio' && isAttacker && typeof value === 'number') {
return value + 0.1;
}
return value;
}
export const traitModule: TraitModule = {
key: 'che_무쌍',
name: '무쌍',
info: '[전투] 대미지 +5%, 피해 -2%, 공격 시 필살 확률 +10%p, <br>승리 수의 로그 비례로 대미지 상승(10회 ⇒ +5%, 40회 ⇒ +15%)<br>승리 수의 로그 비례로 피해 감소(10회 ⇒ -2%, 40회 ⇒ -6%)',
kind: 'war',
getName: () => '무쌍',
getInfo: () =>
'[전투] 대미지 +5%, 피해 -2%, 공격 시 필살 확률 +10%p, <br>승리 수의 로그 비례로 대미지 상승(10회 ⇒ +5%, 40회 ⇒ +15%)<br>승리 수의 로그 비례로 피해 감소(10회 ⇒ -2%, 40회 ⇒ -6%)',
selection: {
requirements: [TraitRequirement.STAT_STRENGTH],
weight: 1,
weightType: TraitWeightType.NORM,
},
getWarPowerMultiplier: (_context, unit, _oppose) => {
let attackMultiplier = 1.05;
let defenceMultiplier = 0.98;
// Note: unit.getGeneral() is only available for WarUnitGeneral.
// In a real scenario, we should check if unit is WarUnitGeneral.
if (hasGeneral(unit)) {
const killnum = getMetaNumber(unit.getGeneral().meta as Record<string, TriggerValue>, 'rank_killnum', 0);
const logVal = Math.log2(Math.max(1, killnum / 5));
attackMultiplier += logVal / 20;
defenceMultiplier -= logVal / 50;
}
return [attackMultiplier, defenceMultiplier];
},
onCalcStat,
};
@@ -0,0 +1,52 @@
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { TraitRequirement, TraitWeightType } from '../requirements.js';
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { che_반계발동, che_반계시도 } from '@sammo-ts/logic/war/triggers/che_반계.js';
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number] {
if (statName === 'warMagicSuccessDamage' && aux === '반목') {
return (value as number) + 0.9;
}
return value;
}
export const traitModule: TraitModule = {
key: 'che_반계',
name: '반계',
info: '[전투] 상대의 계략 성공 확률 -10%p, 상대의 계략을 40% 확률로 되돌림, 반목 성공시 대미지 추가(+60% → +150%)',
kind: 'war',
getName: () => '반계',
getInfo: () =>
'[전투] 상대의 계략 성공 확률 -10%p, 상대의 계략을 40% 확률로 되돌림, 반목 성공시 대미지 추가(+60% → +150%)',
selection: {
requirements: [TraitRequirement.STAT_INTEL],
weight: 1,
weightType: TraitWeightType.NORM,
},
onCalcOpposeStat: ((_context, statName, value, _aux) => {
if (statName === 'warMagicSuccessProb' && typeof value === 'number') {
return value - 0.1;
}
return value;
}) as TraitModule['onCalcOpposeStat'],
getBattlePhaseTriggerList: (_context) => {
if (!_context.unit) return null;
return new WarTriggerCaller(new che_반계시도(_context.unit), new che_반계발동(_context.unit));
},
onCalcStat,
};
@@ -0,0 +1,84 @@
import { TraitRequirement, TraitWeightType } from '../requirements.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { getMetaNumber } from '@sammo-ts/logic/war/utils.js';
import { getAuxArmType, parseWarDexAux } from './aux.js';
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number] {
if (!('unit' in context) || !context.unit) {
return value;
}
const unit = context.unit;
const footmanType = unit.getGameConfig().armTypes.footman;
if (footmanType === undefined) {
return value;
}
if (statName.startsWith('dex')) {
const myDex = getMetaNumber(context.general.meta, `dex${footmanType}`);
const { isAttacker, opposeType } = parseWarDexAux(aux);
if (isAttacker && opposeType && statName === `dex${opposeType.armType}`) {
return (value as number) + myDex;
}
if (!isAttacker && statName === `dex${footmanType}`) {
return (value as number) + myDex;
}
}
return value;
}
// 전투 특기: 보병
export const traitModule: TraitModule = {
key: 'che_보병',
name: '보병',
info: '[군사] 보병 계통 징·모병비 -10%<br>[전투] 공격 시 아군 피해 -10%, 수비 시 아군 피해 -20%,<br>공격시 상대 병종에/수비시 자신 병종 숙련에 보병 숙련을 가산',
kind: 'war',
selection: {
weight: 1,
weightType: TraitWeightType.NORM,
requirements: [
TraitRequirement.STAT_LEADERSHIP |
TraitRequirement.REQ_DEXTERITY |
TraitRequirement.ARMY_FOOTMAN |
TraitRequirement.STAT_NOT_INTEL,
TraitRequirement.STAT_STRENGTH | TraitRequirement.REQ_DEXTERITY | TraitRequirement.ARMY_FOOTMAN,
],
},
getName: () => '보병',
getInfo: () =>
'[군사] 보병 계통 징·모병비 -10%<br>[전투] 공격 시 아군 피해 -10%, 수비 시 아군 피해 -20%,<br>공격시 상대 병종에/수비시 자신 병종 숙련에 보병 숙련을 가산',
onCalcDomestic: (_context, turnType, varType, value, aux) => {
if (turnType === '징병' || turnType === '모병') {
const armType = getAuxArmType(aux);
// Note: In a real scenario, we should check if aux.armType is footman.
// Since we don't have easy access to config here, we might need to assume legacy ID 1 or similar.
if (varType === 'cost' && armType === 1) {
return value * 0.9;
}
}
return value;
},
getWarPowerMultiplier: (_context, unit, _oppose) => {
if (unit.isAttacker()) {
return [1, 0.9];
}
return [1, 0.8];
},
onCalcStat,
};
@@ -0,0 +1,48 @@
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { TraitRequirement, TraitWeightType } from '../requirements.js';
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number] {
if (statName === 'warMagicTrialProb') {
return (value as number) + 0.2;
}
if (statName === 'warMagicSuccessProb') {
return (value as number) + 0.2;
}
return value;
}
export const traitModule: TraitModule = {
key: 'che_신산',
name: '신산',
info: '[계략] 화계·탈취·파괴·선동 : 성공률 +10%p<br>[전투] 계략 시도 확률 +20%p, 계략 성공 확률 +20%p',
kind: 'war',
getName: () => '신산',
getInfo: () => '[계략] 화계·탈취·파괴·선동 : 성공률 +10%p<br>[전투] 계략 시도 확률 +20%p, 계략 성공 확률 +20%p',
selection: {
requirements: [TraitRequirement.STAT_INTEL],
weight: 1,
weightType: TraitWeightType.NORM,
},
onCalcDomestic: (_context, turnType, varType, value, _aux) => {
if (turnType === '계략') {
if (varType === 'success') return value + 0.1;
}
return value;
},
onCalcStat,
};
@@ -0,0 +1,39 @@
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { TraitRequirement, TraitWeightType } from '../requirements.js';
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number] {
if (statName === 'warMagicSuccessProb') {
return (value as number) + 1;
}
return value;
}
export const traitModule: TraitModule = {
key: 'che_신중',
name: '신중',
info: '[전투] 계략 성공 확률 100%',
kind: 'war',
getName: () => '신중',
getInfo: () => '[전투] 계략 성공 확률 100%',
selection: {
requirements: [TraitRequirement.STAT_INTEL],
weight: 1,
weightType: TraitWeightType.NORM,
},
onCalcStat,
};
@@ -0,0 +1,42 @@
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { TraitRequirement, TraitWeightType } from '../requirements.js';
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { che_위압발동, che_위압시도 } from '@sammo-ts/logic/war/triggers/che_위압.js';
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
_statName: GeneralStatName | WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number] {
return value;
}
export const traitModule: TraitModule = {
key: 'che_위압',
name: '위압',
info: '[전투] 첫 페이즈 위압 발동(적 공격, 회피 불가, 사기 5 감소)',
kind: 'war',
getName: () => '위압',
getInfo: () => '[전투] 첫 페이즈 위압 발동(적 공격, 회피 불가, 사기 5 감소)',
selection: {
requirements: [TraitRequirement.STAT_STRENGTH],
weight: 1,
weightType: TraitWeightType.NORM,
},
getBattlePhaseTriggerList: (_context) => {
if (!_context.unit) return null;
return new WarTriggerCaller(new che_위압시도(_context.unit), new che_위압발동(_context.unit));
},
onCalcStat,
};
@@ -0,0 +1,30 @@
import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.js';
import { triggerModule as cheUisulTriggerModule } from '@sammo-ts/logic/war/triggers/che_의술.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { TraitRequirement, TraitWeightType } from '../requirements.js';
// 전투 특기: 의술
export const traitModule: TraitModule = {
key: 'che_의술',
name: '의술',
info: '[군사] 매 턴마다 자신(100%)과 소속 도시 장수(적 포함 50%) 부상 회복<br>[전투] 페이즈마다 40% 확률로 치료 발동(아군 피해 30% 감소, 부상 회복)',
kind: 'war',
getName: () => '의술',
getInfo: () =>
'[군사] 매 턴마다 자신(100%)과 소속 도시 장수(적 포함 50%) 부상 회복<br>[전투] 페이즈마다 40% 확률로 치료 발동(아군 피해 30% 감소, 부상 회복)',
selection: {
requirements: [TraitRequirement.STAT_LEADERSHIP, TraitRequirement.STAT_STRENGTH, TraitRequirement.STAT_INTEL],
weight: 2,
weightType: TraitWeightType.PERCENT,
},
getPreTurnExecuteTriggerList: (context) => new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general)),
getBattlePhaseTriggerList: (context: WarActionContext) => {
const unit = context.unit;
if (!unit) {
return null;
}
return cheUisulTriggerModule.createTriggerList(unit);
},
};
@@ -0,0 +1,45 @@
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { TraitRequirement, TraitWeightType } from '../requirements.js';
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { che_저격발동, che_저격시도 } from '@sammo-ts/logic/war/triggers/che_저격.js';
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
_statName: GeneralStatName | WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number] {
return value;
}
export const traitModule: TraitModule = {
key: 'che_저격',
name: '저격',
info: '[전투] 새로운 상대와 전투 시 50% 확률로 저격 발동, 성공 시 사기+20',
kind: 'war',
getName: () => '저격',
getInfo: () => '[전투] 새로운 상대와 전투 시 50% 확률로 저격 발동, 성공 시 사기+20',
selection: {
requirements: [TraitRequirement.STAT_LEADERSHIP, TraitRequirement.STAT_STRENGTH, TraitRequirement.STAT_INTEL],
weight: 1,
weightType: TraitWeightType.NORM,
},
getBattlePhaseTriggerList: (_context) => {
if (!_context.unit) return null;
return new WarTriggerCaller(
new che_저격시도(_context.unit, BaseWarUnitTrigger.TYPE_NONE, 0.5, 20, 40, 20),
new che_저격발동(_context.unit, BaseWarUnitTrigger.TYPE_NONE)
);
},
onCalcStat,
};
@@ -0,0 +1,39 @@
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { TraitRequirement, TraitWeightType } from '../requirements.js';
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number] {
if (statName === 'warMagicSuccessDamage') {
return (value as number) * 1.5;
}
return value;
}
export const traitModule: TraitModule = {
key: 'che_집중',
name: '집중',
info: '[전투] 계략 성공 시 대미지 +50%',
kind: 'war',
getName: () => '집중',
getInfo: () => '[전투] 계략 성공 시 대미지 +50%',
selection: {
requirements: [TraitRequirement.STAT_INTEL],
weight: 1,
weightType: TraitWeightType.NORM,
},
onCalcStat,
};
@@ -0,0 +1,65 @@
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { TraitRequirement, TraitWeightType } from '../requirements.js';
const RECRUIT_TRAIN = 70;
const CONSCRIPT_TRAIN = 84;
const resolveLeadershipBonus = (
context: GeneralActionContext | WarActionContext,
value: number | [number, number]
): number | [number, number] => {
if (Array.isArray(value)) {
return value;
}
const base = context.general.stats.leadership;
return value + base * 0.25;
};
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number]
): number | [number, number] {
if (statName !== 'leadership') {
return value;
}
return resolveLeadershipBonus(context, value);
}
// 전투 특기: 징병
export const traitModule: TraitModule = {
key: 'che_징병',
name: '징병',
info: '[군사] 징병/모병 시 훈사 70/84 제공<br>[기타] 통솔 순수 능력치 보정 +25%, 징병/모병/소집해제 시 인구 변동 없음',
kind: 'war',
getName: () => '징병',
getInfo: () =>
'[군사] 징병/모병 시 훈사 70/84 제공<br>[기타] 통솔 순수 능력치 보정 +25%, 징병/모병/소집해제 시 인구 변동 없음',
selection: {
requirements: [TraitRequirement.STAT_LEADERSHIP, TraitRequirement.STAT_STRENGTH, TraitRequirement.STAT_INTEL],
weight: 1,
weightType: TraitWeightType.NORM,
},
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '징병' || turnType === '모병') {
if (varType === 'train' || varType === 'atmos') {
return turnType === '징병' ? RECRUIT_TRAIN : CONSCRIPT_TRAIN;
}
}
if (turnType === '징집인구' && varType === 'score') {
return 0;
}
return value;
},
onCalcStat,
};
@@ -0,0 +1,43 @@
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { TraitRequirement, TraitWeightType } from '../requirements.js';
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
_statName: GeneralStatName | WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number] {
return value;
}
export const traitModule: TraitModule = {
key: 'che_척사',
name: '척사',
info: '[전투] 지역·도시 병종 상대로 대미지 +20%, 아군 피해 -20%',
kind: 'war',
getName: () => '척사',
getInfo: () => '[전투] 지역·도시 병종 상대로 대미지 +20%, 아군 피해 -20%',
selection: {
requirements: [TraitRequirement.STAT_LEADERSHIP, TraitRequirement.STAT_STRENGTH, TraitRequirement.STAT_INTEL],
weight: 1,
weightType: TraitWeightType.NORM,
},
getWarPowerMultiplier: (_context, _unit, oppose) => {
const opposeCrewType = oppose.getCrewType();
if (opposeCrewType.reqCities() || opposeCrewType.reqRegions()) {
return [1.2, 0.8];
}
return [1, 1];
},
onCalcStat,
};
@@ -0,0 +1,49 @@
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { TraitRequirement, TraitWeightType } from '../requirements.js';
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { che_필살강화_회피불가 } from '@sammo-ts/logic/war/triggers/che_필살.js';
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number] {
if (statName === 'warCriticalRatio') {
return (value as number) + 0.3;
}
if (statName === 'criticalDamageRange') {
const [rangeMin, rangeMax] = value as [number, number];
return [(rangeMin + rangeMax) / 2, rangeMax];
}
return value;
}
export const traitModule: TraitModule = {
key: 'che_필살',
name: '필살',
info: '[전투] 필살 확률 +30%p, 필살 발동시 대상 회피 불가, 필살 계수 향상',
kind: 'war',
getName: () => '필살',
getInfo: () => '[전투] 필살 확률 +30%p, 필살 발동시 대상 회피 불가, 필살 계수 향상',
selection: {
requirements: [TraitRequirement.STAT_LEADERSHIP, TraitRequirement.STAT_STRENGTH, TraitRequirement.STAT_INTEL],
weight: 1,
weightType: TraitWeightType.NORM,
},
getBattlePhaseTriggerList: (_context) => {
if (!_context.unit) return null;
return new WarTriggerCaller(new che_필살강화_회피불가(_context.unit));
},
onCalcStat,
};
@@ -0,0 +1,42 @@
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { TraitRequirement, TraitWeightType } from '../requirements.js';
function onCalcStat(context: GeneralActionContext, statName: GeneralStatName, value: number, aux?: unknown): number;
function onCalcStat(
context: WarActionContext,
statName: WarStatName,
value: number | [number, number],
aux?: unknown
): number | [number, number];
function onCalcStat(
_context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number],
_aux?: unknown
): number | [number, number] {
if (statName === 'warMagicSuccessProb') {
return (value as number) + 0.1;
}
if (statName === 'warMagicSuccessDamage') {
return (value as number) * 1.3;
}
return value;
}
export const traitModule: TraitModule = {
key: 'che_환술',
name: '환술',
info: '[전투] 계략 성공 확률 +10%p, 계략 성공 시 대미지 +30%',
kind: 'war',
getName: () => '환술',
getInfo: () => '[전투] 계략 성공 확률 +10%p, 계략 성공 시 대미지 +30%',
selection: {
requirements: [TraitRequirement.STAT_INTEL],
weight: 5,
weightType: TraitWeightType.PERCENT,
},
onCalcStat,
};
@@ -0,0 +1,103 @@
import type { TraitModule, TraitModuleExport } from '@sammo-ts/logic/actionModules/traits/types.js';
export const WAR_TRAIT_KEYS = [
'che_귀병',
'che_신산',
'che_환술',
'che_집중',
'che_신중',
'che_반계',
'che_보병',
'che_궁병',
'che_기병',
'che_공성',
'che_돌격',
'che_무쌍',
'che_견고',
'che_위압',
'che_저격',
'che_필살',
'che_징병',
'che_의술',
'che_격노',
'che_척사',
] as const;
export type WarTraitKey = (typeof WAR_TRAIT_KEYS)[number];
export type WarTraitModule = TraitModule;
export type WarTraitImporter = () => Promise<TraitModuleExport>;
const defaultImporters: Record<WarTraitKey, WarTraitImporter> = {
che_귀병: async () => import('./che_귀병.js'),
che_신산: async () => import('./che_신산.js'),
che_환술: async () => import('./che_환술.js'),
che_집중: async () => import('./che_집중.js'),
che_신중: async () => import('./che_신중.js'),
che_반계: async () => import('./che_반계.js'),
che_보병: async () => import('./che_보병.js'),
che_궁병: async () => import('./che_궁병.js'),
che_기병: async () => import('./che_기병.js'),
che_공성: async () => import('./che_공성.js'),
che_돌격: async () => import('./che_돌격.js'),
che_무쌍: async () => import('./che_무쌍.js'),
che_견고: async () => import('./che_견고.js'),
che_위압: async () => import('./che_위압.js'),
che_저격: async () => import('./che_저격.js'),
che_필살: async () => import('./che_필살.js'),
che_징병: async () => import('./che_징병.js'),
che_의술: async () => import('./che_의술.js'),
che_격노: async () => import('./che_격노.js'),
che_척사: async () => import('./che_척사.js'),
};
export const isWarTraitKey = (value: string): value is WarTraitKey => WAR_TRAIT_KEYS.includes(value as WarTraitKey);
export class WarTraitLoader {
private readonly cache = new Map<WarTraitKey, Promise<WarTraitModule>>();
constructor(private readonly importers: Record<WarTraitKey, WarTraitImporter> = defaultImporters) {}
async load(key: WarTraitKey): Promise<WarTraitModule> {
const cached = this.cache.get(key);
if (cached) {
return cached;
}
const importer = this.importers[key];
if (!importer) {
throw new Error(`Unknown war trait key: ${key}`);
}
const loading = importer().then((module) => {
if (!('traitModule' in module)) {
throw new Error(`Missing traitModule for war trait: ${key}`);
}
const resolved = module.traitModule;
if (resolved.key !== key) {
throw new Error(`War trait key mismatch: expected ${key}, got ${resolved.key}`);
}
if (resolved.kind !== 'war') {
throw new Error(`War trait kind mismatch: ${resolved.key}`);
}
return resolved;
});
this.cache.set(key, loading);
return loading;
}
}
export const loadWarTraitModules = async (
keys: WarTraitKey[],
loader: WarTraitLoader = new WarTraitLoader()
): Promise<WarTraitModule[]> => {
const modules: WarTraitModule[] = [];
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;
};
+54
View File
@@ -0,0 +1,54 @@
export type TriggerDomesticActionType =
| '상업'
| '농업'
| '성벽'
| '수비'
| '치안'
| '인재탐색'
| '징병'
| '징집인구'
| '계략'
| '민심'
| '인구'
| '기술'
| '모병'
| '단련'
| '조달';
export type TriggerDomesticVarType = 'cost' | 'score' | 'success' | 'fail' | 'train' | 'atmos' | 'rice' | 'probability';
export type TriggerStrategicActionType =
'의병모집' | '허보' | '필사즉생' | '백성동원' | '이호경식' | '수몰' | '급습' | '피장파장';
export type TriggerStrategicVarType = 'delay' | 'globalDelay';
export type TriggerNationalIncomeType = 'gold' | 'rice' | 'pop';
export type GeneralStatName =
| 'leadership'
| 'strength'
| 'intelligence'
| 'experience'
| 'dedication'
| 'sabotageDefence'
| 'sabotageAttack'
| 'injuryProb'
| 'addDex';
export type WarStatName =
| GeneralStatName
| 'cityBattleOrder'
| 'initWarPhase'
| 'bonusTrain'
| 'bonusAtmos'
| 'warCriticalRatio'
| 'warAvoidRatio'
| 'injuryProb'
| 'killRice'
| 'criticalDamageRange'
| 'warMagicSuccessDamage'
| 'warMagicFailDamage'
| 'warMagicTrialProb'
| 'warMagicSuccessProb'
| 'addDex'
| `dex${number}`;