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', () => {
|
||||
|
||||
Reference in New Issue
Block a user