feat: apply traits in live turn runtime
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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'>();
|
||||
|
||||
Reference in New Issue
Block a user