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', () => {