refactor: rename SpecialAction to Trait and update related types

- Changed SpecialActionKind to TraitKind and updated its usage across the codebase.
- Refactored SpecialActionSpec to TraitSpec and updated all references.
- Updated the SpecialActionModule and related interfaces to TraitModule.
- Added personality traits with corresponding modules and loaders.
- Updated tests to reflect the new Trait structure and naming conventions.
- Adjusted imports and exports to align with the new Trait system.
This commit is contained in:
2026-01-05 14:13:09 +00:00
parent 3581477a42
commit 33ca12bf14
24 changed files with 778 additions and 147 deletions
@@ -1,7 +1,7 @@
import type { SpecialActionModule } from '@sammo-ts/logic/triggers/special/types.js'; import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
// 내정 특기: 발명 // 내정 특기: 발명
export const specialModule: SpecialActionModule = { export const traitModule: TraitModule = {
key: 'che_발명', key: 'che_발명',
name: '발명', name: '발명',
info: '[내정] 기술 연구 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%', info: '[내정] 기술 연구 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
@@ -1,7 +1,7 @@
import type { SpecialActionModule } from '@sammo-ts/logic/triggers/special/types.js'; import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
// 내정 특기: 인덕 // 내정 특기: 인덕
export const specialModule: SpecialActionModule = { export const traitModule: TraitModule = {
key: 'che_인덕', key: 'che_인덕',
name: '인덕', name: '인덕',
info: '[내정] 주민 선정·정착 장려 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%', info: '[내정] 주민 선정·정착 장려 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
@@ -1,68 +1,68 @@
import type { import type {
SpecialActionModule, TraitModule,
SpecialActionModuleExport, TraitModuleExport,
} from '@sammo-ts/logic/triggers/special/types.js'; } from '@sammo-ts/logic/triggers/special/types.js';
export const DOMESTIC_SPECIAL_KEYS = [ export const DOMESTIC_TRAIT_KEYS = [
'che_인덕', 'che_인덕',
'che_발명', 'che_발명',
] as const; ] as const;
export type DomesticSpecialKey = export type DomesticTraitKey =
(typeof DOMESTIC_SPECIAL_KEYS)[number]; (typeof DOMESTIC_TRAIT_KEYS)[number];
export type DomesticSpecialModule = SpecialActionModule; export type DomesticTraitModule = TraitModule;
export type DomesticSpecialImporter = () => Promise<SpecialActionModuleExport>; export type DomesticTraitImporter = () => Promise<TraitModuleExport>;
const defaultImporters: Record< const defaultImporters: Record<
DomesticSpecialKey, DomesticTraitKey,
DomesticSpecialImporter DomesticTraitImporter
> = { > = {
che_인덕: async () => import('./che_인덕.js'), che_인덕: async () => import('./che_인덕.js'),
che_발명: async () => import('./che_발명.js'), che_발명: async () => import('./che_발명.js'),
}; };
export const isDomesticSpecialKey = ( export const isDomesticTraitKey = (
value: string value: string
): value is DomesticSpecialKey => ): value is DomesticTraitKey =>
DOMESTIC_SPECIAL_KEYS.includes(value as DomesticSpecialKey); DOMESTIC_TRAIT_KEYS.includes(value as DomesticTraitKey);
export class DomesticSpecialLoader { export class DomesticTraitLoader {
private readonly cache = new Map< private readonly cache = new Map<
DomesticSpecialKey, DomesticTraitKey,
Promise<DomesticSpecialModule> Promise<DomesticTraitModule>
>(); >();
constructor( constructor(
private readonly importers: Record< private readonly importers: Record<
DomesticSpecialKey, DomesticTraitKey,
DomesticSpecialImporter DomesticTraitImporter
> = defaultImporters > = defaultImporters
) {} ) {}
async load(key: DomesticSpecialKey): Promise<DomesticSpecialModule> { async load(key: DomesticTraitKey): Promise<DomesticTraitModule> {
const cached = this.cache.get(key); const cached = this.cache.get(key);
if (cached) { if (cached) {
return cached; return cached;
} }
const importer = this.importers[key]; const importer = this.importers[key];
if (!importer) { if (!importer) {
throw new Error(`Unknown domestic special key: ${key}`); throw new Error(`Unknown domestic trait key: ${key}`);
} }
const loading = importer().then((module) => { const loading = importer().then((module) => {
if (!('specialModule' in module)) { if (!('traitModule' in module)) {
throw new Error(`Missing specialModule for domestic special: ${key}`); throw new Error(`Missing traitModule for domestic trait: ${key}`);
} }
const resolved = module.specialModule; const resolved = module.traitModule;
if (resolved.key !== key) { if (resolved.key !== key) {
throw new Error( throw new Error(
`Domestic special key mismatch: expected ${key}, got ${resolved.key}` `Domestic trait key mismatch: expected ${key}, got ${resolved.key}`
); );
} }
if (resolved.kind !== 'domestic') { if (resolved.kind !== 'domestic') {
throw new Error( throw new Error(
`Domestic special kind mismatch: ${resolved.key}` `Domestic trait kind mismatch: ${resolved.key}`
); );
} }
return resolved; return resolved;
@@ -72,11 +72,11 @@ export class DomesticSpecialLoader {
} }
} }
export const loadDomesticSpecialModules = async ( export const loadDomesticTraitModules = async (
keys: DomesticSpecialKey[], keys: DomesticTraitKey[],
loader: DomesticSpecialLoader = new DomesticSpecialLoader() loader: DomesticTraitLoader = new DomesticTraitLoader()
): Promise<DomesticSpecialModule[]> => { ): Promise<DomesticTraitModule[]> => {
const modules: DomesticSpecialModule[] = []; const modules: DomesticTraitModule[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const key of keys) { for (const key of keys) {
if (seen.has(key)) { if (seen.has(key)) {
@@ -2,3 +2,4 @@ export * from './types.js';
export * from './registry.js'; export * from './registry.js';
export * from './domestic/index.js'; export * from './domestic/index.js';
export * from './war/index.js'; export * from './war/index.js';
export * from './personality/index.js';
@@ -0,0 +1,43 @@
import type { TraitModule } from '@sammo-ts/logic/triggers/special/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/triggers/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/triggers/special/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/triggers/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/triggers/special/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/triggers/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/triggers/special/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/triggers/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/triggers/special/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/triggers/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/triggers/special/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/triggers/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/triggers/special/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/triggers/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/triggers/special/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/triggers/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/triggers/special/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/triggers/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/triggers/special/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/triggers/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/triggers/special/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/triggers/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,107 @@
import type {
TraitModule,
TraitModuleExport,
} from '@sammo-ts/logic/triggers/special/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;
};
+70 -45
View File
@@ -16,46 +16,64 @@ import type { WarActionContext, WarActionModule } from '@sammo-ts/logic/war/acti
import type { WarUnit } from '@sammo-ts/logic/war/units.js'; import type { WarUnit } from '@sammo-ts/logic/war/units.js';
import type { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; import type { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import type { import type {
SpecialActionKind, TraitKind,
SpecialActionModule, TraitModule,
SpecialActionModuleRegistry, TraitModuleRegistry,
} from './types.js'; } from './types.js';
const resolveSpecialKey = ( const resolveTraitKey = (
context: { general: { role: { specialDomestic: string | null; specialWar: string | null } } }, context: {
kind: SpecialActionKind general: {
): string | null => role: {
kind === 'domestic' personality: string | null;
? context.general.role.specialDomestic specialDomestic: string | null;
: context.general.role.specialWar; specialWar: string | null;
};
};
},
kind: TraitKind
): string | null => {
if (kind === 'domestic') {
return context.general.role.specialDomestic;
}
if (kind === 'war') {
return context.general.role.specialWar;
}
return context.general.role.personality;
};
const resolveModule = < const resolveModule = <TriggerState extends GeneralTriggerState>(
TriggerState extends GeneralTriggerState registry: TraitModuleRegistry<TriggerState>,
>( kind: TraitKind,
registry: SpecialActionModuleRegistry<TriggerState>,
kind: SpecialActionKind,
key: string | null key: string | null
): SpecialActionModule<TriggerState> | null => { ): TraitModule<TriggerState> | null => {
if (!key) { if (!key) {
return null; return null;
} }
const bucket = kind === 'domestic' ? registry.domestic : registry.war; let bucket: Map<string, TraitModule<TriggerState>>;
if (kind === 'domestic') {
bucket = registry.domestic;
} else if (kind === 'war') {
bucket = registry.war;
} else {
bucket = registry.personality;
}
return bucket.get(key) ?? null; return bucket.get(key) ?? null;
}; };
// General 파이프라인에서 특 모듈을 선택해 위임하는 라우터. // General 파이프라인에서 특성(특기/성격) 모듈을 선택해 위임하는 라우터.
export class SpecialGeneralActionRouter< export class TraitGeneralActionRouter<
TriggerState extends GeneralTriggerState = GeneralTriggerState TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionModule<TriggerState> { > implements GeneralActionModule<TriggerState> {
constructor( constructor(
private readonly kind: SpecialActionKind, private readonly kind: TraitKind,
private readonly registry: SpecialActionModuleRegistry<TriggerState> private readonly registry: TraitModuleRegistry<TriggerState>
) {} ) {}
private getModule( private getModule(
context: GeneralActionContext<TriggerState> context: GeneralActionContext<TriggerState>
): SpecialActionModule<TriggerState> | null { ): TraitModule<TriggerState> | null {
const key = resolveSpecialKey(context, this.kind); const key = resolveTraitKey(context, this.kind);
return resolveModule(this.registry, this.kind, key); return resolveModule(this.registry, this.kind, key);
} }
@@ -135,19 +153,19 @@ export class SpecialGeneralActionRouter<
} }
} }
// 전투 파이프라인에서 특 모듈을 선택해 위임하는 라우터. // 전투 파이프라인에서 특성(특기/성격) 모듈을 선택해 위임하는 라우터.
export class SpecialWarActionRouter< export class TraitWarActionRouter<
TriggerState extends GeneralTriggerState = GeneralTriggerState TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements WarActionModule<TriggerState> { > implements WarActionModule<TriggerState> {
constructor( constructor(
private readonly kind: SpecialActionKind, private readonly kind: TraitKind,
private readonly registry: SpecialActionModuleRegistry<TriggerState> private readonly registry: TraitModuleRegistry<TriggerState>
) {} ) {}
private getModule( private getModule(
context: WarActionContext<TriggerState> context: WarActionContext<TriggerState>
): SpecialActionModule<TriggerState> | null { ): TraitModule<TriggerState> | null {
const key = resolveSpecialKey(context, this.kind); const key = resolveTraitKey(context, this.kind);
return resolveModule(this.registry, this.kind, key); return resolveModule(this.registry, this.kind, key);
} }
@@ -195,21 +213,23 @@ export class SpecialWarActionRouter<
} }
} }
export interface SpecialActionModuleSet< export interface TraitModuleSet<
TriggerState extends GeneralTriggerState = GeneralTriggerState TriggerState extends GeneralTriggerState = GeneralTriggerState
> { > {
general: GeneralActionModule<TriggerState>[]; general: GeneralActionModule<TriggerState>[];
war: WarActionModule<TriggerState>[]; war: WarActionModule<TriggerState>[];
} }
export const createSpecialActionModuleRegistry = < export const createTraitModuleRegistry = <
TriggerState extends GeneralTriggerState = GeneralTriggerState TriggerState extends GeneralTriggerState = GeneralTriggerState
>(options: { >(options: {
domestic?: SpecialActionModule<TriggerState>[]; domestic?: TraitModule<TriggerState>[];
war?: SpecialActionModule<TriggerState>[]; war?: TraitModule<TriggerState>[];
}): SpecialActionModuleRegistry<TriggerState> => { personality?: TraitModule<TriggerState>[];
const domestic = new Map<string, SpecialActionModule<TriggerState>>(); }): TraitModuleRegistry<TriggerState> => {
const war = new Map<string, SpecialActionModule<TriggerState>>(); const domestic = new Map<string, TraitModule<TriggerState>>();
const war = new Map<string, TraitModule<TriggerState>>();
const personality = new Map<string, TraitModule<TriggerState>>();
for (const module of options.domestic ?? []) { for (const module of options.domestic ?? []) {
domestic.set(module.key, module); domestic.set(module.key, module);
@@ -217,22 +237,27 @@ export const createSpecialActionModuleRegistry = <
for (const module of options.war ?? []) { for (const module of options.war ?? []) {
war.set(module.key, module); war.set(module.key, module);
} }
for (const module of options.personality ?? []) {
personality.set(module.key, module);
}
return { domestic, war }; return { domestic, war, personality };
}; };
// 특 레지스트리를 General/전투 파이프라인용 모듈 목록으로 변환한다. // 특 레지스트리를 General/전투 파이프라인용 모듈 목록으로 변환한다.
export const createSpecialActionModules = < export const createTraitModules = <
TriggerState extends GeneralTriggerState = GeneralTriggerState TriggerState extends GeneralTriggerState = GeneralTriggerState
>( >(
registry: SpecialActionModuleRegistry<TriggerState> registry: TraitModuleRegistry<TriggerState>
): SpecialActionModuleSet<TriggerState> => ({ ): TraitModuleSet<TriggerState> => ({
general: [ general: [
new SpecialGeneralActionRouter<TriggerState>('domestic', registry), new TraitGeneralActionRouter<TriggerState>('domestic', registry),
new SpecialGeneralActionRouter<TriggerState>('war', registry), new TraitGeneralActionRouter<TriggerState>('war', registry),
new TraitGeneralActionRouter<TriggerState>('personality', registry),
], ],
war: [ war: [
new SpecialWarActionRouter<TriggerState>('domestic', registry), new TraitWarActionRouter<TriggerState>('domestic', registry),
new SpecialWarActionRouter<TriggerState>('war', registry), new TraitWarActionRouter<TriggerState>('war', registry),
new TraitWarActionRouter<TriggerState>('personality', registry),
], ],
}); });
+11 -10
View File
@@ -2,30 +2,31 @@ import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js'; import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js'; import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
export type SpecialActionKind = 'domestic' | 'war'; export type TraitKind = 'domestic' | 'war' | 'personality';
export interface SpecialActionSpec { export interface TraitSpec {
key: string; key: string;
name: string; name: string;
info: string; info: string;
kind: SpecialActionKind; kind: TraitKind;
} }
export type SpecialActionModule< export type TraitModule<
TriggerState extends GeneralTriggerState = GeneralTriggerState TriggerState extends GeneralTriggerState = GeneralTriggerState
> = SpecialActionSpec & > = TraitSpec &
GeneralActionModule<TriggerState> & GeneralActionModule<TriggerState> &
WarActionModule<TriggerState>; WarActionModule<TriggerState>;
export interface SpecialActionModuleExport< export interface TraitModuleExport<
TriggerState extends GeneralTriggerState = GeneralTriggerState TriggerState extends GeneralTriggerState = GeneralTriggerState
> { > {
specialModule: SpecialActionModule<TriggerState>; traitModule: TraitModule<TriggerState>;
} }
export interface SpecialActionModuleRegistry< export interface TraitModuleRegistry<
TriggerState extends GeneralTriggerState = GeneralTriggerState TriggerState extends GeneralTriggerState = GeneralTriggerState
> { > {
domestic: Map<string, SpecialActionModule<TriggerState>>; domestic: Map<string, TraitModule<TriggerState>>;
war: Map<string, SpecialActionModule<TriggerState>>; war: Map<string, TraitModule<TriggerState>>;
personality: Map<string, TraitModule<TriggerState>>;
} }
@@ -2,10 +2,10 @@ import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js'; import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.js'; import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.js';
import { triggerModule as cheUisulTriggerModule } from '@sammo-ts/logic/war/triggers/che_의술.js'; import { triggerModule as cheUisulTriggerModule } from '@sammo-ts/logic/war/triggers/che_의술.js';
import type { SpecialActionModule } from '@sammo-ts/logic/triggers/special/types.js'; import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
// 전투 특기: 의술 // 전투 특기: 의술
export const specialModule: SpecialActionModule = { export const traitModule: TraitModule = {
key: 'che_의술', key: 'che_의술',
name: '의술', name: '의술',
info: '[군사] 매 턴마다 자신(100%)과 소속 도시 장수(적 포함 50%) 부상 회복<br>[전투] 페이즈마다 40% 확률로 치료 발동(아군 피해 30% 감소, 부상 회복)', info: '[군사] 매 턴마다 자신(100%)과 소속 도시 장수(적 포함 50%) 부상 회복<br>[전투] 페이즈마다 40% 확률로 치료 발동(아군 피해 30% 감소, 부상 회복)',
@@ -1,7 +1,7 @@
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js'; import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/triggers/types.js'; import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/triggers/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js'; import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { SpecialActionModule } from '@sammo-ts/logic/triggers/special/types.js'; import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
const RECRUIT_TRAIN = 70; const RECRUIT_TRAIN = 70;
const CONSCRIPT_TRAIN = 84; const CONSCRIPT_TRAIN = 84;
@@ -41,7 +41,7 @@ function onCalcStat(
} }
// 전투 특기: 징병 // 전투 특기: 징병
export const specialModule: SpecialActionModule = { export const traitModule: TraitModule = {
key: 'che_징병', key: 'che_징병',
name: '징병', name: '징병',
info: '[군사] 징병/모병 시 훈사 70/84 제공<br>[기타] 통솔 순수 능력치 보정 +25%, 징병/모병/소집해제 시 인구 변동 없음', info: '[군사] 징병/모병 시 훈사 70/84 제공<br>[기타] 통솔 순수 능력치 보정 +25%, 징병/모병/소집해제 시 인구 변동 없음',
@@ -1,67 +1,67 @@
import type { import type {
SpecialActionModule, TraitModule,
SpecialActionModuleExport, TraitModuleExport,
} from '@sammo-ts/logic/triggers/special/types.js'; } from '@sammo-ts/logic/triggers/special/types.js';
export const WAR_SPECIAL_KEYS = [ export const WAR_TRAIT_KEYS = [
'che_의술', 'che_의술',
'che_징병', 'che_징병',
] as const; ] as const;
export type WarSpecialKey = export type WarTraitKey =
(typeof WAR_SPECIAL_KEYS)[number]; (typeof WAR_TRAIT_KEYS)[number];
export type WarSpecialModule = SpecialActionModule; export type WarTraitModule = TraitModule;
export type WarSpecialImporter = () => Promise<SpecialActionModuleExport>; export type WarTraitImporter = () => Promise<TraitModuleExport>;
const defaultImporters: Record< const defaultImporters: Record<
WarSpecialKey, WarTraitKey,
WarSpecialImporter WarTraitImporter
> = { > = {
che_의술: async () => import('./che_의술.js'), che_의술: async () => import('./che_의술.js'),
che_징병: async () => import('./che_징병.js'), che_징병: async () => import('./che_징병.js'),
}; };
export const isWarSpecialKey = ( export const isWarTraitKey = (
value: string value: string
): value is WarSpecialKey => ): value is WarTraitKey =>
WAR_SPECIAL_KEYS.includes(value as WarSpecialKey); WAR_TRAIT_KEYS.includes(value as WarTraitKey);
export class WarSpecialLoader { export class WarTraitLoader {
private readonly cache = new Map< private readonly cache = new Map<
WarSpecialKey, WarTraitKey,
Promise<WarSpecialModule> Promise<WarTraitModule>
>(); >();
constructor( constructor(
private readonly importers: Record< private readonly importers: Record<
WarSpecialKey, WarTraitKey,
WarSpecialImporter WarTraitImporter
> = defaultImporters > = defaultImporters
) {} ) {}
async load(key: WarSpecialKey): Promise<WarSpecialModule> { async load(key: WarTraitKey): Promise<WarTraitModule> {
const cached = this.cache.get(key); const cached = this.cache.get(key);
if (cached) { if (cached) {
return cached; return cached;
} }
const importer = this.importers[key]; const importer = this.importers[key];
if (!importer) { if (!importer) {
throw new Error(`Unknown war special key: ${key}`); throw new Error(`Unknown war trait key: ${key}`);
} }
const loading = importer().then((module) => { const loading = importer().then((module) => {
if (!('specialModule' in module)) { if (!('traitModule' in module)) {
throw new Error(`Missing specialModule for war special: ${key}`); throw new Error(`Missing traitModule for war trait: ${key}`);
} }
const resolved = module.specialModule; const resolved = module.traitModule;
if (resolved.key !== key) { if (resolved.key !== key) {
throw new Error( throw new Error(
`War special key mismatch: expected ${key}, got ${resolved.key}` `War trait key mismatch: expected ${key}, got ${resolved.key}`
); );
} }
if (resolved.kind !== 'war') { if (resolved.kind !== 'war') {
throw new Error(`War special kind mismatch: ${resolved.key}`); throw new Error(`War trait kind mismatch: ${resolved.key}`);
} }
return resolved; return resolved;
}); });
@@ -70,11 +70,11 @@ export class WarSpecialLoader {
} }
} }
export const loadWarSpecialModules = async ( export const loadWarTraitModules = async (
keys: WarSpecialKey[], keys: WarTraitKey[],
loader: WarSpecialLoader = new WarSpecialLoader() loader: WarTraitLoader = new WarTraitLoader()
): Promise<WarSpecialModule[]> => { ): Promise<WarTraitModule[]> => {
const modules: WarSpecialModule[] = []; const modules: WarTraitModule[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const key of keys) { for (const key of keys) {
if (seen.has(key)) { if (seen.has(key)) {
+8 -2
View File
@@ -11,7 +11,8 @@ export type TriggerDomesticActionType =
| '민심' | '민심'
| '인구' | '인구'
| '기술' | '기술'
| '모병'; | '모병'
| '단련';
export type TriggerDomesticVarType = export type TriggerDomesticVarType =
| 'cost' | 'cost'
@@ -36,7 +37,12 @@ export type TriggerStrategicVarType = 'delay' | 'globalDelay';
export type TriggerNationalIncomeType = 'gold' | 'rice'; export type TriggerNationalIncomeType = 'gold' | 'rice';
export type GeneralStatName = 'leadership' | 'strength' | 'intelligence'; export type GeneralStatName =
| 'leadership'
| 'strength'
| 'intelligence'
| 'experience'
| 'dedication';
export type WarStatName = export type WarStatName =
| GeneralStatName | GeneralStatName
+23 -23
View File
@@ -8,10 +8,10 @@ import type { UnitSetDefinition } from '../src/world/types.js';
import { GeneralActionPipeline } from '../src/triggers/general-action.js'; import { GeneralActionPipeline } from '../src/triggers/general-action.js';
import { createGeneralTriggerContext } from '../src/triggers/general.js'; import { createGeneralTriggerContext } from '../src/triggers/general.js';
import { import {
createSpecialActionModuleRegistry, createTraitModuleRegistry,
createSpecialActionModules, createTraitModules,
loadDomesticSpecialModules, loadDomesticTraitModules,
loadWarSpecialModules, loadWarTraitModules,
} from '../src/triggers/special/index.js'; } from '../src/triggers/special/index.js';
import { ActionLogger } from '../src/logging/actionLogger.js'; import { ActionLogger } from '../src/logging/actionLogger.js';
import { WarActionPipeline } from '../src/war/actions.js'; import { WarActionPipeline } from '../src/war/actions.js';
@@ -161,13 +161,13 @@ const buildUnitSet = (): UnitSetDefinition => ({
], ],
}); });
describe('special action modules', () => { describe('trait modules', () => {
it('loads special modules by key', async () => { it('loads trait modules by key', async () => {
const domestic = await loadDomesticSpecialModules([ const domestic = await loadDomesticTraitModules([
'che_인덕', 'che_인덕',
'che_발명', 'che_발명',
]); ]);
const war = await loadWarSpecialModules(['che_의술', 'che_징병']); const war = await loadWarTraitModules(['che_의술', 'che_징병']);
expect(domestic.map((module) => module.key)).toEqual([ expect(domestic.map((module) => module.key)).toEqual([
'che_인덕', 'che_인덕',
@@ -180,15 +180,15 @@ describe('special action modules', () => {
}); });
it('applies domestic and war modifiers in general pipeline', async () => { it('applies domestic and war modifiers in general pipeline', async () => {
const domestic = await loadDomesticSpecialModules([ const domestic = await loadDomesticTraitModules([
'che_인덕', 'che_인덕',
'che_발명', 'che_발명',
]); ]);
const war = await loadWarSpecialModules(['che_의술', 'che_징병']); const war = await loadWarTraitModules(['che_의술', 'che_징병']);
const registry = createSpecialActionModuleRegistry({ domestic, war }); const registry = createTraitModuleRegistry({ domestic, war });
const specialModules = createSpecialActionModules(registry); const traitModules = createTraitModules(registry);
const pipeline = new GeneralActionPipeline(specialModules.general); const pipeline = new GeneralActionPipeline(traitModules.general);
const general = buildGeneral({ const general = buildGeneral({
role: { role: {
personality: null, personality: null,
@@ -214,14 +214,14 @@ describe('special action modules', () => {
}); });
it('heals city generals with 의술 pre-turn trigger', async () => { it('heals city generals with 의술 pre-turn trigger', async () => {
const domestic = await loadDomesticSpecialModules([ const domestic = await loadDomesticTraitModules([
'che_인덕', 'che_인덕',
'che_발명', 'che_발명',
]); ]);
const war = await loadWarSpecialModules(['che_의술', 'che_징병']); const war = await loadWarTraitModules(['che_의술', 'che_징병']);
const registry = createSpecialActionModuleRegistry({ domestic, war }); const registry = createTraitModuleRegistry({ domestic, war });
const specialModules = createSpecialActionModules(registry); const traitModules = createTraitModules(registry);
const pipeline = new GeneralActionPipeline(specialModules.general); const pipeline = new GeneralActionPipeline(traitModules.general);
const general = buildGeneral({ const general = buildGeneral({
injury: 20, injury: 20,
@@ -270,13 +270,13 @@ describe('special action modules', () => {
}); });
it('activates 의술 battle trigger and reduces damage', async () => { it('activates 의술 battle trigger and reduces damage', async () => {
const domestic = await loadDomesticSpecialModules([ const domestic = await loadDomesticTraitModules([
'che_인덕', 'che_인덕',
'che_발명', 'che_발명',
]); ]);
const war = await loadWarSpecialModules(['che_의술', 'che_징병']); const war = await loadWarTraitModules(['che_의술', 'che_징병']);
const registry = createSpecialActionModuleRegistry({ domestic, war }); const registry = createTraitModuleRegistry({ domestic, war });
const specialModules = createSpecialActionModules(registry); const traitModules = createTraitModules(registry);
const rng = new RandUtil(new ConstantRNG(0)); const rng = new RandUtil(new ConstantRNG(0));
const config = buildConfig(); const config = buildConfig();
@@ -308,7 +308,7 @@ describe('special action modules', () => {
true, true,
crewType, crewType,
new ActionLogger({ generalId: 1, nationId: 1 }), new ActionLogger({ generalId: 1, nationId: 1 }),
new WarActionPipeline(specialModules.war) new WarActionPipeline(traitModules.war)
); );
const defender = new WarUnitCity( const defender = new WarUnitCity(
rng, rng,
+1 -1
View File
@@ -6,7 +6,7 @@ import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
tsconfigPaths({ tsconfigPaths({
projects: [path.resolve(__dirname, '../../tsconfig.base.json')], projects: [path.resolve(__dirname, '../../tsconfig.paths.json')],
}), }),
], ],
test: { test: {