feat: apply traits in live turn runtime

This commit is contained in:
2026-07-25 08:14:25 +00:00
parent 790f4915ab
commit 88afcb5465
24 changed files with 475 additions and 81 deletions
+2 -29
View File
@@ -15,6 +15,7 @@ import {
loadItemModules,
createInheritBuffModules,
createTraitModuleRegistry,
createOfficerLevelActionModules,
DOMESTIC_TRAIT_KEYS,
loadDomesticTraitModules,
loadNationTraitModules,
@@ -32,7 +33,6 @@ import {
type UnitSetDefinition,
type WarBattleOutcome,
type WarActionModule,
type WarActionContext,
type WarUnitReport,
type WarBattleTraceEvent,
type CrewTypeDefinition,
@@ -56,34 +56,7 @@ const traitRegistry = createTraitModuleRegistry({
});
const traitWarModules: WarActionModule[] = [
new TraitWarActionRouter('nation', traitRegistry),
{
onCalcStat: (context: WarActionContext, statName, value) => {
if (statName !== 'leadership' || typeof value !== 'number') {
return value;
}
let officerLevel = context.general.officerLevel;
// Legacy simulator payload omits general.city, so low city offices are
// always downgraded by TriggerOfficerLevel's officer_city comparison.
if (officerLevel >= 2 && officerLevel <= 4) {
officerLevel = 1;
}
const nationLevel = context.nation?.level ?? 0;
const leadershipBonus = officerLevel === 12 ? nationLevel * 2 : officerLevel >= 5 ? nationLevel : 0;
return value + leadershipBonus;
},
getWarPowerMultiplier: (context: WarActionContext) => {
let officerLevel = context.general.officerLevel;
if (officerLevel >= 2 && officerLevel <= 4) {
officerLevel = 1;
}
if (officerLevel === 12) return [1.07, 0.93];
if (officerLevel === 11) return [1.05, 0.95];
if ([10, 8, 6].includes(officerLevel)) return [1.1, 1];
if ([9, 7, 5].includes(officerLevel)) return [1, 0.9];
if ([4, 3, 2].includes(officerLevel)) return [1.05, 0.95];
return [1, 1];
},
},
createOfficerLevelActionModules().war,
new TraitWarActionRouter('domestic', traitRegistry),
new TraitWarActionRouter('war', traitRegistry),
new TraitWarActionRouter('personality', traitRegistry),
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
import { authedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, mapGeneralList, resolveChiefStatMin } from '../shared.js';
import { assertNationAccess, loadTraitNames, mapGeneralList, resolveChiefStatMin } from '../shared.js';
export const getGeneralList = authedProcedure.query(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
@@ -62,6 +62,7 @@ export const getGeneralList = authedProcedure.query(async ({ ctx }) => {
const cityNameMap = new Map(cityRows.map((city) => [city.id, city.name]));
const troopNameMap = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name]));
const list = await mapGeneralList(generalRows, cityNameMap, troopNameMap);
const nationTrait = (await loadTraitNames([nation.typeCode], 'nation')).get(nation.typeCode);
return {
nation: {
@@ -70,6 +71,11 @@ export const getGeneralList = authedProcedure.query(async ({ ctx }) => {
color: nation.color,
level: nation.level,
typeCode: nation.typeCode,
type: {
key: nation.typeCode,
name: nationTrait?.name ?? nation.typeCode,
info: nationTrait?.info ?? '',
},
capitalCityId: nation.capitalCityId ?? 0,
},
chiefStatMin: resolveChiefStatMin(worldState),
+3
View File
@@ -498,18 +498,21 @@ export const mapGeneralList = async (
? {
key: personalityKey,
name: personalityMap.get(personalityKey)?.name ?? personalityKey,
info: personalityMap.get(personalityKey)?.info ?? '',
}
: null,
specialDomestic: domesticKey
? {
key: domesticKey,
name: domesticMap.get(domesticKey)?.name ?? domesticKey,
info: domesticMap.get(domesticKey)?.info ?? '',
}
: null,
specialWar: warKey
? {
key: warKey,
name: warMap.get(warKey)?.name ?? warKey,
info: warMap.get(warKey)?.info ?? '',
}
: null,
belong,
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { loadTraitNames, mapGeneralList, type GeneralListRow } from '../src/router/nation/shared.js';
describe('nation trait information', () => {
it('returns ref-compatible names and descriptions for nation and general traits', async () => {
const row: GeneralListRow = {
id: 1,
name: '특성장수',
npcState: 0,
nationId: 1,
cityId: 1,
troopId: 0,
officerLevel: 1,
leadership: 80,
strength: 70,
intel: 60,
experience: 0,
dedication: 0,
injury: 0,
gold: 1000,
rice: 1000,
crew: 1000,
personalCode: 'che_패권',
specialCode: 'che_상재',
special2Code: 'che_기병',
meta: {},
penalty: {},
};
const [general] = await mapGeneralList([row], new Map([[1, '도시']]), new Map());
expect(general?.personality).toEqual({
key: 'che_패권',
name: '패권',
info: '훈련 +5, 징·모병 비용 +20%',
});
expect(general?.specialDomestic?.info).toContain('상업 투자');
expect(general?.specialWar?.info).toContain('공격 시 대미지 +20%');
const nation = (await loadTraitNames(['che_유가'], 'nation')).get('che_유가');
expect(nation).toEqual({ name: '유가', info: '농상↑ 민심↑ 쌀수입↓' });
});
});
@@ -10,13 +10,7 @@ import type {
import {
loadGeneralTurnCommandSpecs,
loadNationTurnCommandSpecs,
createItemActionModules,
createItemModuleRegistry,
ITEM_KEYS,
loadItemModules,
createInheritBuffModules,
compileCrewTypeCatalog,
createCrewTypeWarTriggerRegistry,
loadActionModuleBundle,
} from '@sammo-ts/logic';
import { asRecord } from '@sammo-ts/common';
@@ -134,7 +128,8 @@ export const buildReservedTurnDefinitions = async (options: {
general: Map<string, GeneralActionDefinition>;
nation: Map<string, GeneralActionDefinition>;
}> => {
const itemModules = await loadItemModules([...ITEM_KEYS]);
const moduleBundle = await loadActionModuleBundle(options.env.unitSet);
const itemModules = moduleBundle.itemModules;
options.env.itemCatalog = Object.fromEntries(
itemModules.map((item) => [
item.key,
@@ -150,23 +145,13 @@ export const buildReservedTurnDefinitions = async (options: {
},
])
);
const itemRegistry = createItemModuleRegistry(itemModules);
const itemActionModules = createItemActionModules(itemRegistry);
const inheritBuffModules = createInheritBuffModules();
const crewTypeCatalog = options.env.unitSet?.crewTypes?.length
? compileCrewTypeCatalog(options.env.unitSet, createCrewTypeWarTriggerRegistry())
: null;
options.env.generalActionModules = [
...(options.env.generalActionModules ?? []),
...(crewTypeCatalog ? [crewTypeCatalog.generalActionModule] : []),
inheritBuffModules.general,
...itemActionModules.general,
...moduleBundle.general,
];
options.env.warActionModules = [
...(options.env.warActionModules ?? []),
...(crewTypeCatalog ? [crewTypeCatalog.warActionModule] : []),
inheritBuffModules.war,
...itemActionModules.war,
...moduleBundle.war,
];
const generalSpecs = await loadGeneralTurnCommandSpecs(options.commandProfile.general);
+116 -1
View File
@@ -1,4 +1,16 @@
import { WarActionPipeline, type General, type ScenarioConfig, type UnitSetDefinition } from '@sammo-ts/logic';
import {
ActionLogger,
GeneralActionPipeline,
WarActionPipeline,
WarCrewType,
WarUnitCity,
WarUnitGeneral,
type General,
type Nation,
type ScenarioConfig,
type UnitSetDefinition,
} from '@sammo-ts/logic';
import { ConstantRNG, RandUtil } from '@sammo-ts/common';
import { describe, expect, it } from 'vitest';
import { buildCommandEnv, buildReservedTurnDefinitions } from '../src/turn/reservedTurnCommands.js';
@@ -111,6 +123,109 @@ describe('reserved turn crew type wiring', () => {
const pipeline = new WarActionPipeline(env.warActionModules ?? []);
expect(pipeline.onCalcOpposeStat({ general }, 'cityBattleOrder', -1)).toBe(10000);
});
it('installs nation, officer, domestic, war, and personality effects in live commands', async () => {
const env = buildCommandEnv(scenarioConfig, unitSet);
await buildReservedTurnDefinitions({
env,
commandProfile: { general: ['휴식'], nation: ['휴식'] },
defaultActionKey: '휴식',
});
const nation: Nation = {
id: 1,
name: '효과국',
color: '#000000',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 0,
rice: 0,
power: 0,
level: 5,
typeCode: 'che_유가',
meta: {},
};
const traitGeneral: General = {
...general,
officerLevel: 12,
role: {
...general.role,
personality: 'che_패권',
specialDomestic: 'che_상재',
specialWar: 'che_기병',
},
};
const pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
const context = { general: traitGeneral, nation };
expect(pipeline.onCalcDomestic(context, '상업', 'score', 100)).toBeCloseTo(127.05);
expect(pipeline.onCalcDomestic(context, '상업', 'cost', 100)).toBeCloseTo(64);
expect(pipeline.onCalcDomestic(context, '징병', 'cost', 100, { armType: 3 })).toBeCloseTo(108);
expect(pipeline.onCalcStat(context, 'leadership', 80)).toBe(90);
const rng = new RandUtil(new ConstantRNG(0));
const warConfig = {
armPerPhase: 500,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
maxTrainByWar: 110,
maxAtmosByWar: 150,
castleCrewTypeId: 1000,
armTypes: { footman: 1, cavalry: 3, castle: 0 },
};
const warPipeline = new WarActionPipeline(env.warActionModules ?? []);
const battleCity = {
id: 1,
name: '출병지',
nationId: 1,
level: 5,
state: 0,
population: 10000,
populationMax: 10000,
agriculture: 0,
agricultureMax: 0,
commerce: 0,
commerceMax: 0,
security: 0,
securityMax: 0,
supplyState: 1,
frontState: 0,
defence: 100,
defenceMax: 100,
wall: 100,
wallMax: 100,
meta: {},
};
const attacker = new WarUnitGeneral(
rng,
warConfig,
traitGeneral,
battleCity,
nation,
true,
new WarCrewType(unitSet.crewTypes![0]!),
new ActionLogger({ generalId: 1, nationId: 1 }),
warPipeline
);
const defender = new WarUnitCity(
rng,
warConfig,
battleCity,
nation,
new WarCrewType({
...unitSet.crewTypes![0]!,
id: 1000,
armType: 0,
name: '성벽',
}),
new ActionLogger({}),
100,
100
);
expect(
warPipeline.getWarPowerMultiplier(attacker.getActionContext(), attacker, defender)
).toEqual([1.284, 0.93]);
expect(warPipeline.onCalcStat(attacker.getActionContext(), 'bonusTrain', 100)).toBe(105);
});
});
describe('NPC crew type selection', () => {
@@ -0,0 +1,78 @@
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-action.js';
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
import { createOfficerLevelActionModules } from './officerLevel.js';
import {
createTraitModuleRegistry,
DOMESTIC_TRAIT_KEYS,
loadDomesticTraitModules,
loadNationTraitModules,
loadPersonalityTraitModules,
loadWarTraitModules,
NATION_TRAIT_KEYS,
PERSONALITY_TRAIT_KEYS,
TraitGeneralActionRouter,
TraitWarActionRouter,
WAR_TRAIT_KEYS,
} from './special/index.js';
export interface ActionModuleBundle<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
general: GeneralActionModule<TriggerState>[];
war: WarActionModule<TriggerState>[];
itemModules: ItemModule<TriggerState>[];
}
// 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 traitRegistry = createTraitModuleRegistry<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: [
new TraitGeneralActionRouter('nation', traitRegistry),
officer.general,
new TraitGeneralActionRouter('domestic', traitRegistry),
new TraitGeneralActionRouter('war', traitRegistry),
new TraitGeneralActionRouter('personality', traitRegistry),
...(crewTypeCatalog ? [crewTypeCatalog.generalActionModule] : []),
inherit.general as GeneralActionModule<TriggerState>,
...items.general,
],
war: [
new TraitWarActionRouter('nation', traitRegistry),
officer.war,
new TraitWarActionRouter('domestic', traitRegistry),
new TraitWarActionRouter('war', traitRegistry),
new TraitWarActionRouter('personality', traitRegistry),
...(crewTypeCatalog ? [crewTypeCatalog.warActionModule] : []),
inherit.war as WarActionModule<TriggerState>,
...items.war,
],
itemModules,
};
};
+2
View File
@@ -2,4 +2,6 @@ export * from './core.js';
export * from './general.js';
export * from './general-action.js';
export * from './types.js';
export * from './officerLevel.js';
export * from './actionModuleBundle.js';
export * from './special/index.js';
@@ -0,0 +1,76 @@
import { asRecord } from '@sammo-ts/common';
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { GeneralActionModule } from './general-action.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>,
});
@@ -4,10 +4,10 @@ import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
export const traitModule: TraitModule = {
key: 'che_덕가',
name: '덕가',
info: '장점: 치안↑ 인구↑ 민심↑ / 단점: 쌀수입↓ 수성↓',
info: '치안↑ 인구↑ 민심↑ 쌀수입↓ 수성↓',
kind: 'nation',
getName: () => '덕가',
getInfo: () => '장점: 치안↑ 인구↑ 민심↑ / 단점: 쌀수입↓ 수성↓',
getInfo: () => '치안↑ 인구↑ 민심↑ 쌀수입↓ 수성↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '치안' || turnType === '민심' || turnType === '인구') {
if (varType === 'score') return value * 1.1;
@@ -4,10 +4,10 @@ import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
export const traitModule: TraitModule = {
key: 'che_도가',
name: '도가',
info: '장점: 인구↑ / 단점: 기술↓ 치안↓',
info: '인구↑ 기술↓ 치안↓',
kind: 'nation',
getName: () => '도가',
getInfo: () => '장점: 인구↑ / 단점: 기술↓ 치안↓',
getInfo: () => '인구↑ 기술↓ 치안↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '기술' || turnType === '치안') {
if (varType === 'score') return value * 0.9;
@@ -4,10 +4,10 @@ import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
export const traitModule: TraitModule = {
key: 'che_도적',
name: '도적',
info: '장점: 계략↑ / 단점: 금수입↓ 치안↓ 민심↓',
info: '계략↑ 금수입↓ 치안↓ 민심↓',
kind: 'nation',
getName: () => '도적',
getInfo: () => '장점: 계략↑ / 단점: 금수입↓ 치안↓ 민심↓',
getInfo: () => '계략↑ 금수입↓ 치안↓ 민심↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '치안' || turnType === '민심' || turnType === '인구') {
if (varType === 'score') return value * 0.9;
@@ -4,10 +4,10 @@ import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
export const traitModule: TraitModule = {
key: 'che_명가',
name: '명가',
info: '장점: 기술↑ 인구↑ / 단점: 쌀수입↓ 수성↓',
info: '기술↑ 인구↑ 쌀수입↓ 수성↓',
kind: 'nation',
getName: () => '명가',
getInfo: () => '장점: 기술↑ 인구↑ / 단점: 쌀수입↓ 수성↓',
getInfo: () => '기술↑ 인구↑ 쌀수입↓ 수성↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '기술') {
if (varType === 'score') return value * 1.1;
@@ -4,10 +4,10 @@ import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
export const traitModule: TraitModule = {
key: 'che_묵가',
name: '묵가',
info: '장점: 수성↑ / 단점: 기술↓',
info: '수성↑ 기술↓',
kind: 'nation',
getName: () => '묵가',
getInfo: () => '장점: 수성↑ / 단점: 기술↓',
getInfo: () => '수성↑ 기술↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '수비' || turnType === '성벽') {
if (varType === 'score') return value * 1.1;
@@ -4,10 +4,10 @@ import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
export const traitModule: TraitModule = {
key: 'che_법가',
name: '법가',
info: '장점: 금수입↑ 치안↑ / 단점: 인구↓ 민심↓',
info: '금수입↑ 치안↑ 인구↓ 민심↓',
kind: 'nation',
getName: () => '법가',
getInfo: () => '장점: 금수입↑ 치안↑ / 단점: 인구↓ 민심↓',
getInfo: () => '금수입↑ 치안↑ 인구↓ 민심↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '치안') {
if (varType === 'score') return value * 1.1;
@@ -4,10 +4,10 @@ import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
export const traitModule: TraitModule = {
key: 'che_병가',
name: '병가',
info: '장점: 기술↑ 수성↑ / 단점: 인구↓ 민심↓',
info: '기술↑ 수성↑ 인구↓ 민심↓',
kind: 'nation',
getName: () => '병가',
getInfo: () => '장점: 기술↑ 수성↑ / 단점: 인구↓ 민심↓',
getInfo: () => '기술↑ 수성↑ 인구↓ 민심↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '기술' || turnType === '수비' || turnType === '성벽') {
if (varType === 'score') return value * 1.1;
@@ -4,10 +4,10 @@ import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
export const traitModule: TraitModule = {
key: 'che_불가',
name: '불가',
info: '장점: 민심↑ 수성↑ / 단점: 금수입↓',
info: '민심↑ 수성↑ 금수입↓',
kind: 'nation',
getName: () => '불가',
getInfo: () => '장점: 민심↑ 수성↑ / 단점: 금수입↓',
getInfo: () => '민심↑ 수성↑ 금수입↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '민심' || turnType === '인구' || turnType === '수비' || turnType === '성벽') {
if (varType === 'score') return value * 1.1;
@@ -4,10 +4,10 @@ import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
export const traitModule: TraitModule = {
key: 'che_오두미도',
name: '오두미도',
info: '장점: 쌀수입↑ 인구↑ / 단점: 기술↓ 수성↓ 농상↓',
info: '쌀수입↑ 인구↑ 기술↓ 수성↓ 농상↓',
kind: 'nation',
getName: () => '오두미도',
getInfo: () => '장점: 쌀수입↑ 인구↑ / 단점: 기술↓ 수성↓ 농상↓',
getInfo: () => '쌀수입↑ 인구↑ 기술↓ 수성↓ 농상↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (
turnType === '기술' ||
@@ -4,10 +4,10 @@ import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
export const traitModule: TraitModule = {
key: 'che_유가',
name: '유가',
info: '장점: 농상↑ 민심↑ / 단점: 쌀수입↓',
info: '농상↑ 민심↑ 쌀수입↓',
kind: 'nation',
getName: () => '유가',
getInfo: () => '장점: 농상↑ 민심↑ / 단점: 쌀수입↓',
getInfo: () => '농상↑ 민심↑ 쌀수입↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '농업' || turnType === '상업' || turnType === '민심' || turnType === '인구') {
if (varType === 'score') return value * 1.1;
@@ -4,10 +4,10 @@ import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
export const traitModule: TraitModule = {
key: 'che_음양가',
name: '음양가',
info: '장점: 농상↑ 인구↑ / 단점: 기술↓ 전략↓',
info: '농상↑ 인구↑ 기술↓ 전략↓',
kind: 'nation',
getName: () => '음양가',
getInfo: () => '장점: 농상↑ 인구↑ / 단점: 기술↓ 전략↓',
getInfo: () => '농상↑ 인구↑ 기술↓ 전략↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '농업' || turnType === '상업') {
if (varType === 'score') return value * 1.1;
@@ -4,10 +4,10 @@ import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
export const traitModule: TraitModule = {
key: 'che_종횡가',
name: '종횡가',
info: '장점: 전략↑ 수성↑ / 단점: 금수입↓ 농상↓',
info: '전략↑ 수성↑ 금수입↓ 농상↓',
kind: 'nation',
getName: () => '종횡가',
getInfo: () => '장점: 전략↑ 수성↑ / 단점: 금수입↓ 농상↓',
getInfo: () => '전략↑ 수성↑ 금수입↓ 농상↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '수비' || turnType === '성벽') {
if (varType === 'score') return value * 1.1;
@@ -3,9 +3,9 @@ import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
// 국가 성향: 중립
export const traitModule: TraitModule = {
key: 'che_중립',
name: '중립',
info: '장점: 없음 / 단점: 없음',
name: '-',
info: ' ',
kind: 'nation',
getName: () => '중립',
getInfo: () => '장점: 없음 / 단점: 없음',
getName: () => '-',
getInfo: () => ' ',
};
@@ -4,10 +4,10 @@ import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
export const traitModule: TraitModule = {
key: 'che_태평도',
name: '태평도',
info: '장점: 인구↑ 민심↑ / 단점: 기술↓ 수성↓',
info: '인구↑ 민심↑ 기술↓ 수성↓',
kind: 'nation',
getName: () => '태평도',
getInfo: () => '장점: 인구↑ 민심↑ / 단점: 기술↓ 수성↓',
getInfo: () => '인구↑ 민심↑ 기술↓ 수성↓',
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === '민심' || turnType === '인구') {
if (varType === 'score') return value * 1.1;
@@ -5,7 +5,15 @@ import path from 'node:path';
import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
import {
ITEM_KEYS,
DOMESTIC_TRAIT_KEYS,
NATION_TRAIT_KEYS,
PERSONALITY_TRAIT_KEYS,
WAR_TRAIT_KEYS,
loadDomesticTraitModules,
loadItemModules,
loadNationTraitModules,
loadPersonalityTraitModules,
loadWarTraitModules,
type UnitSetDefinition,
type WarBattleTraceEvent,
type WarEngineConfig,
@@ -39,6 +47,11 @@ interface ReferenceItemMetadata {
reqSecu: number;
}
type ReferenceTraitCatalog = Record<
'nation' | 'domestic' | 'war' | 'personality',
Record<string, { name: string; info: string }>
>;
class TracingRng implements RNG {
public readonly calls: RandomCall[] = [];
@@ -124,6 +137,19 @@ const runReferenceItemCatalog = (workspaceRoot: string, itemKeys: string[]): Rec
return JSON.parse(stdout) as Record<string, ReferenceItemMetadata>;
};
const runReferenceTraitCatalog = (workspaceRoot: string): ReferenceTraitCatalog => {
const stdout = execFileSync(
'docker',
['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/trait_catalog.php'],
{
cwd: path.join(workspaceRoot, 'docker_compose_files/reference'),
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
}
);
return JSON.parse(stdout) as ReferenceTraitCatalog;
};
const expectNearlyEqual = (actual: unknown, expected: unknown, label: string): void => {
expect(typeof actual, `${label}: actual type`).toBe('number');
expect(typeof expected, `${label}: reference type`).toBe('number');
@@ -178,6 +204,93 @@ const workspaceRoot = findWorkspaceRoot(process.cwd());
const describeWithReference = workspaceRoot ? describe : describe.skip;
describeWithReference('ref ↔ core2026 battle differential', () => {
it('matches every legacy trait name and description', async () => {
const reference = runReferenceTraitCatalog(workspaceRoot!);
const [nation, domestic, war, personality] = await Promise.all([
loadNationTraitModules([...NATION_TRAIT_KEYS]),
loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS]),
loadWarTraitModules([...WAR_TRAIT_KEYS]),
loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]),
]);
const groups = { nation, domestic, war, personality };
const failures: string[] = [];
for (const [kind, modules] of Object.entries(groups) as Array<
[keyof ReferenceTraitCatalog, Array<{ key: string; name: string; info: string }>]
>) {
expect(modules.map((module) => module.key).sort()).toEqual(Object.keys(reference[kind]).sort());
for (const module of modules) {
const actual = { name: module.name, info: module.info };
if (JSON.stringify(actual) !== JSON.stringify(reference[kind][module.key])) {
failures.push(
`${kind}/${module.key}: core=${JSON.stringify(actual)} ref=${JSON.stringify(reference[kind][module.key])}`
);
}
}
}
expect(failures, failures.join('\n')).toEqual([]);
});
it('matches every war, personality, and nation trait in battle', { timeout: 180_000 }, () => {
const unitSet = readJson<UnitSetDefinition>(
path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json')
);
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 cases = [
...WAR_TRAIT_KEYS.map((key) => ({ kind: 'war' as const, key })),
...PERSONALITY_TRAIT_KEYS.map((key) => ({ kind: 'personality' as const, key })),
...NATION_TRAIT_KEYS.map((key) => ({ kind: 'nation' as const, key })),
];
for (const entry of cases) {
const base = readJson<BattleSimRequestPayload & { startYear: number }>(
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
);
base.seed = `battle-differential-trait-${entry.kind}-${entry.key}`;
base.attackerGeneral.crew = 5000;
base.attackerGeneral.leadership = 90;
base.attackerGeneral.strength = 85;
base.attackerGeneral.intel = 80;
base.attackerGeneral.special2 = entry.kind === 'war' ? entry.key : 'None';
base.attackerGeneral.personal = entry.kind === 'personality' ? entry.key : 'None';
if (entry.kind === 'nation') {
base.attackerNation.type = entry.key;
}
const coreEvents: WarBattleTraceEvent[] = [];
let coreRng: TracingRng | null = null;
processBattleSimJob(
{
...base,
unitSet,
config,
time: { year: base.year, month: base.month, startYear: base.startYear },
},
{
trace: (event) => coreEvents.push(event),
rngFactory: (seed) => {
coreRng = new TracingRng(LiteHashDRBG.build(seed));
return new RandUtil(coreRng);
},
}
);
try {
assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng);
} catch (error) {
throw new Error(`${entry.kind}/${entry.key}: ${error instanceof Error ? error.message : String(error)}`, {
cause: error,
});
}
}
});
it('loads every scenario item with the scenario slot', async () => {
const scenarioDir = path.resolve(process.cwd(), '../../resources/scenario');
const itemSlots = new Map<string, 'horse' | 'weapon' | 'book' | 'item'>();