diff --git a/app/game-api/src/battleSim/processor.ts b/app/game-api/src/battleSim/processor.ts index 168d595..3477d0e 100644 --- a/app/game-api/src/battleSim/processor.ts +++ b/app/game-api/src/battleSim/processor.ts @@ -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), diff --git a/app/game-api/src/router/nation/endpoints/getGeneralList.ts b/app/game-api/src/router/nation/endpoints/getGeneralList.ts index 69199c4..8223e9b 100644 --- a/app/game-api/src/router/nation/endpoints/getGeneralList.ts +++ b/app/game-api/src/router/nation/endpoints/getGeneralList.ts @@ -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), diff --git a/app/game-api/src/router/nation/shared.ts b/app/game-api/src/router/nation/shared.ts index 9c937c1..87c9517 100644 --- a/app/game-api/src/router/nation/shared.ts +++ b/app/game-api/src/router/nation/shared.ts @@ -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, diff --git a/app/game-api/test/nationTraitInfo.test.ts b/app/game-api/test/nationTraitInfo.test.ts new file mode 100644 index 0000000..1e2a2d9 --- /dev/null +++ b/app/game-api/test/nationTraitInfo.test.ts @@ -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: '농상↑ 민심↑ 쌀수입↓' }); + }); +}); diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index 6560ea7..5b0fe96 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -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; nation: Map; }> => { - 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); diff --git a/app/game-engine/test/crewTypeExecution.test.ts b/app/game-engine/test/crewTypeExecution.test.ts index a71ccdb..40205a6 100644 --- a/app/game-engine/test/crewTypeExecution.test.ts +++ b/app/game-engine/test/crewTypeExecution.test.ts @@ -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', () => { diff --git a/packages/logic/src/triggers/actionModuleBundle.ts b/packages/logic/src/triggers/actionModuleBundle.ts new file mode 100644 index 0000000..767b3a6 --- /dev/null +++ b/packages/logic/src/triggers/actionModuleBundle.ts @@ -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 { + general: GeneralActionModule[]; + war: WarActionModule[]; + itemModules: ItemModule[]; +} + +// General::getActionList와 같은 소유권 순서로 실제 턴과 시뮬레이터의 모듈을 조립한다. +export const loadActionModuleBundle = async < + TriggerState extends GeneralTriggerState = GeneralTriggerState, +>(unitSet?: UnitSetDefinition): Promise> => { + 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[]>, + ]); + const traitRegistry = createTraitModuleRegistry({ domestic, war, personality, nation }); + const officer = createOfficerLevelActionModules(); + 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, + ...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, + ...items.war, + ], + itemModules, + }; +}; diff --git a/packages/logic/src/triggers/index.ts b/packages/logic/src/triggers/index.ts index d2116e4..9f734e0 100644 --- a/packages/logic/src/triggers/index.ts +++ b/packages/logic/src/triggers/index.ts @@ -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'; diff --git a/packages/logic/src/triggers/officerLevel.ts b/packages/logic/src/triggers/officerLevel.ts new file mode 100644 index 0000000..8aa495b --- /dev/null +++ b/packages/logic/src/triggers/officerLevel.ts @@ -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 }; +}): 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; + war: WarActionModule; +} => ({ + general: officerGeneralModule as GeneralActionModule, + war: officerWarModule as WarActionModule, +}); diff --git a/packages/logic/src/triggers/special/nation/che_덕가.ts b/packages/logic/src/triggers/special/nation/che_덕가.ts index fa07023..c82fc47 100644 --- a/packages/logic/src/triggers/special/nation/che_덕가.ts +++ b/packages/logic/src/triggers/special/nation/che_덕가.ts @@ -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; diff --git a/packages/logic/src/triggers/special/nation/che_도가.ts b/packages/logic/src/triggers/special/nation/che_도가.ts index 97f5ba6..6062674 100644 --- a/packages/logic/src/triggers/special/nation/che_도가.ts +++ b/packages/logic/src/triggers/special/nation/che_도가.ts @@ -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; diff --git a/packages/logic/src/triggers/special/nation/che_도적.ts b/packages/logic/src/triggers/special/nation/che_도적.ts index 422cd5f..a361fd6 100644 --- a/packages/logic/src/triggers/special/nation/che_도적.ts +++ b/packages/logic/src/triggers/special/nation/che_도적.ts @@ -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; diff --git a/packages/logic/src/triggers/special/nation/che_명가.ts b/packages/logic/src/triggers/special/nation/che_명가.ts index 1518b19..0638ef2 100644 --- a/packages/logic/src/triggers/special/nation/che_명가.ts +++ b/packages/logic/src/triggers/special/nation/che_명가.ts @@ -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; diff --git a/packages/logic/src/triggers/special/nation/che_묵가.ts b/packages/logic/src/triggers/special/nation/che_묵가.ts index 2e9f151..9575874 100644 --- a/packages/logic/src/triggers/special/nation/che_묵가.ts +++ b/packages/logic/src/triggers/special/nation/che_묵가.ts @@ -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; diff --git a/packages/logic/src/triggers/special/nation/che_법가.ts b/packages/logic/src/triggers/special/nation/che_법가.ts index 4cab2de..62a6e80 100644 --- a/packages/logic/src/triggers/special/nation/che_법가.ts +++ b/packages/logic/src/triggers/special/nation/che_법가.ts @@ -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; diff --git a/packages/logic/src/triggers/special/nation/che_병가.ts b/packages/logic/src/triggers/special/nation/che_병가.ts index 528a086..fb7537d 100644 --- a/packages/logic/src/triggers/special/nation/che_병가.ts +++ b/packages/logic/src/triggers/special/nation/che_병가.ts @@ -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; diff --git a/packages/logic/src/triggers/special/nation/che_불가.ts b/packages/logic/src/triggers/special/nation/che_불가.ts index 7ae897f..59b64ea 100644 --- a/packages/logic/src/triggers/special/nation/che_불가.ts +++ b/packages/logic/src/triggers/special/nation/che_불가.ts @@ -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; diff --git a/packages/logic/src/triggers/special/nation/che_오두미도.ts b/packages/logic/src/triggers/special/nation/che_오두미도.ts index d42befe..04b6d12 100644 --- a/packages/logic/src/triggers/special/nation/che_오두미도.ts +++ b/packages/logic/src/triggers/special/nation/che_오두미도.ts @@ -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 === '기술' || diff --git a/packages/logic/src/triggers/special/nation/che_유가.ts b/packages/logic/src/triggers/special/nation/che_유가.ts index 29899fb..dd91eff 100644 --- a/packages/logic/src/triggers/special/nation/che_유가.ts +++ b/packages/logic/src/triggers/special/nation/che_유가.ts @@ -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; diff --git a/packages/logic/src/triggers/special/nation/che_음양가.ts b/packages/logic/src/triggers/special/nation/che_음양가.ts index 02d41c6..9849dca 100644 --- a/packages/logic/src/triggers/special/nation/che_음양가.ts +++ b/packages/logic/src/triggers/special/nation/che_음양가.ts @@ -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; diff --git a/packages/logic/src/triggers/special/nation/che_종횡가.ts b/packages/logic/src/triggers/special/nation/che_종횡가.ts index b66a9db..a63f81f 100644 --- a/packages/logic/src/triggers/special/nation/che_종횡가.ts +++ b/packages/logic/src/triggers/special/nation/che_종횡가.ts @@ -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; diff --git a/packages/logic/src/triggers/special/nation/che_중립.ts b/packages/logic/src/triggers/special/nation/che_중립.ts index 5721581..0f708fc 100644 --- a/packages/logic/src/triggers/special/nation/che_중립.ts +++ b/packages/logic/src/triggers/special/nation/che_중립.ts @@ -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: () => ' ', }; diff --git a/packages/logic/src/triggers/special/nation/che_태평도.ts b/packages/logic/src/triggers/special/nation/che_태평도.ts index 4c702af..a7cdb2e 100644 --- a/packages/logic/src/triggers/special/nation/che_태평도.ts +++ b/packages/logic/src/triggers/special/nation/che_태평도.ts @@ -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; diff --git a/tools/integration-tests/test/battleDifferential.test.ts b/tools/integration-tests/test/battleDifferential.test.ts index f92d9e1..c08c3b8 100644 --- a/tools/integration-tests/test/battleDifferential.test.ts +++ b/tools/integration-tests/test/battleDifferential.test.ts @@ -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 +>; + 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; }; +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( + 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( + 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();