diff --git a/app/game-api/src/battleSim/processor.ts b/app/game-api/src/battleSim/processor.ts index b806a89..aec1414 100644 --- a/app/game-api/src/battleSim/processor.ts +++ b/app/game-api/src/battleSim/processor.ts @@ -13,6 +13,8 @@ import { ITEM_KEYS, loadItemModules, createInheritBuffModules, + compileCrewTypeCatalog, + createCrewTypeWarTriggerRegistry, type City, type General, type Nation, @@ -29,10 +31,15 @@ import { convertLog } from './logFormatter.js'; const DEFAULT_GENERAL_AGE = 20; const inheritBuffModules = createInheritBuffModules(); -const itemWarModules: WarActionModule[] = [ - ...createItemActionModules(createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]))).war, - inheritBuffModules.war, -]; +const itemWarModules: WarActionModule[] = createItemActionModules( + createItemModuleRegistry(await loadItemModules([...ITEM_KEYS])) +).war; +const crewTypeWarTriggerRegistry = createCrewTypeWarTriggerRegistry(); + +const buildWarActionModules = (unitSet: UnitSetDefinition): WarActionModule[] => { + const crewTypeCatalog = compileCrewTypeCatalog(unitSet, crewTypeWarTriggerRegistry); + return [crewTypeCatalog.warActionModule, inheritBuffModules.war, ...itemWarModules]; +}; const normalizeItemCode = (value: string | null): string | null => (value === 'None' ? null : value); @@ -253,6 +260,7 @@ const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] => const defenderCity = mapCityPayload(payload.defenderCity); const attacker = mapGeneralPayload(payload.attackerGeneral); const defenders = payload.defenderGenerals.map(mapGeneralPayload); + const warActionModules = buildWarActionModules(payload.unitSet); return resolveDefenderOrder({ unitSet: payload.unitSet, @@ -263,11 +271,13 @@ const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] => general: attacker, city: attackerCity, nation: attackerNation, + modules: warActionModules, }, defenders: defenders.map((general) => ({ general, city: defenderCity, nation: defenderNation, + modules: warActionModules, })), defenderCity, defenderNation, @@ -284,6 +294,7 @@ export const processBattleSimJob = (payload: BattleSimJobPayload): BattleSimResu } let repeatCnt = payload.repeatCnt; + const warActionModules = buildWarActionModules(payload.unitSet); const baseSeed = payload.seed ?? ''; if (baseSeed) { repeatCnt = 1; @@ -329,13 +340,13 @@ export const processBattleSimJob = (payload: BattleSimJobPayload): BattleSimResu general: attackerGeneral, city: attackerCity, nation: attackerNation, - modules: itemWarModules, + modules: warActionModules, }, defenders: defenderGenerals.map((general) => ({ general, city: defenderCity, nation: defenderNation, - modules: itemWarModules, + modules: warActionModules, })), defenderCity, defenderNation, diff --git a/app/game-api/test/battleSimProcessor.test.ts b/app/game-api/test/battleSimProcessor.test.ts index 9ac59b0..69648e6 100644 --- a/app/game-api/test/battleSimProcessor.test.ts +++ b/app/game-api/test/battleSimProcessor.test.ts @@ -244,4 +244,28 @@ describe('battle sim processor', () => { expect(result.result).toBe(true); expect(result.order?.length).toBe(1); }); + + it('executes crew trigger handlers in simulator battles', () => { + const payload = buildPayload('battle'); + payload.unitSet.crewTypes![0]!.phaseSkillTrigger = ['che_선제사격시도', 'che_선제사격발동']; + payload.unitSet.crewTypes!.splice(1, 0, { + ...payload.unitSet.crewTypes![0]!, + id: 200, + name: '수비 보병', + phaseSkillTrigger: null, + }); + payload.defenderGenerals[0]!.crewtype = 200; + + const result = processBattleSimJob(payload); + + expect(result.result).toBe(true); + expect(result.attackerSkills?.['선제']).toBe(1); + }); + + it('fails fast when a simulator unit set references an unknown crew handler', () => { + const payload = buildPayload('battle'); + payload.unitSet.crewTypes![0]!.iActionList = ['missing_action']; + + expect(() => processBattleSimJob(payload)).toThrow('Unknown crew type action'); + }); }); diff --git a/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts b/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts index 2650d07..22d1946 100644 --- a/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts +++ b/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts @@ -1,9 +1,44 @@ -import { getTechCost, isCrewTypeAvailable } from '@sammo-ts/logic/world/unitSet.js'; +import { + findCrewTypeById, + getCrewTypePickScore, + getTechCost, + isCrewTypeAvailable, +} from '@sammo-ts/logic/world/unitSet.js'; +import { buildWarConfig } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js'; +import type { CrewTypeDefinition, General, WarArmTypes } from '@sammo-ts/logic'; import type { GeneralAI } from '../core.js'; import { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js'; import { t통솔장 } from './helpers.js'; +export const buildRecruitArmTypeWeights = (general: General, armTypes: WarArmTypes): Array<[number, number]> => { + const meta = asRecord(general.meta); + const fullStrength = readMetaNumber(meta, 'fullStrength', general.stats.strength); + const fullIntelligence = readMetaNumber(meta, 'fullIntelligence', general.stats.intelligence); + const weights: Array<[number, number]> = []; + + if (fullStrength > fullIntelligence * 0.9) { + for (const armType of [armTypes.footman, armTypes.archer, armTypes.cavalry]) { + if (armType === undefined) { + continue; + } + weights.push([armType, Math.sqrt(readMetaNumber(meta, `dex${armType}`, 0) + 500) * fullStrength]); + } + } + if (fullIntelligence > fullStrength * 0.9 && armTypes.wizard !== undefined) { + weights.push([ + armTypes.wizard, + Math.sqrt(readMetaNumber(meta, `dex${armTypes.wizard}`, 0) + 500) * fullIntelligence * 3, + ]); + } + return weights; +}; + +const getRequiredTech = (crewType: CrewTypeDefinition): number | null => { + const requirement = crewType.requirements.find((entry) => entry.type === 'ReqTech'); + return requirement?.type === 'ReqTech' && typeof requirement.tech === 'number' ? requirement.tech : null; +}; + export const do징병 = (ai: GeneralAI) => { const city = ai.city; const nation = ai.nation; @@ -37,9 +72,12 @@ export const do징병 = (ai: GeneralAI) => { const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0); const crewAmountBase = ai.general.stats.leadership * 100; + const warConfig = buildWarConfig(ai.scenarioConfig, ai.unitSet); + const forcedArmType = readMetaNumber(asRecord(ai.general.meta), 'armType', 0); const armType = - readMetaNumber(asRecord(ai.general.meta), 'armType', 0) || - (ai.general.stats.strength >= ai.general.stats.intelligence * 0.9 ? 1 : 4); + forcedArmType > 0 + ? forcedArmType + : ai.rng.choiceUsingWeightPair(buildRecruitArmTypeWeights(ai.general, warConfig.armTypes)); const candidates = (ai.unitSet?.crewTypes ?? []) .filter((crew) => crew.armType === armType) @@ -56,7 +94,31 @@ export const do징병 = (ai: GeneralAI) => { if (candidates.length === 0) { return null; } - const picked = ai.rng.choiceUsingWeightPair(candidates.map((crew) => [crew, Math.max(1, crew.cost)])); + let picked = ai.rng.choiceUsingWeightPair( + candidates.map((crew) => [crew, getCrewTypePickScore(crew, tech, warConfig.armPerPhase)]) + ); + if (ai.generalPolicy.can('고급병종')) { + const currentCrewType = findCrewTypeById(ai.unitSet, ai.general.crewTypeId); + if ( + currentCrewType && + isCrewTypeAvailable(ai.unitSet, currentCrewType.id, { + general: ai.general, + nation, + map: ai.map, + cities: ai.worldRef?.listCities() ?? [], + currentYear: ai.world.currentYear, + startYear: ai.startYear, + }) + ) { + const requiredTech = getRequiredTech(currentCrewType); + if ( + requiredTech !== null && + (requiredTech >= 2000 || (currentCrewType.armType !== armType && requiredTech >= 1000)) + ) { + picked = currentCrewType; + } + } + } const crewTypeId = picked.id; let crewAmount = crewAmountBase; diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index f0f6c9f..e8c677b 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -15,6 +15,8 @@ import { ITEM_KEYS, loadItemModules, createInheritBuffModules, + compileCrewTypeCatalog, + createCrewTypeWarTriggerRegistry, } from '@sammo-ts/logic'; import { asRecord } from '@sammo-ts/common'; @@ -70,6 +72,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit const constValues = asRecord(config.const); return { + ...(unitSet ? { unitSet } : {}), develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0), minAvailableRecruitPop: resolveNumber(constValues, ['minAvailableRecruitPop'], 30000), trainDelta: resolveNumber(constValues, ['trainDelta'], DEFAULT_TRAIN_DELTA), @@ -96,11 +99,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit ), defaultSpecialDomestic: resolveOptionalString(constValues, ['defaultSpecialDomestic']), defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']), - initialNationGenLimit: resolveNumber( - constValues, - ['initialNationGenLimit'], - DEFAULT_INITIAL_NATION_GEN_LIMIT - ), + initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], DEFAULT_INITIAL_NATION_GEN_LIMIT), maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], DEFAULT_MAX_TECH_LEVEL), baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD), baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE), @@ -153,10 +152,21 @@ export const buildReservedTurnDefinitions = async (options: { const itemRegistry = createItemModuleRegistry(itemModules); const itemActionModules = createItemActionModules(itemRegistry); const inheritBuffModules = createInheritBuffModules(); - options.env.generalActionModules = [...(options.env.generalActionModules ?? []), ...itemActionModules.general]; - options.env.warActionModules = [...(options.env.warActionModules ?? []), ...itemActionModules.war]; - options.env.generalActionModules.push(inheritBuffModules.general); - options.env.warActionModules.push(inheritBuffModules.war); + 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, + ]; + options.env.warActionModules = [ + ...(options.env.warActionModules ?? []), + ...(crewTypeCatalog ? [crewTypeCatalog.warActionModule] : []), + inheritBuffModules.war, + ...itemActionModules.war, + ]; const generalSpecs = await loadGeneralTurnCommandSpecs(options.commandProfile.general); const nationSpecs = await loadNationTurnCommandSpecs(options.commandProfile.nation); diff --git a/app/game-engine/test/crewTypeExecution.test.ts b/app/game-engine/test/crewTypeExecution.test.ts new file mode 100644 index 0000000..a71ccdb --- /dev/null +++ b/app/game-engine/test/crewTypeExecution.test.ts @@ -0,0 +1,146 @@ +import { WarActionPipeline, type General, type ScenarioConfig, type UnitSetDefinition } from '@sammo-ts/logic'; +import { describe, expect, it } from 'vitest'; + +import { buildCommandEnv, buildReservedTurnDefinitions } from '../src/turn/reservedTurnCommands.js'; +import { buildRecruitArmTypeWeights } from '../src/turn/ai/generalAi/general/recruitActions.js'; + +const scenarioConfig: ScenarioConfig = { + stat: { + total: 200, + min: 10, + max: 100, + npcTotal: 200, + npcMin: 10, + npcMax: 100, + chiefMin: 10, + }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'test' }, +}; + +const general: General = { + id: 1, + name: '공성장', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 80, strength: 80, intelligence: 80 }, + experience: 0, + dedication: 0, + officerLevel: 3, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 1000, + rice: 1000, + crew: 1000, + crewTypeId: 1500, + train: 100, + atmos: 100, + age: 20, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, +}; + +const unitSet: UnitSetDefinition = { + id: 'engine-crew', + name: 'engine-crew', + defaultCrewTypeId: 1100, + crewTypes: [ + { + id: 1100, + armType: 1, + name: '보병', + attack: 100, + defence: 100, + speed: 7, + avoid: 10, + magicCoef: 0, + cost: 9, + rice: 9, + requirements: [], + attackCoef: {}, + defenceCoef: {}, + info: [], + initSkillTrigger: null, + phaseSkillTrigger: null, + iActionList: null, + }, + { + id: 1500, + armType: 5, + name: '정란', + attack: 100, + defence: 100, + speed: 7, + avoid: 10, + magicCoef: 0, + cost: 9, + rice: 9, + requirements: [], + attackCoef: {}, + defenceCoef: {}, + info: [], + initSkillTrigger: null, + phaseSkillTrigger: null, + iActionList: ['che_성벽선제'], + }, + ], +}; + +describe('reserved turn crew type wiring', () => { + it('installs the crew action router before inherit and item handlers', async () => { + const env = buildCommandEnv(scenarioConfig, unitSet); + + await buildReservedTurnDefinitions({ + env, + commandProfile: { general: ['휴식'], nation: ['휴식'] }, + defaultActionKey: '휴식', + }); + + expect(env.unitSet).toBe(unitSet); + expect(env.warActionModules?.length).toBeGreaterThan(2); + + const pipeline = new WarActionPipeline(env.warActionModules ?? []); + expect(pipeline.onCalcOpposeStat({ general }, 'cityBattleOrder', -1)).toBe(10000); + }); +}); + +describe('NPC crew type selection', () => { + it('matches the legacy stat and dexterity weights for arm-type selection', () => { + const weightedGeneral: General = { + ...general, + stats: { ...general.stats, strength: 80, intelligence: 75 }, + meta: { + killturn: 24, + fullStrength: 80, + fullIntelligence: 75, + dex1: 400, + dex2: 1300, + dex3: 3100, + dex4: 7600, + }, + }; + + expect( + buildRecruitArmTypeWeights(weightedGeneral, { + footman: 1, + archer: 2, + cavalry: 3, + wizard: 4, + }) + ).toEqual([ + [1, Math.sqrt(900) * 80], + [2, Math.sqrt(1800) * 80], + [3, Math.sqrt(3600) * 80], + [4, Math.sqrt(8100) * 75 * 3], + ]); + }); +}); diff --git a/docs/architecture/todo.md b/docs/architecture/todo.md index 022eb7e..2e36adf 100644 --- a/docs/architecture/todo.md +++ b/docs/architecture/todo.md @@ -89,6 +89,10 @@ Move items into the main docs once they are finalized. startup when a unit/item/trait references an unregistered trigger. Compose personality, domestic/war specialty, item, inheritance, nation, and unit triggers in the live turn-daemon battle path. +- [AI suggestion] Extend unit-set init/phase trigger specs from `string[]` to + a typed string-or-`{ key, args }` union before importing a legacy ruleset + that uses parameterized `buildWarUnitTriggerClass` arguments. Preserve + argument order and include it in differential trigger traces. - Input snapshot format (seed, scenario, trigger inputs, game time) - Deterministic RNG test harness guidelines - Output comparison rules (sorting, tolerances, diff granularity) @@ -107,6 +111,9 @@ Move items into the main docs once they are finalized. ## Data and Profiles (Lower Priority) +- [AI suggestion] Resolve the shipped `ludo_rathowm` unit set's + `defaultCrewTypeId=1100` mismatch with its `217xxx` crew IDs, then enable + catalog validation for default and castle crew IDs across every profile. - [AI suggestion] Split gateway orchestration into immutable `Release`, versioned `Ruleset`, `ProfileInstance`, `Deployment`, and first-class `AdminJob` records. Store artifact/API/resource digests and an auditable job diff --git a/packages/logic/src/actions/turn/commandEnv.ts b/packages/logic/src/actions/turn/commandEnv.ts index 810d123..f6353bf 100644 --- a/packages/logic/src/actions/turn/commandEnv.ts +++ b/packages/logic/src/actions/turn/commandEnv.ts @@ -1,5 +1,6 @@ import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js'; import type { WarActionModule } from '@sammo-ts/logic/war/actions.js'; +import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js'; export interface TurnCommandItemCatalogEntry { slot: 'horse' | 'weapon' | 'book' | 'item'; @@ -12,6 +13,7 @@ export interface TurnCommandItemCatalogEntry { } export interface TurnCommandEnv { + unitSet?: UnitSetDefinition; develCost: number; minAvailableRecruitPop?: number; trainDelta: number; diff --git a/packages/logic/src/crewType/actions/che_성벽선제.ts b/packages/logic/src/crewType/actions/che_성벽선제.ts new file mode 100644 index 0000000..2f5ffce --- /dev/null +++ b/packages/logic/src/crewType/actions/che_성벽선제.ts @@ -0,0 +1,15 @@ +import type { CrewTypeActionModule } from '../types.js'; + +export const actionModule: CrewTypeActionModule = { + key: 'che_성벽선제', + name: '성벽선제', + info: '전투 가능한 성벽이라면 선제공격을 합니다.', + war: { + onCalcOpposeStat: (_context, statName, value) => { + if (statName === 'cityBattleOrder') { + return 10000; + } + return value; + }, + }, +}; diff --git a/packages/logic/src/crewType/catalog.ts b/packages/logic/src/crewType/catalog.ts new file mode 100644 index 0000000..ceb9631 --- /dev/null +++ b/packages/logic/src/crewType/catalog.ts @@ -0,0 +1,263 @@ +import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js'; +import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js'; +import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js'; +import type { WarActionContext, WarActionModule } from '@sammo-ts/logic/war/actions.js'; +import { WarTriggerCaller, type WarTriggerRegistry } from '@sammo-ts/logic/war/triggers.js'; +import type { CrewTypeDefinition, CrewTypeRequirement, UnitSetDefinition } from '@sammo-ts/logic/world/types.js'; + +import { createCrewTypeActionRegistry } from './registry.js'; +import type { CompiledCrewType, CrewTypeActionModule, CrewTypeActionRegistry, CrewTypeCatalog } from './types.js'; + +const crewTypeWarActionRouters = new WeakSet(); + +const SUPPORTED_REQUIREMENTS = new Set([ + 'ReqTech', + 'ReqRegions', + 'ReqCities', + 'ReqCitiesWithCityLevel', + 'ReqHighLevelCities', + 'ReqNationAux', + 'ReqMinRelYear', + 'ReqChief', + 'ReqNotChief', + 'Impossible', +]); + +const validateRequirement = ( + unitSet: UnitSetDefinition, + crewType: CrewTypeDefinition, + requirement: CrewTypeRequirement +): void => { + if (!SUPPORTED_REQUIREMENTS.has(requirement.type)) { + throw new Error(`Unknown crew type requirement in ${unitSet.id}/${crewType.id}: ${requirement.type}`); + } +}; + +const validateCoefficientKeys = ( + unitSet: UnitSetDefinition, + crewType: CrewTypeDefinition, + field: 'attackCoef' | 'defenceCoef', + crewTypeIds: ReadonlySet, + armTypes: ReadonlySet +): void => { + for (const rawKey of Object.keys(crewType[field])) { + const key = Number(rawKey); + if (!Number.isInteger(key) || (!crewTypeIds.has(key) && !armTypes.has(key))) { + throw new Error(`Invalid ${field} key in ${unitSet.id}/${crewType.id}: ${rawKey}`); + } + } +}; + +const compileDefinitions = ( + unitSet: UnitSetDefinition, + actionRegistry: CrewTypeActionRegistry, + triggerRegistry: WarTriggerRegistry +): Map => { + const definitions = unitSet.crewTypes ?? []; + if (definitions.length === 0) { + throw new Error(`Unit set has no crew types: ${unitSet.id}`); + } + + const crewTypeIds = new Set(); + const crewTypeNames = new Set(); + const armTypes = new Set(definitions.map((crewType) => crewType.armType)); + + for (const crewType of definitions) { + if (crewTypeIds.has(crewType.id)) { + throw new Error(`Duplicate crew type id in ${unitSet.id}: ${crewType.id}`); + } + if (crewTypeNames.has(crewType.name)) { + throw new Error(`Duplicate crew type name in ${unitSet.id}: ${crewType.name}`); + } + crewTypeIds.add(crewType.id); + crewTypeNames.add(crewType.name); + } + + const compiled = new Map(); + for (const crewType of definitions) { + for (const requirement of crewType.requirements) { + validateRequirement(unitSet, crewType, requirement); + } + validateCoefficientKeys(unitSet, crewType, 'attackCoef', crewTypeIds, armTypes); + validateCoefficientKeys(unitSet, crewType, 'defenceCoef', crewTypeIds, armTypes); + + const actions: CrewTypeActionModule[] = []; + for (const key of crewType.iActionList ?? []) { + const action = actionRegistry.get(key); + if (!action) { + throw new Error(`Unknown crew type action in ${unitSet.id}/${crewType.id}: ${key}`); + } + actions.push(action); + } + + for (const key of [...(crewType.initSkillTrigger ?? []), ...(crewType.phaseSkillTrigger ?? [])]) { + if (!triggerRegistry[key]) { + throw new Error(`Unknown crew type war trigger in ${unitSet.id}/${crewType.id}: ${key}`); + } + } + + compiled.set(crewType.id, { definition: crewType, actions }); + } + return compiled; +}; + +const createGeneralActionRouter = ( + byId: ReadonlyMap +): GeneralActionModule => { + const modules = (context: GeneralActionContext) => + (byId.get(context.general.crewTypeId)?.actions ?? []) + .map((action) => action.general as GeneralActionModule | undefined) + .filter((action): action is GeneralActionModule => action !== undefined); + + return { + getPreTurnExecuteTriggerList: (context) => { + const caller = new GeneralTriggerCaller(); + for (const module of modules(context)) { + caller.merge(module.getPreTurnExecuteTriggerList?.(context)); + } + return caller; + }, + onCalcDomestic: (context, turnType, varType, value, aux) => { + let current = value; + for (const module of modules(context)) { + current = module.onCalcDomestic?.(context, turnType, varType, current, aux) ?? current; + } + return current; + }, + onCalcStat: (context, statName, value, aux) => { + let current = value; + for (const module of modules(context)) { + current = module.onCalcStat?.(context, statName, current, aux) ?? current; + } + return current; + }, + onCalcOpposeStat: (context, statName, value, aux) => { + let current = value; + for (const module of modules(context)) { + current = module.onCalcOpposeStat?.(context, statName, current, aux) ?? current; + } + return current; + }, + onCalcStrategic: (context, turnType, varType, value) => { + let current = value; + for (const module of modules(context)) { + current = module.onCalcStrategic?.(context, turnType, varType, current) ?? current; + } + return current; + }, + onCalcNationalIncome: (context, type, amount) => { + let current = amount; + for (const module of modules(context)) { + current = module.onCalcNationalIncome?.(context, type, current) ?? current; + } + return current; + }, + onArbitraryAction: (context, actionType, phase, aux) => { + let current = aux ?? null; + for (const module of modules(context)) { + current = module.onArbitraryAction?.(context, actionType, phase, current) ?? current; + } + return current; + }, + } satisfies GeneralActionModule; +}; + +const createWarActionRouter = ( + byId: ReadonlyMap, + triggerRegistry: WarTriggerRegistry +): WarActionModule => { + const compiled = (context: WarActionContext) => byId.get(context.general.crewTypeId); + const modules = (context: WarActionContext) => + (compiled(context)?.actions ?? []) + .map((action) => action.war as WarActionModule | undefined) + .filter((action): action is WarActionModule => action !== undefined); + const appendDefinitionTriggers = ( + caller: WarTriggerCaller, + context: WarActionContext, + keys: readonly string[] + ): void => { + if (!context.unit) { + if (keys.length > 0) { + throw new Error('Crew type war triggers require a battle unit context'); + } + return; + } + for (const key of keys) { + const trigger = triggerRegistry[key]?.(context.unit); + if (!trigger) { + throw new Error(`Unknown crew type war trigger: ${key}`); + } + if (trigger instanceof WarTriggerCaller) { + caller.merge(trigger); + } else { + caller.append(trigger); + } + } + }; + + const router = { + getBattleInitTriggerList: (context) => { + const caller = new WarTriggerCaller(); + appendDefinitionTriggers(caller, context, compiled(context)?.definition.initSkillTrigger ?? []); + for (const module of modules(context)) { + caller.merge(module.getBattleInitTriggerList?.(context)); + } + return caller; + }, + getBattlePhaseTriggerList: (context) => { + const caller = new WarTriggerCaller(); + appendDefinitionTriggers(caller, context, compiled(context)?.definition.phaseSkillTrigger ?? []); + for (const module of modules(context)) { + caller.merge(module.getBattlePhaseTriggerList?.(context)); + } + return caller; + }, + onCalcStat: (context, statName, value, aux) => { + let current = value; + for (const module of modules(context)) { + current = module.onCalcStat?.(context, statName, current, aux) ?? current; + } + return current; + }, + onCalcOpposeStat: (context, statName, value, aux) => { + let current = value; + for (const module of modules(context)) { + current = module.onCalcOpposeStat?.(context, statName, current, aux) ?? current; + } + return current; + }, + getWarPowerMultiplier: (context, unit, oppose) => { + let attack = 1; + let defence = 1; + for (const module of modules(context)) { + const [attackMultiplier, defenceMultiplier] = module.getWarPowerMultiplier?.(context, unit, oppose) ?? [ + 1, 1, + ]; + attack *= attackMultiplier; + defence *= defenceMultiplier; + } + return [attack, defence]; + }, + } satisfies WarActionModule; + crewTypeWarActionRouters.add(router); + return router; +}; + +export const isCrewTypeWarActionRouter = ( + module: WarActionModule +): boolean => crewTypeWarActionRouters.has(module); + +export const compileCrewTypeCatalog = ( + unitSet: UnitSetDefinition, + triggerRegistry: WarTriggerRegistry, + actionRegistry: CrewTypeActionRegistry = createCrewTypeActionRegistry() +): CrewTypeCatalog => { + const byId = compileDefinitions(unitSet, actionRegistry, triggerRegistry); + return { + unitSet, + byId, + generalActionModule: createGeneralActionRouter(byId), + warActionModule: createWarActionRouter(byId, triggerRegistry), + }; +}; diff --git a/packages/logic/src/crewType/index.ts b/packages/logic/src/crewType/index.ts new file mode 100644 index 0000000..96f2cb1 --- /dev/null +++ b/packages/logic/src/crewType/index.ts @@ -0,0 +1,3 @@ +export * from './types.js'; +export * from './registry.js'; +export * from './catalog.js'; diff --git a/packages/logic/src/crewType/registry.ts b/packages/logic/src/crewType/registry.ts new file mode 100644 index 0000000..f2645dd --- /dev/null +++ b/packages/logic/src/crewType/registry.ts @@ -0,0 +1,8 @@ +import { actionModule as castleFirst } from './actions/che_성벽선제.js'; +import type { CrewTypeActionModule, CrewTypeActionRegistry } from './types.js'; + +export const CREW_TYPE_ACTION_KEYS = ['che_성벽선제'] as const; + +export const createCrewTypeActionRegistry = ( + modules: readonly CrewTypeActionModule[] = [castleFirst] +): CrewTypeActionRegistry => new Map(modules.map((module) => [module.key, module])); diff --git a/packages/logic/src/crewType/types.ts b/packages/logic/src/crewType/types.ts new file mode 100644 index 0000000..d2208e0 --- /dev/null +++ b/packages/logic/src/crewType/types.ts @@ -0,0 +1,25 @@ +import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js'; +import type { WarActionModule } from '@sammo-ts/logic/war/actions.js'; +import type { CrewTypeDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js'; + +export interface CrewTypeActionModule { + key: string; + name: string; + info: string; + general?: GeneralActionModule; + war?: WarActionModule; +} + +export type CrewTypeActionRegistry = ReadonlyMap; + +export interface CompiledCrewType { + definition: CrewTypeDefinition; + actions: readonly CrewTypeActionModule[]; +} + +export interface CrewTypeCatalog { + unitSet: UnitSetDefinition; + byId: ReadonlyMap; + generalActionModule: GeneralActionModule; + warActionModule: WarActionModule; +} diff --git a/packages/logic/src/index.ts b/packages/logic/src/index.ts index 8cb9e70..47d8f1d 100644 --- a/packages/logic/src/index.ts +++ b/packages/logic/src/index.ts @@ -2,6 +2,7 @@ export * from './domain/entities.js'; export type { RandomGenerator } from '@sammo-ts/common'; export * from './actions/index.js'; export * from './constraints/index.js'; +export * from './crewType/index.js'; export * from './diplomacy/index.js'; export * from './economy/index.js'; export * from './logging/index.js'; diff --git a/packages/logic/src/war/crewType.ts b/packages/logic/src/war/crewType.ts index 566f673..adfce6e 100644 --- a/packages/logic/src/war/crewType.ts +++ b/packages/logic/src/war/crewType.ts @@ -36,6 +36,14 @@ export class WarCrewType { return this.definition.rice; } + get magicCoef(): number { + return this.definition.magicCoef; + } + + get cost(): number { + return this.definition.cost; + } + public reqCities(): boolean { return this.definition.requirements.some((req) => req.type === 'ReqCities'); } diff --git a/packages/logic/src/war/crewTypeTriggers.ts b/packages/logic/src/war/crewTypeTriggers.ts new file mode 100644 index 0000000..a45ea35 --- /dev/null +++ b/packages/logic/src/war/crewTypeTriggers.ts @@ -0,0 +1,26 @@ +import type { WarTriggerRegistry } from './triggers.js'; +import { che_기병병종전투 } from './triggers/che_기병병종전투.js'; +import { che_방어력증가5p } from './triggers/che_방어력증가5p.js'; +import { che_선제사격발동, che_선제사격시도 } from './triggers/che_선제사격.js'; +import { che_성벽부상무효 } from './triggers/che_성벽부상무효.js'; +import { che_저지, che_저지_시도 } from './triggers/che_저지.js'; + +export const CREW_TYPE_WAR_TRIGGER_KEYS = [ + 'che_성벽부상무효', + 'che_기병병종전투', + 'che_방어력증가5p', + 'che_선제사격시도', + 'che_선제사격발동', + 'che_저지시도', + 'che_저지발동', +] as const; + +export const createCrewTypeWarTriggerRegistry = (): WarTriggerRegistry => ({ + che_성벽부상무효: (unit) => new che_성벽부상무효(unit), + che_기병병종전투: (unit) => new che_기병병종전투(unit), + che_방어력증가5p: (unit) => new che_방어력증가5p(unit), + che_선제사격시도: (unit) => new che_선제사격시도(unit), + che_선제사격발동: (unit) => new che_선제사격발동(unit), + che_저지시도: (unit) => new che_저지_시도(unit), + che_저지발동: (unit) => new che_저지(unit), +}); diff --git a/packages/logic/src/war/engine.ts b/packages/logic/src/war/engine.ts index c1e9852..a91e7dc 100644 --- a/packages/logic/src/war/engine.ts +++ b/packages/logic/src/war/engine.ts @@ -1,10 +1,12 @@ import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import { compileCrewTypeCatalog, isCrewTypeWarActionRouter } from '@sammo-ts/logic/crewType/catalog.js'; import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js'; import { LogFormat } from '@sammo-ts/logic/logging/types.js'; import { buildCrewTypeIndex as buildCrewTypeDefinitionIndex } from '@sammo-ts/logic/world/unitSet.js'; -import { WarActionPipeline } from './actions.js'; +import { WarActionPipeline, type WarActionModule } from './actions.js'; +import { createCrewTypeWarTriggerRegistry } from './crewTypeTriggers.js'; import { WarCrewType } from './crewType.js'; import { WarTriggerCaller, createWarTriggerEnv, type WarTriggerRegistry } from './triggers.js'; import type { WarBattleInput, WarBattleOutcome, WarGeneralInput, WarUnitReport } from './types.js'; @@ -37,22 +39,26 @@ const buildWarCrewTypeIndex = (unitSet: WarBattleInput['unitSet']): Map( - input: WarGeneralInput -): WarActionPipeline => new WarActionPipeline(input.modules ?? []); + input: WarGeneralInput, + crewTypeModule: WarActionModule +): WarActionPipeline => { + const modules = input.modules ?? []; + if (modules.some((module) => module && isCrewTypeWarActionRouter(module))) { + return new WarActionPipeline(modules); + } + return new WarActionPipeline([crewTypeModule, ...modules]); +}; const appendCrewTypeTriggers = ( caller: WarTriggerCaller, unit: WarUnit, names: string[], - registry: WarTriggerRegistry | undefined + registry: WarTriggerRegistry ): void => { - if (!registry) { - return; - } for (const name of names) { const factory = registry[name]; if (!factory) { - continue; + throw new Error(`Unknown crew type war trigger: ${name}`); } const trigger = factory(unit); if (!trigger) { @@ -66,24 +72,28 @@ const appendCrewTypeTriggers = ( } }; -const buildBattleInitTriggers = (unit: WarUnit, registry: WarTriggerRegistry | undefined): WarTriggerCaller => { +const buildBattleInitTriggers = (unit: WarUnit, registry: WarTriggerRegistry): WarTriggerCaller => { const caller = new WarTriggerCaller(); if (unit instanceof WarUnitGeneral) { const context = unit.getActionContext(); caller.merge(unit.getActionPipeline().getBattleInitTriggerList(context)); + } else { + appendCrewTypeTriggers(caller, unit, unit.getCrewType().initSkillTrigger, registry); } - appendCrewTypeTriggers(caller, unit, unit.getCrewType().initSkillTrigger, registry); return caller; }; -const buildBattlePhaseTriggers = (unit: WarUnit, registry: WarTriggerRegistry | undefined): WarTriggerCaller => { +const buildBattlePhaseTriggers = (unit: WarUnit, registry: WarTriggerRegistry): WarTriggerCaller => { const caller = new WarTriggerCaller(); if (unit instanceof WarUnitGeneral) { - appendCrewTypeTriggers(caller, unit, ['che_필살'], registry); + if (registry['che_필살']) { + appendCrewTypeTriggers(caller, unit, ['che_필살'], registry); + } const context = unit.getActionContext(); caller.merge(unit.getActionPipeline().getBattlePhaseTriggerList(context)); + } else { + appendCrewTypeTriggers(caller, unit, unit.getCrewType().phaseSkillTrigger, registry); } - appendCrewTypeTriggers(caller, unit, unit.getCrewType().phaseSkillTrigger, registry); return caller; }; @@ -196,10 +206,14 @@ export const resolveWarBattle = { const rng = input.rng ?? new RandUtil(LiteHashDRBG.build(input.seed ?? '')); const loggerFactory = input.loggerFactory ?? defaultLoggerFactory; + const triggerRegistry: WarTriggerRegistry = { + ...createCrewTypeWarTriggerRegistry(), + ...(input.triggerRegistry ?? {}), + }; + const crewTypeCatalog = compileCrewTypeCatalog(input.unitSet, triggerRegistry); const crewTypeIndex = buildWarCrewTypeIndex(input.unitSet); - const attackerPipeline = createPipeline(input.attacker); + const attackerPipeline = createPipeline(input.attacker, crewTypeCatalog.warActionModule); const attackerLogger = input.attacker.logger ?? loggerFactory({ @@ -640,7 +659,7 @@ export const resolveDefenderOrder = 선제 사격을 주고 받았다!', LogFormat.PLAIN); + self.getLogger().pushGeneralBattleDetailLog('서로 선제 사격을 주고 받았다!', LogFormat.PLAIN); + return true; + } + + oppose.multiplyWarPowerMultiply(0); + self.multiplyWarPowerMultiply(2 / 3); + self.activateSkill('회피불가', '필살불가', '계략불가'); + oppose.activateSkill('회피불가', '필살불가', '격노불가', '계략불가'); + + oppose.getLogger().pushGeneralBattleDetailLog('상대에게 선제 사격을 받았다!', LogFormat.PLAIN); + self.getLogger().pushGeneralBattleDetailLog('상대에게 선제 사격을 했다!', LogFormat.PLAIN); + return true; + } +} diff --git a/packages/logic/src/war/triggers/che_성벽부상무효.ts b/packages/logic/src/war/triggers/che_성벽부상무효.ts new file mode 100644 index 0000000..b7d05ed --- /dev/null +++ b/packages/logic/src/war/triggers/che_성벽부상무효.ts @@ -0,0 +1,17 @@ +import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js'; +import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js'; +import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js'; + +export class che_성벽부상무효 extends BaseWarUnitTrigger { + constructor(unit: WarUnit) { + super(unit, TriggerPriority.Begin + 150); + } + + protected actionWar(self: WarUnit, oppose: WarUnit): boolean { + if (!(self instanceof WarUnitGeneral) || !(oppose instanceof WarUnitCity)) { + return true; + } + self.activateSkill('부상무효'); + return true; + } +} diff --git a/packages/logic/src/war/triggers/che_저지.ts b/packages/logic/src/war/triggers/che_저지.ts index b4f108f..c724a38 100644 --- a/packages/logic/src/war/triggers/che_저지.ts +++ b/packages/logic/src/war/triggers/che_저지.ts @@ -7,8 +7,18 @@ export class che_저지_시도 extends BaseWarUnitTrigger { constructor(unit: WarUnit, raiseType: number = 0) { super(unit, TriggerPriority.Pre, raiseType); } - protected actionWar(u: WarUnit): boolean { - u.activateSkill('특수', '저지'); + protected actionWar(self: WarUnit): boolean { + if (!(self instanceof WarUnitGeneral) || self.isAttacker()) { + return true; + } + if (self.hasActivatedSkill('특수') || self.hasActivatedSkill('저지불가')) { + return true; + } + + const ratio = self.getComputedAtmos() + self.getComputedTrain(); + if (self.rng.nextBool(ratio / 400)) { + self.activateSkill('특수', '저지'); + } return true; } } @@ -40,7 +50,7 @@ export class che_저지 extends BaseWarUnitTrigger { } self.getLogger().pushGeneralBattleDetailLog('상대를 저지했다!', LogFormat.PLAIN); - oppose.getLogger().pushGeneralBattleDetailLog('저지당했다!', LogFormat.PLAIN); + oppose.getLogger().pushGeneralBattleDetailLog('저지당했다!', LogFormat.PLAIN); const calcDamage = oppose.getWarPower() * 0.9; if (self instanceof WarUnitGeneral) { diff --git a/packages/logic/src/world/unitSet.ts b/packages/logic/src/world/unitSet.ts index 420db32..57f6e69 100644 --- a/packages/logic/src/world/unitSet.ts +++ b/packages/logic/src/world/unitSet.ts @@ -1,13 +1,6 @@ import type { City, General, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { CrewTypeDefinition, CrewTypeRequirement, MapDefinition, UnitSetDefinition } from './types.js'; -import { - asNullableStringArray, - asNumber, - asRecord, - asString, - asStringArray, - isRecord, -} from '@sammo-ts/common'; +import { asNullableStringArray, asNumber, asRecord, asString, asStringArray, isRecord } from '@sammo-ts/common'; import { UnitSetDefinitionInputSchema } from '../resources/unitSetSchema.js'; const DEFAULT_REGION_MAP: Record = { @@ -181,6 +174,14 @@ export const getTechAbility = (tech: number): number => getTechLevel(tech) * 25; export const getTechCost = (tech: number): number => 1 + getTechLevel(tech) * 0.15; +export const getCrewTypePickScore = (crewType: CrewTypeDefinition, tech: number, armPerPhase: number): number => { + let score = armPerPhase + crewType.attack + crewType.defence + getTechAbility(tech) * 2; + score *= 1 + crewType.speed / 2; + score /= Math.max(1 - crewType.avoid / 100, 0.1); + score *= 1 + crewType.magicCoef / 2; + return score; +}; + export interface CrewTypeAvailabilityContext { general: General; nation: Nation | null; diff --git a/packages/logic/test/crewTypeExecution.test.ts b/packages/logic/test/crewTypeExecution.test.ts new file mode 100644 index 0000000..c248974 --- /dev/null +++ b/packages/logic/test/crewTypeExecution.test.ts @@ -0,0 +1,365 @@ +import { readdir, readFile } from 'node:fs/promises'; + +import { ConstantRNG, RandUtil } from '@sammo-ts/common'; +import { describe, expect, it } from 'vitest'; + +import { compileCrewTypeCatalog } from '../src/crewType/catalog.js'; +import { ActionLogger } from '../src/logging/actionLogger.js'; +import type { City, General, Nation } from '../src/domain/entities.js'; +import { WarActionPipeline } from '../src/war/actions.js'; +import { WarCrewType } from '../src/war/crewType.js'; +import { createCrewTypeWarTriggerRegistry } from '../src/war/crewTypeTriggers.js'; +import { computeBattleOrder, resolveWarBattle } from '../src/war/engine.js'; +import { createWarTriggerEnv, WarTriggerCaller } from '../src/war/triggers.js'; +import type { WarEngineConfig } from '../src/war/types.js'; +import { WarUnitCity, WarUnitGeneral, type WarUnit } from '../src/war/units.js'; +import { getCrewTypePickScore, parseUnitSetDefinition } from '../src/world/unitSet.js'; +import type { CrewTypeDefinition, UnitSetDefinition } from '../src/world/types.js'; + +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 nation: Nation = { + id: 1, + name: '테스트국', + color: '#000000', + capitalCityId: 1, + chiefGeneralId: null, + gold: 10000, + rice: 10000, + power: 0, + level: 1, + typeCode: 'test', + meta: { tech: 3000 }, +}; + +const city: City = { + id: 1, + name: '테스트성', + nationId: 1, + level: 1, + state: 0, + population: 10000, + populationMax: 10000, + agriculture: 500, + agricultureMax: 1000, + commerce: 500, + commerceMax: 1000, + security: 500, + securityMax: 1000, + defence: 100, + defenceMax: 1000, + wall: 1000, + wallMax: 1000, + supplyState: 1, + frontState: 0, + meta: {}, +}; + +const crewType = ( + id: number, + armType: number, + name: string, + options: Partial = {} +): CrewTypeDefinition => ({ + id, + armType, + name, + attack: 100, + defence: 100, + speed: 7, + avoid: 10, + magicCoef: 0, + cost: 10, + rice: 10, + requirements: [], + attackCoef: {}, + defenceCoef: {}, + info: [], + initSkillTrigger: null, + phaseSkillTrigger: null, + iActionList: null, + ...options, +}); + +const buildGeneral = (id: number, crewTypeId: number): General => ({ + id, + name: `장수${id}`, + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 80, strength: 80, intelligence: 80 }, + experience: 0, + dedication: 0, + officerLevel: 3, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 1000, + rice: 10000, + crew: 1000, + crewTypeId, + train: 100, + atmos: 100, + age: 20, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24, dex1: 1000, dex2: 1000, dex3: 1000, dex5: 1000 }, +}); + +const buildGeneralUnit = ( + rng: RandUtil, + general: General, + definition: CrewTypeDefinition, + attacker: boolean, + modules: WarActionPipeline = new WarActionPipeline([]) +) => + new WarUnitGeneral( + rng, + config, + general, + city, + nation, + attacker, + new WarCrewType(definition), + new ActionLogger({ generalId: general.id, nationId: general.nationId }), + modules + ); + +const fireTriggers = (keys: string[], self: WarUnit, attacker: WarUnit, defender: WarUnit): void => { + const registry = createCrewTypeWarTriggerRegistry(); + const caller = new WarTriggerCaller(); + for (const key of keys) { + const trigger = registry[key]?.(self); + if (!trigger) { + throw new Error(`Missing trigger: ${key}`); + } + if (trigger instanceof WarTriggerCaller) { + caller.merge(trigger); + } else { + caller.append(trigger); + } + } + caller.fire({ rng: self.rng, attacker, defender }, createWarTriggerEnv()); +}; + +describe('crew type catalog', () => { + it('compiles every shipped unit set and resolves all crew handlers', async () => { + const unitSetDirectory = new URL('../../../resources/unitset/', import.meta.url); + const fileNames = (await readdir(unitSetDirectory)).filter((fileName) => fileName.endsWith('.json')); + + for (const fileName of fileNames) { + const raw = JSON.parse(await readFile(new URL(fileName, unitSetDirectory), 'utf8')) as unknown; + const unitSet = parseUnitSetDefinition(raw); + const catalog = compileCrewTypeCatalog(unitSet, createCrewTypeWarTriggerRegistry()); + + expect(catalog.byId.size, fileName).toBe(unitSet.crewTypes?.length); + } + + const raw = JSON.parse(await readFile(new URL('unitset_che.json', unitSetDirectory), 'utf8')) as unknown; + const cheCatalog = compileCrewTypeCatalog(parseUnitSetDefinition(raw), createCrewTypeWarTriggerRegistry()); + expect(cheCatalog.byId.get(1500)?.actions.map((action) => action.key)).toEqual(['che_성벽선제']); + }); + + it('fails fast for unresolved crew actions and war triggers', () => { + const base: UnitSetDefinition = { + id: 'invalid', + name: 'invalid', + defaultCrewTypeId: 1100, + crewTypes: [ + crewType(1100, 1, '보병', { + iActionList: ['missing_action'], + phaseSkillTrigger: ['missing_trigger'], + }), + ], + }; + expect(() => compileCrewTypeCatalog(base, createCrewTypeWarTriggerRegistry())).toThrow( + 'Unknown crew type action' + ); + + base.crewTypes![0]!.iActionList = null; + expect(() => compileCrewTypeCatalog(base, createCrewTypeWarTriggerRegistry())).toThrow( + 'Unknown crew type war trigger' + ); + }); + + it('routes 정란의 성벽 우선 action through the war pipeline', () => { + const tower = crewType(1500, 5, '정란', { iActionList: ['che_성벽선제'] }); + const wall = crewType(1000, 0, '성벽'); + const unitSet: UnitSetDefinition = { + id: 'tower', + name: 'tower', + defaultCrewTypeId: tower.id, + crewTypes: [wall, tower], + }; + const catalog = compileCrewTypeCatalog(unitSet, createCrewTypeWarTriggerRegistry()); + const rng = new RandUtil(new ConstantRNG(0)); + const attacker = buildGeneralUnit( + rng, + buildGeneral(1, tower.id), + tower, + true, + new WarActionPipeline([catalog.warActionModule]) + ); + const defender = new WarUnitCity( + rng, + config, + city, + nation, + new WarCrewType(wall), + new ActionLogger({ nationId: nation.id }), + 200, + 180 + ); + + expect(computeBattleOrder(defender, attacker)).toBe(10000); + }); +}); + +describe('crew type war triggers', () => { + it('loads crew triggers automatically in the live battle engine', () => { + const archer = crewType(1200, 2, '궁병', { + phaseSkillTrigger: ['che_선제사격시도', 'che_선제사격발동'], + }); + const footman = crewType(1100, 1, '보병'); + const wall = crewType(1000, 0, '성벽'); + const unitSet: UnitSetDefinition = { + id: 'live-engine', + name: 'live-engine', + defaultCrewTypeId: footman.id, + crewTypes: [wall, footman, archer], + }; + const attacker = buildGeneral(1, archer.id); + const defender = buildGeneral(2, footman.id); + + const outcome = resolveWarBattle({ + rng: new RandUtil(new ConstantRNG(0)), + unitSet, + config, + time: { year: 200, month: 1, startYear: 180 }, + attacker: { general: attacker, city, nation }, + defenders: [{ general: defender, city, nation }], + defenderCity: city, + defenderNation: nation, + }); + + expect(outcome.metrics?.attackerActivatedSkills['선제']).toBe(1); + }); + + it('activates wound immunity only for a general fighting a city wall', () => { + const siege = crewType(1501, 5, '충차'); + const wall = crewType(1000, 0, '성벽'); + const rng = new RandUtil(new ConstantRNG(0)); + const attacker = buildGeneralUnit(rng, buildGeneral(1, siege.id), siege, true); + const defender = new WarUnitCity( + rng, + config, + city, + nation, + new WarCrewType(wall), + new ActionLogger({ nationId: nation.id }), + 200, + 180 + ); + attacker.setOppose(defender); + defender.setOppose(attacker); + + fireTriggers(['che_성벽부상무효'], attacker, attacker, defender); + + expect(attacker.hasActivatedSkill('부상무효')).toBe(true); + }); + + it('applies cavalry and footman end-of-phase multipliers', () => { + const cavalry = crewType(1300, 3, '기병'); + const footman = crewType(1100, 1, '보병'); + const rng = new RandUtil(new ConstantRNG(0)); + const attacker = buildGeneralUnit(rng, buildGeneral(1, cavalry.id), cavalry, true); + const defender = buildGeneralUnit(rng, buildGeneral(2, footman.id), footman, false); + attacker.setOppose(defender); + defender.setOppose(attacker); + + fireTriggers(['che_기병병종전투'], attacker, attacker, defender); + fireTriggers(['che_방어력증가5p'], defender, attacker, defender); + + expect(attacker.getWarPowerMultiply()).toBeCloseTo(1.02 / 1.05); + expect(defender.getWarPowerMultiply()).toBeCloseTo(0.97); + }); + + it('executes one-sided preemptive fire once and suppresses the opponent', () => { + const archer = crewType(1200, 2, '궁병'); + const footman = crewType(1100, 1, '보병'); + const rng = new RandUtil(new ConstantRNG(0)); + const attacker = buildGeneralUnit(rng, buildGeneral(1, archer.id), archer, true); + const defender = buildGeneralUnit(rng, buildGeneral(2, footman.id), footman, false); + attacker.setOppose(defender); + defender.setOppose(attacker); + + fireTriggers(['che_선제사격시도', 'che_선제사격발동'], attacker, attacker, defender); + + expect(attacker.getPhase()).toBe(-1); + expect(defender.getPhase()).toBe(-1); + expect(attacker.getWarPowerMultiply()).toBeCloseTo(2 / 3); + expect(defender.getWarPowerMultiply()).toBe(0); + expect(attacker.hasActivatedSkill('선제')).toBe(true); + expect(defender.hasActivatedSkill('회피불가')).toBe(true); + }); + + it('uses the legacy stop probability and never lets an attacker initiate 저지', () => { + const ram = crewType(1503, 5, '목우'); + const footman = crewType(1100, 1, '보병'); + const rng = new RandUtil(new ConstantRNG(0)); + const attacker = buildGeneralUnit(rng, buildGeneral(1, footman.id), footman, true); + const defender = buildGeneralUnit(rng, buildGeneral(2, ram.id), ram, false); + attacker.setOppose(defender); + defender.setOppose(attacker); + + fireTriggers(['che_저지시도'], attacker, attacker, defender); + expect(attacker.hasActivatedSkill('저지')).toBe(false); + + fireTriggers(['che_저지시도', 'che_저지발동'], defender, attacker, defender); + expect(defender.hasActivatedSkill('저지')).toBe(true); + expect(attacker.getWarPowerMultiply()).toBe(0); + expect(defender.getWarPowerMultiply()).toBe(0); + + const missRng = new RandUtil(new ConstantRNG(1)); + const missedAttacker = buildGeneralUnit(missRng, buildGeneral(3, footman.id), footman, true); + const missedDefender = buildGeneralUnit(missRng, buildGeneral(4, ram.id), ram, false); + missedAttacker.setOppose(missedDefender); + missedDefender.setOppose(missedAttacker); + fireTriggers(['che_저지시도', 'che_저지발동'], missedDefender, missedAttacker, missedDefender); + expect(missedDefender.hasActivatedSkill('저지')).toBe(false); + }); +}); + +describe('crew type numeric policy', () => { + it('matches the legacy pickScore formula including magicCoef', () => { + const wizard = crewType(1400, 4, '귀병', { + attack: 80, + defence: 80, + speed: 7, + avoid: 5, + magicCoef: 0.5, + }); + const expected = ((500 + 80 + 80 + 75 * 2) * (1 + 7 / 2) * (1 + 0.5 / 2)) / (1 - 0.05); + expect(getCrewTypePickScore(wizard, 3000, 500)).toBeCloseTo(expected); + }); +});