Implement crew type execution model

This commit is contained in:
2026-07-25 05:16:11 +00:00
parent 2824e5af13
commit c5da507df9
24 changed files with 1165 additions and 48 deletions
@@ -1,5 +1,6 @@
import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
export interface TurnCommandItemCatalogEntry {
slot: 'horse' | 'weapon' | 'book' | 'item';
@@ -12,6 +13,7 @@ export interface TurnCommandItemCatalogEntry {
}
export interface TurnCommandEnv {
unitSet?: UnitSetDefinition;
develCost: number;
minAvailableRecruitPop?: number;
trainDelta: number;
@@ -0,0 +1,15 @@
import type { CrewTypeActionModule } from '../types.js';
export const actionModule: CrewTypeActionModule = {
key: 'che_성벽선제',
name: '성벽선제',
info: '전투 가능한 성벽이라면 선제공격을 합니다.',
war: {
onCalcOpposeStat: (_context, statName, value) => {
if (statName === 'cityBattleOrder') {
return 10000;
}
return value;
},
},
};
+263
View File
@@ -0,0 +1,263 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
import type { WarActionContext, WarActionModule } from '@sammo-ts/logic/war/actions.js';
import { WarTriggerCaller, type WarTriggerRegistry } from '@sammo-ts/logic/war/triggers.js';
import type { CrewTypeDefinition, CrewTypeRequirement, UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
import { createCrewTypeActionRegistry } from './registry.js';
import type { CompiledCrewType, CrewTypeActionModule, CrewTypeActionRegistry, CrewTypeCatalog } from './types.js';
const crewTypeWarActionRouters = new WeakSet<object>();
const SUPPORTED_REQUIREMENTS = new Set([
'ReqTech',
'ReqRegions',
'ReqCities',
'ReqCitiesWithCityLevel',
'ReqHighLevelCities',
'ReqNationAux',
'ReqMinRelYear',
'ReqChief',
'ReqNotChief',
'Impossible',
]);
const validateRequirement = (
unitSet: UnitSetDefinition,
crewType: CrewTypeDefinition,
requirement: CrewTypeRequirement
): void => {
if (!SUPPORTED_REQUIREMENTS.has(requirement.type)) {
throw new Error(`Unknown crew type requirement in ${unitSet.id}/${crewType.id}: ${requirement.type}`);
}
};
const validateCoefficientKeys = (
unitSet: UnitSetDefinition,
crewType: CrewTypeDefinition,
field: 'attackCoef' | 'defenceCoef',
crewTypeIds: ReadonlySet<number>,
armTypes: ReadonlySet<number>
): void => {
for (const rawKey of Object.keys(crewType[field])) {
const key = Number(rawKey);
if (!Number.isInteger(key) || (!crewTypeIds.has(key) && !armTypes.has(key))) {
throw new Error(`Invalid ${field} key in ${unitSet.id}/${crewType.id}: ${rawKey}`);
}
}
};
const compileDefinitions = (
unitSet: UnitSetDefinition,
actionRegistry: CrewTypeActionRegistry,
triggerRegistry: WarTriggerRegistry
): Map<number, CompiledCrewType> => {
const definitions = unitSet.crewTypes ?? [];
if (definitions.length === 0) {
throw new Error(`Unit set has no crew types: ${unitSet.id}`);
}
const crewTypeIds = new Set<number>();
const crewTypeNames = new Set<string>();
const armTypes = new Set(definitions.map((crewType) => crewType.armType));
for (const crewType of definitions) {
if (crewTypeIds.has(crewType.id)) {
throw new Error(`Duplicate crew type id in ${unitSet.id}: ${crewType.id}`);
}
if (crewTypeNames.has(crewType.name)) {
throw new Error(`Duplicate crew type name in ${unitSet.id}: ${crewType.name}`);
}
crewTypeIds.add(crewType.id);
crewTypeNames.add(crewType.name);
}
const compiled = new Map<number, CompiledCrewType>();
for (const crewType of definitions) {
for (const requirement of crewType.requirements) {
validateRequirement(unitSet, crewType, requirement);
}
validateCoefficientKeys(unitSet, crewType, 'attackCoef', crewTypeIds, armTypes);
validateCoefficientKeys(unitSet, crewType, 'defenceCoef', crewTypeIds, armTypes);
const actions: CrewTypeActionModule[] = [];
for (const key of crewType.iActionList ?? []) {
const action = actionRegistry.get(key);
if (!action) {
throw new Error(`Unknown crew type action in ${unitSet.id}/${crewType.id}: ${key}`);
}
actions.push(action);
}
for (const key of [...(crewType.initSkillTrigger ?? []), ...(crewType.phaseSkillTrigger ?? [])]) {
if (!triggerRegistry[key]) {
throw new Error(`Unknown crew type war trigger in ${unitSet.id}/${crewType.id}: ${key}`);
}
}
compiled.set(crewType.id, { definition: crewType, actions });
}
return compiled;
};
const createGeneralActionRouter = <TriggerState extends GeneralTriggerState>(
byId: ReadonlyMap<number, CompiledCrewType>
): GeneralActionModule<TriggerState> => {
const modules = (context: GeneralActionContext<TriggerState>) =>
(byId.get(context.general.crewTypeId)?.actions ?? [])
.map((action) => action.general as GeneralActionModule<TriggerState> | undefined)
.filter((action): action is GeneralActionModule<TriggerState> => action !== undefined);
return {
getPreTurnExecuteTriggerList: (context) => {
const caller = new GeneralTriggerCaller<TriggerState>();
for (const module of modules(context)) {
caller.merge(module.getPreTurnExecuteTriggerList?.(context));
}
return caller;
},
onCalcDomestic: (context, turnType, varType, value, aux) => {
let current = value;
for (const module of modules(context)) {
current = module.onCalcDomestic?.(context, turnType, varType, current, aux) ?? current;
}
return current;
},
onCalcStat: (context, statName, value, aux) => {
let current = value;
for (const module of modules(context)) {
current = module.onCalcStat?.(context, statName, current, aux) ?? current;
}
return current;
},
onCalcOpposeStat: (context, statName, value, aux) => {
let current = value;
for (const module of modules(context)) {
current = module.onCalcOpposeStat?.(context, statName, current, aux) ?? current;
}
return current;
},
onCalcStrategic: (context, turnType, varType, value) => {
let current = value;
for (const module of modules(context)) {
current = module.onCalcStrategic?.(context, turnType, varType, current) ?? current;
}
return current;
},
onCalcNationalIncome: (context, type, amount) => {
let current = amount;
for (const module of modules(context)) {
current = module.onCalcNationalIncome?.(context, type, current) ?? current;
}
return current;
},
onArbitraryAction: (context, actionType, phase, aux) => {
let current = aux ?? null;
for (const module of modules(context)) {
current = module.onArbitraryAction?.(context, actionType, phase, current) ?? current;
}
return current;
},
} satisfies GeneralActionModule<TriggerState>;
};
const createWarActionRouter = <TriggerState extends GeneralTriggerState>(
byId: ReadonlyMap<number, CompiledCrewType>,
triggerRegistry: WarTriggerRegistry
): WarActionModule<TriggerState> => {
const compiled = (context: WarActionContext<TriggerState>) => byId.get(context.general.crewTypeId);
const modules = (context: WarActionContext<TriggerState>) =>
(compiled(context)?.actions ?? [])
.map((action) => action.war as WarActionModule<TriggerState> | undefined)
.filter((action): action is WarActionModule<TriggerState> => action !== undefined);
const appendDefinitionTriggers = (
caller: WarTriggerCaller,
context: WarActionContext<TriggerState>,
keys: readonly string[]
): void => {
if (!context.unit) {
if (keys.length > 0) {
throw new Error('Crew type war triggers require a battle unit context');
}
return;
}
for (const key of keys) {
const trigger = triggerRegistry[key]?.(context.unit);
if (!trigger) {
throw new Error(`Unknown crew type war trigger: ${key}`);
}
if (trigger instanceof WarTriggerCaller) {
caller.merge(trigger);
} else {
caller.append(trigger);
}
}
};
const router = {
getBattleInitTriggerList: (context) => {
const caller = new WarTriggerCaller();
appendDefinitionTriggers(caller, context, compiled(context)?.definition.initSkillTrigger ?? []);
for (const module of modules(context)) {
caller.merge(module.getBattleInitTriggerList?.(context));
}
return caller;
},
getBattlePhaseTriggerList: (context) => {
const caller = new WarTriggerCaller();
appendDefinitionTriggers(caller, context, compiled(context)?.definition.phaseSkillTrigger ?? []);
for (const module of modules(context)) {
caller.merge(module.getBattlePhaseTriggerList?.(context));
}
return caller;
},
onCalcStat: (context, statName, value, aux) => {
let current = value;
for (const module of modules(context)) {
current = module.onCalcStat?.(context, statName, current, aux) ?? current;
}
return current;
},
onCalcOpposeStat: (context, statName, value, aux) => {
let current = value;
for (const module of modules(context)) {
current = module.onCalcOpposeStat?.(context, statName, current, aux) ?? current;
}
return current;
},
getWarPowerMultiplier: (context, unit, oppose) => {
let attack = 1;
let defence = 1;
for (const module of modules(context)) {
const [attackMultiplier, defenceMultiplier] = module.getWarPowerMultiplier?.(context, unit, oppose) ?? [
1, 1,
];
attack *= attackMultiplier;
defence *= defenceMultiplier;
}
return [attack, defence];
},
} satisfies WarActionModule<TriggerState>;
crewTypeWarActionRouters.add(router);
return router;
};
export const isCrewTypeWarActionRouter = <TriggerState extends GeneralTriggerState>(
module: WarActionModule<TriggerState>
): boolean => crewTypeWarActionRouters.has(module);
export const compileCrewTypeCatalog = (
unitSet: UnitSetDefinition,
triggerRegistry: WarTriggerRegistry,
actionRegistry: CrewTypeActionRegistry = createCrewTypeActionRegistry()
): CrewTypeCatalog => {
const byId = compileDefinitions(unitSet, actionRegistry, triggerRegistry);
return {
unitSet,
byId,
generalActionModule: createGeneralActionRouter(byId),
warActionModule: createWarActionRouter(byId, triggerRegistry),
};
};
+3
View File
@@ -0,0 +1,3 @@
export * from './types.js';
export * from './registry.js';
export * from './catalog.js';
+8
View File
@@ -0,0 +1,8 @@
import { actionModule as castleFirst } from './actions/che_성벽선제.js';
import type { CrewTypeActionModule, CrewTypeActionRegistry } from './types.js';
export const CREW_TYPE_ACTION_KEYS = ['che_성벽선제'] as const;
export const createCrewTypeActionRegistry = (
modules: readonly CrewTypeActionModule[] = [castleFirst]
): CrewTypeActionRegistry => new Map(modules.map((module) => [module.key, module]));
+25
View File
@@ -0,0 +1,25 @@
import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
import type { CrewTypeDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
export interface CrewTypeActionModule {
key: string;
name: string;
info: string;
general?: GeneralActionModule;
war?: WarActionModule;
}
export type CrewTypeActionRegistry = ReadonlyMap<string, CrewTypeActionModule>;
export interface CompiledCrewType {
definition: CrewTypeDefinition;
actions: readonly CrewTypeActionModule[];
}
export interface CrewTypeCatalog {
unitSet: UnitSetDefinition;
byId: ReadonlyMap<number, CompiledCrewType>;
generalActionModule: GeneralActionModule;
warActionModule: WarActionModule;
}
+1
View File
@@ -2,6 +2,7 @@ export * from './domain/entities.js';
export type { RandomGenerator } from '@sammo-ts/common';
export * from './actions/index.js';
export * from './constraints/index.js';
export * from './crewType/index.js';
export * from './diplomacy/index.js';
export * from './economy/index.js';
export * from './logging/index.js';
+8
View File
@@ -36,6 +36,14 @@ export class WarCrewType {
return this.definition.rice;
}
get magicCoef(): number {
return this.definition.magicCoef;
}
get cost(): number {
return this.definition.cost;
}
public reqCities(): boolean {
return this.definition.requirements.some((req) => req.type === 'ReqCities');
}
@@ -0,0 +1,26 @@
import type { WarTriggerRegistry } from './triggers.js';
import { che_기병병종전투 } from './triggers/che_기병병종전투.js';
import { che_방어력증가5p } from './triggers/che_방어력증가5p.js';
import { che_선제사격발동, che_선제사격시도 } from './triggers/che_선제사격.js';
import { che_성벽부상무효 } from './triggers/che_성벽부상무효.js';
import { che_저지, che_저지_시도 } from './triggers/che_저지.js';
export const CREW_TYPE_WAR_TRIGGER_KEYS = [
'che_성벽부상무효',
'che_기병병종전투',
'che_방어력증가5p',
'che_선제사격시도',
'che_선제사격발동',
'che_저지시도',
'che_저지발동',
] as const;
export const createCrewTypeWarTriggerRegistry = (): WarTriggerRegistry => ({
che_성벽부상무효: (unit) => new che_성벽부상무효(unit),
che_기병병종전투: (unit) => new che_기병병종전투(unit),
che_방어력증가5p: (unit) => new che_방어력증가5p(unit),
che_선제사격시도: (unit) => new che_선제사격시도(unit),
che_선제사격발동: (unit) => new che_선제사격발동(unit),
che_저지시도: (unit) => new che_저지_시도(unit),
che_저지발동: (unit) => new che_저지(unit),
});
+37 -18
View File
@@ -1,10 +1,12 @@
import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import { compileCrewTypeCatalog, isCrewTypeWarActionRouter } from '@sammo-ts/logic/crewType/catalog.js';
import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
import { buildCrewTypeIndex as buildCrewTypeDefinitionIndex } from '@sammo-ts/logic/world/unitSet.js';
import { WarActionPipeline } from './actions.js';
import { WarActionPipeline, type WarActionModule } from './actions.js';
import { createCrewTypeWarTriggerRegistry } from './crewTypeTriggers.js';
import { WarCrewType } from './crewType.js';
import { WarTriggerCaller, createWarTriggerEnv, type WarTriggerRegistry } from './triggers.js';
import type { WarBattleInput, WarBattleOutcome, WarGeneralInput, WarUnitReport } from './types.js';
@@ -37,22 +39,26 @@ const buildWarCrewTypeIndex = (unitSet: WarBattleInput['unitSet']): Map<number,
};
const createPipeline = <TriggerState extends GeneralTriggerState>(
input: WarGeneralInput<TriggerState>
): WarActionPipeline<TriggerState> => new WarActionPipeline(input.modules ?? []);
input: WarGeneralInput<TriggerState>,
crewTypeModule: WarActionModule<TriggerState>
): WarActionPipeline<TriggerState> => {
const modules = input.modules ?? [];
if (modules.some((module) => module && isCrewTypeWarActionRouter(module))) {
return new WarActionPipeline(modules);
}
return new WarActionPipeline([crewTypeModule, ...modules]);
};
const appendCrewTypeTriggers = (
caller: WarTriggerCaller,
unit: WarUnit,
names: string[],
registry: WarTriggerRegistry | undefined
registry: WarTriggerRegistry
): void => {
if (!registry) {
return;
}
for (const name of names) {
const factory = registry[name];
if (!factory) {
continue;
throw new Error(`Unknown crew type war trigger: ${name}`);
}
const trigger = factory(unit);
if (!trigger) {
@@ -66,24 +72,28 @@ const appendCrewTypeTriggers = (
}
};
const buildBattleInitTriggers = (unit: WarUnit, registry: WarTriggerRegistry | undefined): WarTriggerCaller => {
const buildBattleInitTriggers = (unit: WarUnit, registry: WarTriggerRegistry): WarTriggerCaller => {
const caller = new WarTriggerCaller();
if (unit instanceof WarUnitGeneral) {
const context = unit.getActionContext();
caller.merge(unit.getActionPipeline().getBattleInitTriggerList(context));
} else {
appendCrewTypeTriggers(caller, unit, unit.getCrewType().initSkillTrigger, registry);
}
appendCrewTypeTriggers(caller, unit, unit.getCrewType().initSkillTrigger, registry);
return caller;
};
const buildBattlePhaseTriggers = (unit: WarUnit, registry: WarTriggerRegistry | undefined): WarTriggerCaller => {
const buildBattlePhaseTriggers = (unit: WarUnit, registry: WarTriggerRegistry): WarTriggerCaller => {
const caller = new WarTriggerCaller();
if (unit instanceof WarUnitGeneral) {
appendCrewTypeTriggers(caller, unit, ['che_필살'], registry);
if (registry['che_필살']) {
appendCrewTypeTriggers(caller, unit, ['che_필살'], registry);
}
const context = unit.getActionContext();
caller.merge(unit.getActionPipeline().getBattlePhaseTriggerList(context));
} else {
appendCrewTypeTriggers(caller, unit, unit.getCrewType().phaseSkillTrigger, registry);
}
appendCrewTypeTriggers(caller, unit, unit.getCrewType().phaseSkillTrigger, registry);
return caller;
};
@@ -196,10 +206,14 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
// process_war.php 전투 루프를 순수 로직으로 이식한다.
const rng = input.rng ?? new RandUtil(LiteHashDRBG.build(input.seed ?? ''));
const loggerFactory = input.loggerFactory ?? defaultLoggerFactory;
const triggerRegistry = input.triggerRegistry;
const triggerRegistry: WarTriggerRegistry = {
...createCrewTypeWarTriggerRegistry(),
...(input.triggerRegistry ?? {}),
};
const crewTypeCatalog = compileCrewTypeCatalog(input.unitSet, triggerRegistry);
const crewTypeIndex = buildWarCrewTypeIndex(input.unitSet);
const attackerPipeline = createPipeline(input.attacker);
const attackerPipeline = createPipeline(input.attacker, crewTypeCatalog.warActionModule);
const attackerLogger =
input.attacker.logger ??
loggerFactory({
@@ -251,7 +265,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
false,
resolveCrewType(crewTypeIndex, defender.general.crewTypeId),
defenderLogger,
createPipeline(defender)
createPipeline(defender, crewTypeCatalog.warActionModule)
);
if (computeBattleOrder(unit, attackerUnit) <= 0) {
continue;
@@ -601,9 +615,14 @@ export const resolveDefenderOrder = <TriggerState extends GeneralTriggerState =
): number[] => {
const rng = input.rng ?? new RandUtil(LiteHashDRBG.build(input.seed ?? ''));
const loggerFactory = input.loggerFactory ?? defaultLoggerFactory;
const triggerRegistry: WarTriggerRegistry = {
...createCrewTypeWarTriggerRegistry(),
...(input.triggerRegistry ?? {}),
};
const crewTypeCatalog = compileCrewTypeCatalog(input.unitSet, triggerRegistry);
const crewTypeIndex = buildWarCrewTypeIndex(input.unitSet);
const attackerPipeline = createPipeline(input.attacker);
const attackerPipeline = createPipeline(input.attacker, crewTypeCatalog.warActionModule);
const attackerLogger =
input.attacker.logger ??
loggerFactory({
@@ -640,7 +659,7 @@ export const resolveDefenderOrder = <TriggerState extends GeneralTriggerState =
false,
resolveCrewType(crewTypeIndex, defender.general.crewTypeId),
defenderLogger,
createPipeline(defender)
createPipeline(defender, crewTypeCatalog.warActionModule)
);
if (computeBattleOrder(unit, attackerUnit) <= 0) {
continue;
+1
View File
@@ -6,4 +6,5 @@ export * from './units.js';
export * from './triggers.js';
export * from './triggers/index.js';
export * from './crewType.js';
export * from './crewTypeTriggers.js';
export * from './utils.js';
@@ -0,0 +1,22 @@
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js';
import { WarUnitCity, type WarUnit } from '@sammo-ts/logic/war/units.js';
export class che_기병병종전투 extends BaseWarUnitTrigger {
constructor(unit: WarUnit) {
super(unit, TriggerPriority.Final + 100);
}
protected actionWar(self: WarUnit, oppose: WarUnit): boolean {
if (!self.isAttacker()) {
oppose.multiplyWarPowerMultiply(1.02);
self.multiplyWarPowerMultiply(0.97);
} else if (oppose instanceof WarUnitCity) {
self.multiplyWarPowerMultiply(0.9);
} else {
oppose.multiplyWarPowerMultiply(0.97);
self.multiplyWarPowerMultiply(1.02);
}
return true;
}
}
@@ -0,0 +1,16 @@
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js';
import type { WarUnit } from '@sammo-ts/logic/war/units.js';
export class che_방어력증가5p extends BaseWarUnitTrigger {
constructor(unit: WarUnit) {
super(unit, TriggerPriority.Final + 200);
}
protected actionWar(self: WarUnit, oppose: WarUnit): boolean {
if (!self.isAttacker()) {
oppose.multiplyWarPowerMultiply(1 / 1.05);
}
return true;
}
}
@@ -0,0 +1,55 @@
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js';
import type { WarUnit } from '@sammo-ts/logic/war/units.js';
export class che_선제사격시도 extends BaseWarUnitTrigger {
constructor(unit: WarUnit) {
super(unit, TriggerPriority.Begin + 50);
}
protected actionWar(self: WarUnit, oppose: WarUnit): boolean {
if (self.getPhase() !== 0 && oppose.getPhase() !== 0) {
return true;
}
if (self.hasActivatedSkill('선제') || self.hasActivatedSkillOnLog('선제')) {
return true;
}
self.activateSkill('특수', '선제');
return true;
}
}
export class che_선제사격발동 extends BaseWarUnitTrigger {
constructor(unit: WarUnit) {
super(unit, TriggerPriority.Begin + 51);
}
protected actionWar(self: WarUnit, oppose: WarUnit): boolean {
if (!self.hasActivatedSkill('선제')) {
return true;
}
if (oppose.hasActivatedSkill('선제') && oppose.isAttacker()) {
return true;
}
self.addPhase(-1);
oppose.addPhase(-1);
if (oppose.hasActivatedSkill('선제')) {
self.multiplyWarPowerMultiply(2 / 3);
oppose.multiplyWarPowerMultiply(2 / 3);
oppose.getLogger().pushGeneralBattleDetailLog('서로 <C>선제 사격</>을 주고 받았다!</>', LogFormat.PLAIN);
self.getLogger().pushGeneralBattleDetailLog('서로 <C>선제 사격</>을 주고 받았다!</>', LogFormat.PLAIN);
return true;
}
oppose.multiplyWarPowerMultiply(0);
self.multiplyWarPowerMultiply(2 / 3);
self.activateSkill('회피불가', '필살불가', '계략불가');
oppose.activateSkill('회피불가', '필살불가', '격노불가', '계략불가');
oppose.getLogger().pushGeneralBattleDetailLog('상대에게 <R>선제 사격</>을 받았다!</>', LogFormat.PLAIN);
self.getLogger().pushGeneralBattleDetailLog('상대에게 <C>선제 사격</>을 했다!</>', LogFormat.PLAIN);
return true;
}
}
@@ -0,0 +1,17 @@
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js';
import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
export class che_성벽부상무효 extends BaseWarUnitTrigger {
constructor(unit: WarUnit) {
super(unit, TriggerPriority.Begin + 150);
}
protected actionWar(self: WarUnit, oppose: WarUnit): boolean {
if (!(self instanceof WarUnitGeneral) || !(oppose instanceof WarUnitCity)) {
return true;
}
self.activateSkill('부상무효');
return true;
}
}
+13 -3
View File
@@ -7,8 +7,18 @@ export class che_저지_시도 extends BaseWarUnitTrigger {
constructor(unit: WarUnit, raiseType: number = 0) {
super(unit, TriggerPriority.Pre, raiseType);
}
protected actionWar(u: WarUnit): boolean {
u.activateSkill('특수', '저지');
protected actionWar(self: WarUnit): boolean {
if (!(self instanceof WarUnitGeneral) || self.isAttacker()) {
return true;
}
if (self.hasActivatedSkill('특수') || self.hasActivatedSkill('저지불가')) {
return true;
}
const ratio = self.getComputedAtmos() + self.getComputedTrain();
if (self.rng.nextBool(ratio / 400)) {
self.activateSkill('특수', '저지');
}
return true;
}
}
@@ -40,7 +50,7 @@ export class che_저지 extends BaseWarUnitTrigger {
}
self.getLogger().pushGeneralBattleDetailLog('상대를 <C>저지</>했다!', LogFormat.PLAIN);
oppose.getLogger().pushGeneralBattleDetailLog('<R>저지</>당했다!', LogFormat.PLAIN);
oppose.getLogger().pushGeneralBattleDetailLog('저지</>당했다!', LogFormat.PLAIN);
const calcDamage = oppose.getWarPower() * 0.9;
if (self instanceof WarUnitGeneral) {
+9 -8
View File
@@ -1,13 +1,6 @@
import type { City, General, Nation } from '@sammo-ts/logic/domain/entities.js';
import type { CrewTypeDefinition, CrewTypeRequirement, MapDefinition, UnitSetDefinition } from './types.js';
import {
asNullableStringArray,
asNumber,
asRecord,
asString,
asStringArray,
isRecord,
} from '@sammo-ts/common';
import { asNullableStringArray, asNumber, asRecord, asString, asStringArray, isRecord } from '@sammo-ts/common';
import { UnitSetDefinitionInputSchema } from '../resources/unitSetSchema.js';
const DEFAULT_REGION_MAP: Record<string, number> = {
@@ -181,6 +174,14 @@ export const getTechAbility = (tech: number): number => getTechLevel(tech) * 25;
export const getTechCost = (tech: number): number => 1 + getTechLevel(tech) * 0.15;
export const getCrewTypePickScore = (crewType: CrewTypeDefinition, tech: number, armPerPhase: number): number => {
let score = armPerPhase + crewType.attack + crewType.defence + getTechAbility(tech) * 2;
score *= 1 + crewType.speed / 2;
score /= Math.max(1 - crewType.avoid / 100, 0.1);
score *= 1 + crewType.magicCoef / 2;
return score;
};
export interface CrewTypeAvailabilityContext {
general: General;
nation: Nation | null;
@@ -0,0 +1,365 @@
import { readdir, readFile } from 'node:fs/promises';
import { ConstantRNG, RandUtil } from '@sammo-ts/common';
import { describe, expect, it } from 'vitest';
import { compileCrewTypeCatalog } from '../src/crewType/catalog.js';
import { ActionLogger } from '../src/logging/actionLogger.js';
import type { City, General, Nation } from '../src/domain/entities.js';
import { WarActionPipeline } from '../src/war/actions.js';
import { WarCrewType } from '../src/war/crewType.js';
import { createCrewTypeWarTriggerRegistry } from '../src/war/crewTypeTriggers.js';
import { computeBattleOrder, resolveWarBattle } from '../src/war/engine.js';
import { createWarTriggerEnv, WarTriggerCaller } from '../src/war/triggers.js';
import type { WarEngineConfig } from '../src/war/types.js';
import { WarUnitCity, WarUnitGeneral, type WarUnit } from '../src/war/units.js';
import { getCrewTypePickScore, parseUnitSetDefinition } from '../src/world/unitSet.js';
import type { CrewTypeDefinition, UnitSetDefinition } from '../src/world/types.js';
const config: WarEngineConfig = {
armPerPhase: 500,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
maxTrainByWar: 110,
maxAtmosByWar: 150,
castleCrewTypeId: 1000,
armTypes: {
footman: 1,
archer: 2,
cavalry: 3,
wizard: 4,
siege: 5,
misc: 6,
castle: 0,
},
};
const nation: Nation = {
id: 1,
name: '테스트국',
color: '#000000',
capitalCityId: 1,
chiefGeneralId: null,
gold: 10000,
rice: 10000,
power: 0,
level: 1,
typeCode: 'test',
meta: { tech: 3000 },
};
const city: City = {
id: 1,
name: '테스트성',
nationId: 1,
level: 1,
state: 0,
population: 10000,
populationMax: 10000,
agriculture: 500,
agricultureMax: 1000,
commerce: 500,
commerceMax: 1000,
security: 500,
securityMax: 1000,
defence: 100,
defenceMax: 1000,
wall: 1000,
wallMax: 1000,
supplyState: 1,
frontState: 0,
meta: {},
};
const crewType = (
id: number,
armType: number,
name: string,
options: Partial<CrewTypeDefinition> = {}
): CrewTypeDefinition => ({
id,
armType,
name,
attack: 100,
defence: 100,
speed: 7,
avoid: 10,
magicCoef: 0,
cost: 10,
rice: 10,
requirements: [],
attackCoef: {},
defenceCoef: {},
info: [],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
...options,
});
const buildGeneral = (id: number, crewTypeId: number): General => ({
id,
name: `장수${id}`,
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 80, strength: 80, intelligence: 80 },
experience: 0,
dedication: 0,
officerLevel: 3,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 1000,
rice: 10000,
crew: 1000,
crewTypeId,
train: 100,
atmos: 100,
age: 20,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24, dex1: 1000, dex2: 1000, dex3: 1000, dex5: 1000 },
});
const buildGeneralUnit = (
rng: RandUtil,
general: General,
definition: CrewTypeDefinition,
attacker: boolean,
modules: WarActionPipeline = new WarActionPipeline([])
) =>
new WarUnitGeneral(
rng,
config,
general,
city,
nation,
attacker,
new WarCrewType(definition),
new ActionLogger({ generalId: general.id, nationId: general.nationId }),
modules
);
const fireTriggers = (keys: string[], self: WarUnit, attacker: WarUnit, defender: WarUnit): void => {
const registry = createCrewTypeWarTriggerRegistry();
const caller = new WarTriggerCaller();
for (const key of keys) {
const trigger = registry[key]?.(self);
if (!trigger) {
throw new Error(`Missing trigger: ${key}`);
}
if (trigger instanceof WarTriggerCaller) {
caller.merge(trigger);
} else {
caller.append(trigger);
}
}
caller.fire({ rng: self.rng, attacker, defender }, createWarTriggerEnv());
};
describe('crew type catalog', () => {
it('compiles every shipped unit set and resolves all crew handlers', async () => {
const unitSetDirectory = new URL('../../../resources/unitset/', import.meta.url);
const fileNames = (await readdir(unitSetDirectory)).filter((fileName) => fileName.endsWith('.json'));
for (const fileName of fileNames) {
const raw = JSON.parse(await readFile(new URL(fileName, unitSetDirectory), 'utf8')) as unknown;
const unitSet = parseUnitSetDefinition(raw);
const catalog = compileCrewTypeCatalog(unitSet, createCrewTypeWarTriggerRegistry());
expect(catalog.byId.size, fileName).toBe(unitSet.crewTypes?.length);
}
const raw = JSON.parse(await readFile(new URL('unitset_che.json', unitSetDirectory), 'utf8')) as unknown;
const cheCatalog = compileCrewTypeCatalog(parseUnitSetDefinition(raw), createCrewTypeWarTriggerRegistry());
expect(cheCatalog.byId.get(1500)?.actions.map((action) => action.key)).toEqual(['che_성벽선제']);
});
it('fails fast for unresolved crew actions and war triggers', () => {
const base: UnitSetDefinition = {
id: 'invalid',
name: 'invalid',
defaultCrewTypeId: 1100,
crewTypes: [
crewType(1100, 1, '보병', {
iActionList: ['missing_action'],
phaseSkillTrigger: ['missing_trigger'],
}),
],
};
expect(() => compileCrewTypeCatalog(base, createCrewTypeWarTriggerRegistry())).toThrow(
'Unknown crew type action'
);
base.crewTypes![0]!.iActionList = null;
expect(() => compileCrewTypeCatalog(base, createCrewTypeWarTriggerRegistry())).toThrow(
'Unknown crew type war trigger'
);
});
it('routes 정란의 성벽 우선 action through the war pipeline', () => {
const tower = crewType(1500, 5, '정란', { iActionList: ['che_성벽선제'] });
const wall = crewType(1000, 0, '성벽');
const unitSet: UnitSetDefinition = {
id: 'tower',
name: 'tower',
defaultCrewTypeId: tower.id,
crewTypes: [wall, tower],
};
const catalog = compileCrewTypeCatalog(unitSet, createCrewTypeWarTriggerRegistry());
const rng = new RandUtil(new ConstantRNG(0));
const attacker = buildGeneralUnit(
rng,
buildGeneral(1, tower.id),
tower,
true,
new WarActionPipeline([catalog.warActionModule])
);
const defender = new WarUnitCity(
rng,
config,
city,
nation,
new WarCrewType(wall),
new ActionLogger({ nationId: nation.id }),
200,
180
);
expect(computeBattleOrder(defender, attacker)).toBe(10000);
});
});
describe('crew type war triggers', () => {
it('loads crew triggers automatically in the live battle engine', () => {
const archer = crewType(1200, 2, '궁병', {
phaseSkillTrigger: ['che_선제사격시도', 'che_선제사격발동'],
});
const footman = crewType(1100, 1, '보병');
const wall = crewType(1000, 0, '성벽');
const unitSet: UnitSetDefinition = {
id: 'live-engine',
name: 'live-engine',
defaultCrewTypeId: footman.id,
crewTypes: [wall, footman, archer],
};
const attacker = buildGeneral(1, archer.id);
const defender = buildGeneral(2, footman.id);
const outcome = resolveWarBattle({
rng: new RandUtil(new ConstantRNG(0)),
unitSet,
config,
time: { year: 200, month: 1, startYear: 180 },
attacker: { general: attacker, city, nation },
defenders: [{ general: defender, city, nation }],
defenderCity: city,
defenderNation: nation,
});
expect(outcome.metrics?.attackerActivatedSkills['선제']).toBe(1);
});
it('activates wound immunity only for a general fighting a city wall', () => {
const siege = crewType(1501, 5, '충차');
const wall = crewType(1000, 0, '성벽');
const rng = new RandUtil(new ConstantRNG(0));
const attacker = buildGeneralUnit(rng, buildGeneral(1, siege.id), siege, true);
const defender = new WarUnitCity(
rng,
config,
city,
nation,
new WarCrewType(wall),
new ActionLogger({ nationId: nation.id }),
200,
180
);
attacker.setOppose(defender);
defender.setOppose(attacker);
fireTriggers(['che_성벽부상무효'], attacker, attacker, defender);
expect(attacker.hasActivatedSkill('부상무효')).toBe(true);
});
it('applies cavalry and footman end-of-phase multipliers', () => {
const cavalry = crewType(1300, 3, '기병');
const footman = crewType(1100, 1, '보병');
const rng = new RandUtil(new ConstantRNG(0));
const attacker = buildGeneralUnit(rng, buildGeneral(1, cavalry.id), cavalry, true);
const defender = buildGeneralUnit(rng, buildGeneral(2, footman.id), footman, false);
attacker.setOppose(defender);
defender.setOppose(attacker);
fireTriggers(['che_기병병종전투'], attacker, attacker, defender);
fireTriggers(['che_방어력증가5p'], defender, attacker, defender);
expect(attacker.getWarPowerMultiply()).toBeCloseTo(1.02 / 1.05);
expect(defender.getWarPowerMultiply()).toBeCloseTo(0.97);
});
it('executes one-sided preemptive fire once and suppresses the opponent', () => {
const archer = crewType(1200, 2, '궁병');
const footman = crewType(1100, 1, '보병');
const rng = new RandUtil(new ConstantRNG(0));
const attacker = buildGeneralUnit(rng, buildGeneral(1, archer.id), archer, true);
const defender = buildGeneralUnit(rng, buildGeneral(2, footman.id), footman, false);
attacker.setOppose(defender);
defender.setOppose(attacker);
fireTriggers(['che_선제사격시도', 'che_선제사격발동'], attacker, attacker, defender);
expect(attacker.getPhase()).toBe(-1);
expect(defender.getPhase()).toBe(-1);
expect(attacker.getWarPowerMultiply()).toBeCloseTo(2 / 3);
expect(defender.getWarPowerMultiply()).toBe(0);
expect(attacker.hasActivatedSkill('선제')).toBe(true);
expect(defender.hasActivatedSkill('회피불가')).toBe(true);
});
it('uses the legacy stop probability and never lets an attacker initiate 저지', () => {
const ram = crewType(1503, 5, '목우');
const footman = crewType(1100, 1, '보병');
const rng = new RandUtil(new ConstantRNG(0));
const attacker = buildGeneralUnit(rng, buildGeneral(1, footman.id), footman, true);
const defender = buildGeneralUnit(rng, buildGeneral(2, ram.id), ram, false);
attacker.setOppose(defender);
defender.setOppose(attacker);
fireTriggers(['che_저지시도'], attacker, attacker, defender);
expect(attacker.hasActivatedSkill('저지')).toBe(false);
fireTriggers(['che_저지시도', 'che_저지발동'], defender, attacker, defender);
expect(defender.hasActivatedSkill('저지')).toBe(true);
expect(attacker.getWarPowerMultiply()).toBe(0);
expect(defender.getWarPowerMultiply()).toBe(0);
const missRng = new RandUtil(new ConstantRNG(1));
const missedAttacker = buildGeneralUnit(missRng, buildGeneral(3, footman.id), footman, true);
const missedDefender = buildGeneralUnit(missRng, buildGeneral(4, ram.id), ram, false);
missedAttacker.setOppose(missedDefender);
missedDefender.setOppose(missedAttacker);
fireTriggers(['che_저지시도', 'che_저지발동'], missedDefender, missedAttacker, missedDefender);
expect(missedDefender.hasActivatedSkill('저지')).toBe(false);
});
});
describe('crew type numeric policy', () => {
it('matches the legacy pickScore formula including magicCoef', () => {
const wizard = crewType(1400, 4, '귀병', {
attack: 80,
defence: 80,
speed: 7,
avoid: 5,
magicCoef: 0.5,
});
const expected = ((500 + 80 + 80 + 75 * 2) * (1 + 7 / 2) * (1 + 0.5 / 2)) / (1 - 0.05);
expect(getCrewTypePickScore(wizard, 3000, 500)).toBeCloseTo(expected);
});
});