Implement crew type execution model
This commit is contained in:
@@ -13,6 +13,8 @@ import {
|
|||||||
ITEM_KEYS,
|
ITEM_KEYS,
|
||||||
loadItemModules,
|
loadItemModules,
|
||||||
createInheritBuffModules,
|
createInheritBuffModules,
|
||||||
|
compileCrewTypeCatalog,
|
||||||
|
createCrewTypeWarTriggerRegistry,
|
||||||
type City,
|
type City,
|
||||||
type General,
|
type General,
|
||||||
type Nation,
|
type Nation,
|
||||||
@@ -29,10 +31,15 @@ import { convertLog } from './logFormatter.js';
|
|||||||
const DEFAULT_GENERAL_AGE = 20;
|
const DEFAULT_GENERAL_AGE = 20;
|
||||||
|
|
||||||
const inheritBuffModules = createInheritBuffModules();
|
const inheritBuffModules = createInheritBuffModules();
|
||||||
const itemWarModules: WarActionModule[] = [
|
const itemWarModules: WarActionModule[] = createItemActionModules(
|
||||||
...createItemActionModules(createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]))).war,
|
createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]))
|
||||||
inheritBuffModules.war,
|
).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);
|
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 defenderCity = mapCityPayload(payload.defenderCity);
|
||||||
const attacker = mapGeneralPayload(payload.attackerGeneral);
|
const attacker = mapGeneralPayload(payload.attackerGeneral);
|
||||||
const defenders = payload.defenderGenerals.map(mapGeneralPayload);
|
const defenders = payload.defenderGenerals.map(mapGeneralPayload);
|
||||||
|
const warActionModules = buildWarActionModules(payload.unitSet);
|
||||||
|
|
||||||
return resolveDefenderOrder({
|
return resolveDefenderOrder({
|
||||||
unitSet: payload.unitSet,
|
unitSet: payload.unitSet,
|
||||||
@@ -263,11 +271,13 @@ const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] =>
|
|||||||
general: attacker,
|
general: attacker,
|
||||||
city: attackerCity,
|
city: attackerCity,
|
||||||
nation: attackerNation,
|
nation: attackerNation,
|
||||||
|
modules: warActionModules,
|
||||||
},
|
},
|
||||||
defenders: defenders.map((general) => ({
|
defenders: defenders.map((general) => ({
|
||||||
general,
|
general,
|
||||||
city: defenderCity,
|
city: defenderCity,
|
||||||
nation: defenderNation,
|
nation: defenderNation,
|
||||||
|
modules: warActionModules,
|
||||||
})),
|
})),
|
||||||
defenderCity,
|
defenderCity,
|
||||||
defenderNation,
|
defenderNation,
|
||||||
@@ -284,6 +294,7 @@ export const processBattleSimJob = (payload: BattleSimJobPayload): BattleSimResu
|
|||||||
}
|
}
|
||||||
|
|
||||||
let repeatCnt = payload.repeatCnt;
|
let repeatCnt = payload.repeatCnt;
|
||||||
|
const warActionModules = buildWarActionModules(payload.unitSet);
|
||||||
const baseSeed = payload.seed ?? '';
|
const baseSeed = payload.seed ?? '';
|
||||||
if (baseSeed) {
|
if (baseSeed) {
|
||||||
repeatCnt = 1;
|
repeatCnt = 1;
|
||||||
@@ -329,13 +340,13 @@ export const processBattleSimJob = (payload: BattleSimJobPayload): BattleSimResu
|
|||||||
general: attackerGeneral,
|
general: attackerGeneral,
|
||||||
city: attackerCity,
|
city: attackerCity,
|
||||||
nation: attackerNation,
|
nation: attackerNation,
|
||||||
modules: itemWarModules,
|
modules: warActionModules,
|
||||||
},
|
},
|
||||||
defenders: defenderGenerals.map((general) => ({
|
defenders: defenderGenerals.map((general) => ({
|
||||||
general,
|
general,
|
||||||
city: defenderCity,
|
city: defenderCity,
|
||||||
nation: defenderNation,
|
nation: defenderNation,
|
||||||
modules: itemWarModules,
|
modules: warActionModules,
|
||||||
})),
|
})),
|
||||||
defenderCity,
|
defenderCity,
|
||||||
defenderNation,
|
defenderNation,
|
||||||
|
|||||||
@@ -244,4 +244,28 @@ describe('battle sim processor', () => {
|
|||||||
expect(result.result).toBe(true);
|
expect(result.result).toBe(true);
|
||||||
expect(result.order?.length).toBe(1);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 type { GeneralAI } from '../core.js';
|
||||||
import { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js';
|
import { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js';
|
||||||
import { t통솔장 } from './helpers.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) => {
|
export const do징병 = (ai: GeneralAI) => {
|
||||||
const city = ai.city;
|
const city = ai.city;
|
||||||
const nation = ai.nation;
|
const nation = ai.nation;
|
||||||
@@ -37,9 +72,12 @@ export const do징병 = (ai: GeneralAI) => {
|
|||||||
|
|
||||||
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
|
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
|
||||||
const crewAmountBase = ai.general.stats.leadership * 100;
|
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 =
|
const armType =
|
||||||
readMetaNumber(asRecord(ai.general.meta), 'armType', 0) ||
|
forcedArmType > 0
|
||||||
(ai.general.stats.strength >= ai.general.stats.intelligence * 0.9 ? 1 : 4);
|
? forcedArmType
|
||||||
|
: ai.rng.choiceUsingWeightPair(buildRecruitArmTypeWeights(ai.general, warConfig.armTypes));
|
||||||
|
|
||||||
const candidates = (ai.unitSet?.crewTypes ?? [])
|
const candidates = (ai.unitSet?.crewTypes ?? [])
|
||||||
.filter((crew) => crew.armType === armType)
|
.filter((crew) => crew.armType === armType)
|
||||||
@@ -56,7 +94,31 @@ export const do징병 = (ai: GeneralAI) => {
|
|||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
return null;
|
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;
|
const crewTypeId = picked.id;
|
||||||
|
|
||||||
let crewAmount = crewAmountBase;
|
let crewAmount = crewAmountBase;
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
ITEM_KEYS,
|
ITEM_KEYS,
|
||||||
loadItemModules,
|
loadItemModules,
|
||||||
createInheritBuffModules,
|
createInheritBuffModules,
|
||||||
|
compileCrewTypeCatalog,
|
||||||
|
createCrewTypeWarTriggerRegistry,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
|
|||||||
const constValues = asRecord(config.const);
|
const constValues = asRecord(config.const);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...(unitSet ? { unitSet } : {}),
|
||||||
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0),
|
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0),
|
||||||
minAvailableRecruitPop: resolveNumber(constValues, ['minAvailableRecruitPop'], 30000),
|
minAvailableRecruitPop: resolveNumber(constValues, ['minAvailableRecruitPop'], 30000),
|
||||||
trainDelta: resolveNumber(constValues, ['trainDelta'], DEFAULT_TRAIN_DELTA),
|
trainDelta: resolveNumber(constValues, ['trainDelta'], DEFAULT_TRAIN_DELTA),
|
||||||
@@ -96,11 +99,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
|
|||||||
),
|
),
|
||||||
defaultSpecialDomestic: resolveOptionalString(constValues, ['defaultSpecialDomestic']),
|
defaultSpecialDomestic: resolveOptionalString(constValues, ['defaultSpecialDomestic']),
|
||||||
defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']),
|
defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']),
|
||||||
initialNationGenLimit: resolveNumber(
|
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], DEFAULT_INITIAL_NATION_GEN_LIMIT),
|
||||||
constValues,
|
|
||||||
['initialNationGenLimit'],
|
|
||||||
DEFAULT_INITIAL_NATION_GEN_LIMIT
|
|
||||||
),
|
|
||||||
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], DEFAULT_MAX_TECH_LEVEL),
|
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], DEFAULT_MAX_TECH_LEVEL),
|
||||||
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD),
|
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD),
|
||||||
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE),
|
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE),
|
||||||
@@ -153,10 +152,21 @@ export const buildReservedTurnDefinitions = async (options: {
|
|||||||
const itemRegistry = createItemModuleRegistry(itemModules);
|
const itemRegistry = createItemModuleRegistry(itemModules);
|
||||||
const itemActionModules = createItemActionModules(itemRegistry);
|
const itemActionModules = createItemActionModules(itemRegistry);
|
||||||
const inheritBuffModules = createInheritBuffModules();
|
const inheritBuffModules = createInheritBuffModules();
|
||||||
options.env.generalActionModules = [...(options.env.generalActionModules ?? []), ...itemActionModules.general];
|
const crewTypeCatalog = options.env.unitSet?.crewTypes?.length
|
||||||
options.env.warActionModules = [...(options.env.warActionModules ?? []), ...itemActionModules.war];
|
? compileCrewTypeCatalog(options.env.unitSet, createCrewTypeWarTriggerRegistry())
|
||||||
options.env.generalActionModules.push(inheritBuffModules.general);
|
: null;
|
||||||
options.env.warActionModules.push(inheritBuffModules.war);
|
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 generalSpecs = await loadGeneralTurnCommandSpecs(options.commandProfile.general);
|
||||||
const nationSpecs = await loadNationTurnCommandSpecs(options.commandProfile.nation);
|
const nationSpecs = await loadNationTurnCommandSpecs(options.commandProfile.nation);
|
||||||
|
|||||||
@@ -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],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
startup when a unit/item/trait references an unregistered trigger. Compose
|
||||||
personality, domestic/war specialty, item, inheritance, nation, and unit
|
personality, domestic/war specialty, item, inheritance, nation, and unit
|
||||||
triggers in the live turn-daemon battle path.
|
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)
|
- Input snapshot format (seed, scenario, trigger inputs, game time)
|
||||||
- Deterministic RNG test harness guidelines
|
- Deterministic RNG test harness guidelines
|
||||||
- Output comparison rules (sorting, tolerances, diff granularity)
|
- 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)
|
## 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`,
|
- [AI suggestion] Split gateway orchestration into immutable `Release`,
|
||||||
versioned `Ruleset`, `ProfileInstance`, `Deployment`, and first-class
|
versioned `Ruleset`, `ProfileInstance`, `Deployment`, and first-class
|
||||||
`AdminJob` records. Store artifact/API/resource digests and an auditable job
|
`AdminJob` records. Store artifact/API/resource digests and an auditable job
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
|
import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
|
||||||
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
|
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
|
||||||
|
import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
|
||||||
|
|
||||||
export interface TurnCommandItemCatalogEntry {
|
export interface TurnCommandItemCatalogEntry {
|
||||||
slot: 'horse' | 'weapon' | 'book' | 'item';
|
slot: 'horse' | 'weapon' | 'book' | 'item';
|
||||||
@@ -12,6 +13,7 @@ export interface TurnCommandItemCatalogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnCommandEnv {
|
export interface TurnCommandEnv {
|
||||||
|
unitSet?: UnitSetDefinition;
|
||||||
develCost: number;
|
develCost: number;
|
||||||
minAvailableRecruitPop?: number;
|
minAvailableRecruitPop?: number;
|
||||||
trainDelta: number;
|
trainDelta: number;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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<object>();
|
||||||
|
|
||||||
|
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<number>,
|
||||||
|
armTypes: ReadonlySet<number>
|
||||||
|
): 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<number, CompiledCrewType> => {
|
||||||
|
const definitions = unitSet.crewTypes ?? [];
|
||||||
|
if (definitions.length === 0) {
|
||||||
|
throw new Error(`Unit set has no crew types: ${unitSet.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const crewTypeIds = new Set<number>();
|
||||||
|
const crewTypeNames = new Set<string>();
|
||||||
|
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<number, CompiledCrewType>();
|
||||||
|
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 = <TriggerState extends GeneralTriggerState>(
|
||||||
|
byId: ReadonlyMap<number, CompiledCrewType>
|
||||||
|
): GeneralActionModule<TriggerState> => {
|
||||||
|
const modules = (context: GeneralActionContext<TriggerState>) =>
|
||||||
|
(byId.get(context.general.crewTypeId)?.actions ?? [])
|
||||||
|
.map((action) => action.general as GeneralActionModule<TriggerState> | undefined)
|
||||||
|
.filter((action): action is GeneralActionModule<TriggerState> => action !== undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getPreTurnExecuteTriggerList: (context) => {
|
||||||
|
const caller = new GeneralTriggerCaller<TriggerState>();
|
||||||
|
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<TriggerState>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createWarActionRouter = <TriggerState extends GeneralTriggerState>(
|
||||||
|
byId: ReadonlyMap<number, CompiledCrewType>,
|
||||||
|
triggerRegistry: WarTriggerRegistry
|
||||||
|
): WarActionModule<TriggerState> => {
|
||||||
|
const compiled = (context: WarActionContext<TriggerState>) => byId.get(context.general.crewTypeId);
|
||||||
|
const modules = (context: WarActionContext<TriggerState>) =>
|
||||||
|
(compiled(context)?.actions ?? [])
|
||||||
|
.map((action) => action.war as WarActionModule<TriggerState> | undefined)
|
||||||
|
.filter((action): action is WarActionModule<TriggerState> => action !== undefined);
|
||||||
|
const appendDefinitionTriggers = (
|
||||||
|
caller: WarTriggerCaller,
|
||||||
|
context: WarActionContext<TriggerState>,
|
||||||
|
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<TriggerState>;
|
||||||
|
crewTypeWarActionRouters.add(router);
|
||||||
|
return router;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isCrewTypeWarActionRouter = <TriggerState extends GeneralTriggerState>(
|
||||||
|
module: WarActionModule<TriggerState>
|
||||||
|
): 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),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './types.js';
|
||||||
|
export * from './registry.js';
|
||||||
|
export * from './catalog.js';
|
||||||
@@ -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]));
|
||||||
@@ -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<string, CrewTypeActionModule>;
|
||||||
|
|
||||||
|
export interface CompiledCrewType {
|
||||||
|
definition: CrewTypeDefinition;
|
||||||
|
actions: readonly CrewTypeActionModule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrewTypeCatalog {
|
||||||
|
unitSet: UnitSetDefinition;
|
||||||
|
byId: ReadonlyMap<number, CompiledCrewType>;
|
||||||
|
generalActionModule: GeneralActionModule;
|
||||||
|
warActionModule: WarActionModule;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export * from './domain/entities.js';
|
|||||||
export type { RandomGenerator } from '@sammo-ts/common';
|
export type { RandomGenerator } from '@sammo-ts/common';
|
||||||
export * from './actions/index.js';
|
export * from './actions/index.js';
|
||||||
export * from './constraints/index.js';
|
export * from './constraints/index.js';
|
||||||
|
export * from './crewType/index.js';
|
||||||
export * from './diplomacy/index.js';
|
export * from './diplomacy/index.js';
|
||||||
export * from './economy/index.js';
|
export * from './economy/index.js';
|
||||||
export * from './logging/index.js';
|
export * from './logging/index.js';
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ export class WarCrewType {
|
|||||||
return this.definition.rice;
|
return this.definition.rice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get magicCoef(): number {
|
||||||
|
return this.definition.magicCoef;
|
||||||
|
}
|
||||||
|
|
||||||
|
get cost(): number {
|
||||||
|
return this.definition.cost;
|
||||||
|
}
|
||||||
|
|
||||||
public reqCities(): boolean {
|
public reqCities(): boolean {
|
||||||
return this.definition.requirements.some((req) => req.type === 'ReqCities');
|
return this.definition.requirements.some((req) => req.type === 'ReqCities');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
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 { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
|
||||||
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||||
import { buildCrewTypeIndex as buildCrewTypeDefinitionIndex } from '@sammo-ts/logic/world/unitSet.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 { WarCrewType } from './crewType.js';
|
||||||
import { WarTriggerCaller, createWarTriggerEnv, type WarTriggerRegistry } from './triggers.js';
|
import { WarTriggerCaller, createWarTriggerEnv, type WarTriggerRegistry } from './triggers.js';
|
||||||
import type { WarBattleInput, WarBattleOutcome, WarGeneralInput, WarUnitReport } from './types.js';
|
import type { WarBattleInput, WarBattleOutcome, WarGeneralInput, WarUnitReport } from './types.js';
|
||||||
@@ -37,22 +39,26 @@ const buildWarCrewTypeIndex = (unitSet: WarBattleInput['unitSet']): Map<number,
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createPipeline = <TriggerState extends GeneralTriggerState>(
|
const createPipeline = <TriggerState extends GeneralTriggerState>(
|
||||||
input: WarGeneralInput<TriggerState>
|
input: WarGeneralInput<TriggerState>,
|
||||||
): WarActionPipeline<TriggerState> => new WarActionPipeline(input.modules ?? []);
|
crewTypeModule: WarActionModule<TriggerState>
|
||||||
|
): WarActionPipeline<TriggerState> => {
|
||||||
|
const modules = input.modules ?? [];
|
||||||
|
if (modules.some((module) => module && isCrewTypeWarActionRouter(module))) {
|
||||||
|
return new WarActionPipeline(modules);
|
||||||
|
}
|
||||||
|
return new WarActionPipeline([crewTypeModule, ...modules]);
|
||||||
|
};
|
||||||
|
|
||||||
const appendCrewTypeTriggers = (
|
const appendCrewTypeTriggers = (
|
||||||
caller: WarTriggerCaller,
|
caller: WarTriggerCaller,
|
||||||
unit: WarUnit,
|
unit: WarUnit,
|
||||||
names: string[],
|
names: string[],
|
||||||
registry: WarTriggerRegistry | undefined
|
registry: WarTriggerRegistry
|
||||||
): void => {
|
): void => {
|
||||||
if (!registry) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const factory = registry[name];
|
const factory = registry[name];
|
||||||
if (!factory) {
|
if (!factory) {
|
||||||
continue;
|
throw new Error(`Unknown crew type war trigger: ${name}`);
|
||||||
}
|
}
|
||||||
const trigger = factory(unit);
|
const trigger = factory(unit);
|
||||||
if (!trigger) {
|
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();
|
const caller = new WarTriggerCaller();
|
||||||
if (unit instanceof WarUnitGeneral) {
|
if (unit instanceof WarUnitGeneral) {
|
||||||
const context = unit.getActionContext();
|
const context = unit.getActionContext();
|
||||||
caller.merge(unit.getActionPipeline().getBattleInitTriggerList(context));
|
caller.merge(unit.getActionPipeline().getBattleInitTriggerList(context));
|
||||||
|
} else {
|
||||||
|
appendCrewTypeTriggers(caller, unit, unit.getCrewType().initSkillTrigger, registry);
|
||||||
}
|
}
|
||||||
appendCrewTypeTriggers(caller, unit, unit.getCrewType().initSkillTrigger, registry);
|
|
||||||
return caller;
|
return caller;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildBattlePhaseTriggers = (unit: WarUnit, registry: WarTriggerRegistry | undefined): WarTriggerCaller => {
|
const buildBattlePhaseTriggers = (unit: WarUnit, registry: WarTriggerRegistry): WarTriggerCaller => {
|
||||||
const caller = new WarTriggerCaller();
|
const caller = new WarTriggerCaller();
|
||||||
if (unit instanceof WarUnitGeneral) {
|
if (unit instanceof WarUnitGeneral) {
|
||||||
appendCrewTypeTriggers(caller, unit, ['che_필살'], registry);
|
if (registry['che_필살']) {
|
||||||
|
appendCrewTypeTriggers(caller, unit, ['che_필살'], registry);
|
||||||
|
}
|
||||||
const context = unit.getActionContext();
|
const context = unit.getActionContext();
|
||||||
caller.merge(unit.getActionPipeline().getBattlePhaseTriggerList(context));
|
caller.merge(unit.getActionPipeline().getBattlePhaseTriggerList(context));
|
||||||
|
} else {
|
||||||
|
appendCrewTypeTriggers(caller, unit, unit.getCrewType().phaseSkillTrigger, registry);
|
||||||
}
|
}
|
||||||
appendCrewTypeTriggers(caller, unit, unit.getCrewType().phaseSkillTrigger, registry);
|
|
||||||
return caller;
|
return caller;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -196,10 +206,14 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
|||||||
// process_war.php 전투 루프를 순수 로직으로 이식한다.
|
// process_war.php 전투 루프를 순수 로직으로 이식한다.
|
||||||
const rng = input.rng ?? new RandUtil(LiteHashDRBG.build(input.seed ?? ''));
|
const rng = input.rng ?? new RandUtil(LiteHashDRBG.build(input.seed ?? ''));
|
||||||
const loggerFactory = input.loggerFactory ?? defaultLoggerFactory;
|
const loggerFactory = input.loggerFactory ?? defaultLoggerFactory;
|
||||||
const triggerRegistry = input.triggerRegistry;
|
const triggerRegistry: WarTriggerRegistry = {
|
||||||
|
...createCrewTypeWarTriggerRegistry(),
|
||||||
|
...(input.triggerRegistry ?? {}),
|
||||||
|
};
|
||||||
|
const crewTypeCatalog = compileCrewTypeCatalog(input.unitSet, triggerRegistry);
|
||||||
|
|
||||||
const crewTypeIndex = buildWarCrewTypeIndex(input.unitSet);
|
const crewTypeIndex = buildWarCrewTypeIndex(input.unitSet);
|
||||||
const attackerPipeline = createPipeline(input.attacker);
|
const attackerPipeline = createPipeline(input.attacker, crewTypeCatalog.warActionModule);
|
||||||
const attackerLogger =
|
const attackerLogger =
|
||||||
input.attacker.logger ??
|
input.attacker.logger ??
|
||||||
loggerFactory({
|
loggerFactory({
|
||||||
@@ -251,7 +265,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
|||||||
false,
|
false,
|
||||||
resolveCrewType(crewTypeIndex, defender.general.crewTypeId),
|
resolveCrewType(crewTypeIndex, defender.general.crewTypeId),
|
||||||
defenderLogger,
|
defenderLogger,
|
||||||
createPipeline(defender)
|
createPipeline(defender, crewTypeCatalog.warActionModule)
|
||||||
);
|
);
|
||||||
if (computeBattleOrder(unit, attackerUnit) <= 0) {
|
if (computeBattleOrder(unit, attackerUnit) <= 0) {
|
||||||
continue;
|
continue;
|
||||||
@@ -601,9 +615,14 @@ export const resolveDefenderOrder = <TriggerState extends GeneralTriggerState =
|
|||||||
): number[] => {
|
): number[] => {
|
||||||
const rng = input.rng ?? new RandUtil(LiteHashDRBG.build(input.seed ?? ''));
|
const rng = input.rng ?? new RandUtil(LiteHashDRBG.build(input.seed ?? ''));
|
||||||
const loggerFactory = input.loggerFactory ?? defaultLoggerFactory;
|
const loggerFactory = input.loggerFactory ?? defaultLoggerFactory;
|
||||||
|
const triggerRegistry: WarTriggerRegistry = {
|
||||||
|
...createCrewTypeWarTriggerRegistry(),
|
||||||
|
...(input.triggerRegistry ?? {}),
|
||||||
|
};
|
||||||
|
const crewTypeCatalog = compileCrewTypeCatalog(input.unitSet, triggerRegistry);
|
||||||
|
|
||||||
const crewTypeIndex = buildWarCrewTypeIndex(input.unitSet);
|
const crewTypeIndex = buildWarCrewTypeIndex(input.unitSet);
|
||||||
const attackerPipeline = createPipeline(input.attacker);
|
const attackerPipeline = createPipeline(input.attacker, crewTypeCatalog.warActionModule);
|
||||||
const attackerLogger =
|
const attackerLogger =
|
||||||
input.attacker.logger ??
|
input.attacker.logger ??
|
||||||
loggerFactory({
|
loggerFactory({
|
||||||
@@ -640,7 +659,7 @@ export const resolveDefenderOrder = <TriggerState extends GeneralTriggerState =
|
|||||||
false,
|
false,
|
||||||
resolveCrewType(crewTypeIndex, defender.general.crewTypeId),
|
resolveCrewType(crewTypeIndex, defender.general.crewTypeId),
|
||||||
defenderLogger,
|
defenderLogger,
|
||||||
createPipeline(defender)
|
createPipeline(defender, crewTypeCatalog.warActionModule)
|
||||||
);
|
);
|
||||||
if (computeBattleOrder(unit, attackerUnit) <= 0) {
|
if (computeBattleOrder(unit, attackerUnit) <= 0) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ export * from './units.js';
|
|||||||
export * from './triggers.js';
|
export * from './triggers.js';
|
||||||
export * from './triggers/index.js';
|
export * from './triggers/index.js';
|
||||||
export * from './crewType.js';
|
export * from './crewType.js';
|
||||||
|
export * from './crewTypeTriggers.js';
|
||||||
export * from './utils.js';
|
export * from './utils.js';
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
||||||
|
import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js';
|
||||||
|
import { WarUnitCity, type WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||||
|
|
||||||
|
export class che_기병병종전투 extends BaseWarUnitTrigger {
|
||||||
|
constructor(unit: WarUnit) {
|
||||||
|
super(unit, TriggerPriority.Final + 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected actionWar(self: WarUnit, oppose: WarUnit): boolean {
|
||||||
|
if (!self.isAttacker()) {
|
||||||
|
oppose.multiplyWarPowerMultiply(1.02);
|
||||||
|
self.multiplyWarPowerMultiply(0.97);
|
||||||
|
} else if (oppose instanceof WarUnitCity) {
|
||||||
|
self.multiplyWarPowerMultiply(0.9);
|
||||||
|
} else {
|
||||||
|
oppose.multiplyWarPowerMultiply(0.97);
|
||||||
|
self.multiplyWarPowerMultiply(1.02);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
||||||
|
import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js';
|
||||||
|
import type { WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||||
|
|
||||||
|
export class che_방어력증가5p extends BaseWarUnitTrigger {
|
||||||
|
constructor(unit: WarUnit) {
|
||||||
|
super(unit, TriggerPriority.Final + 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected actionWar(self: WarUnit, oppose: WarUnit): boolean {
|
||||||
|
if (!self.isAttacker()) {
|
||||||
|
oppose.multiplyWarPowerMultiply(1 / 1.05);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||||
|
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
||||||
|
import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js';
|
||||||
|
import type { WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||||
|
|
||||||
|
export class che_선제사격시도 extends BaseWarUnitTrigger {
|
||||||
|
constructor(unit: WarUnit) {
|
||||||
|
super(unit, TriggerPriority.Begin + 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected actionWar(self: WarUnit, oppose: WarUnit): boolean {
|
||||||
|
if (self.getPhase() !== 0 && oppose.getPhase() !== 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (self.hasActivatedSkill('선제') || self.hasActivatedSkillOnLog('선제')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.activateSkill('특수', '선제');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class che_선제사격발동 extends BaseWarUnitTrigger {
|
||||||
|
constructor(unit: WarUnit) {
|
||||||
|
super(unit, TriggerPriority.Begin + 51);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected actionWar(self: WarUnit, oppose: WarUnit): boolean {
|
||||||
|
if (!self.hasActivatedSkill('선제')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (oppose.hasActivatedSkill('선제') && oppose.isAttacker()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addPhase(-1);
|
||||||
|
oppose.addPhase(-1);
|
||||||
|
if (oppose.hasActivatedSkill('선제')) {
|
||||||
|
self.multiplyWarPowerMultiply(2 / 3);
|
||||||
|
oppose.multiplyWarPowerMultiply(2 / 3);
|
||||||
|
oppose.getLogger().pushGeneralBattleDetailLog('서로 <C>선제 사격</>을 주고 받았다!</>', LogFormat.PLAIN);
|
||||||
|
self.getLogger().pushGeneralBattleDetailLog('서로 <C>선제 사격</>을 주고 받았다!</>', LogFormat.PLAIN);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
oppose.multiplyWarPowerMultiply(0);
|
||||||
|
self.multiplyWarPowerMultiply(2 / 3);
|
||||||
|
self.activateSkill('회피불가', '필살불가', '계략불가');
|
||||||
|
oppose.activateSkill('회피불가', '필살불가', '격노불가', '계략불가');
|
||||||
|
|
||||||
|
oppose.getLogger().pushGeneralBattleDetailLog('상대에게 <R>선제 사격</>을 받았다!</>', LogFormat.PLAIN);
|
||||||
|
self.getLogger().pushGeneralBattleDetailLog('상대에게 <C>선제 사격</>을 했다!</>', LogFormat.PLAIN);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,18 @@ export class che_저지_시도 extends BaseWarUnitTrigger {
|
|||||||
constructor(unit: WarUnit, raiseType: number = 0) {
|
constructor(unit: WarUnit, raiseType: number = 0) {
|
||||||
super(unit, TriggerPriority.Pre, raiseType);
|
super(unit, TriggerPriority.Pre, raiseType);
|
||||||
}
|
}
|
||||||
protected actionWar(u: WarUnit): boolean {
|
protected actionWar(self: WarUnit): boolean {
|
||||||
u.activateSkill('특수', '저지');
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,7 +50,7 @@ export class che_저지 extends BaseWarUnitTrigger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.getLogger().pushGeneralBattleDetailLog('상대를 <C>저지</>했다!', LogFormat.PLAIN);
|
self.getLogger().pushGeneralBattleDetailLog('상대를 <C>저지</>했다!', LogFormat.PLAIN);
|
||||||
oppose.getLogger().pushGeneralBattleDetailLog('<R>저지</>당했다!', LogFormat.PLAIN);
|
oppose.getLogger().pushGeneralBattleDetailLog('저지</>당했다!', LogFormat.PLAIN);
|
||||||
|
|
||||||
const calcDamage = oppose.getWarPower() * 0.9;
|
const calcDamage = oppose.getWarPower() * 0.9;
|
||||||
if (self instanceof WarUnitGeneral) {
|
if (self instanceof WarUnitGeneral) {
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import type { City, General, Nation } from '@sammo-ts/logic/domain/entities.js';
|
import type { City, General, Nation } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import type { CrewTypeDefinition, CrewTypeRequirement, MapDefinition, UnitSetDefinition } from './types.js';
|
import type { CrewTypeDefinition, CrewTypeRequirement, MapDefinition, UnitSetDefinition } from './types.js';
|
||||||
import {
|
import { asNullableStringArray, asNumber, asRecord, asString, asStringArray, isRecord } from '@sammo-ts/common';
|
||||||
asNullableStringArray,
|
|
||||||
asNumber,
|
|
||||||
asRecord,
|
|
||||||
asString,
|
|
||||||
asStringArray,
|
|
||||||
isRecord,
|
|
||||||
} from '@sammo-ts/common';
|
|
||||||
import { UnitSetDefinitionInputSchema } from '../resources/unitSetSchema.js';
|
import { UnitSetDefinitionInputSchema } from '../resources/unitSetSchema.js';
|
||||||
|
|
||||||
const DEFAULT_REGION_MAP: Record<string, number> = {
|
const DEFAULT_REGION_MAP: Record<string, number> = {
|
||||||
@@ -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 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 {
|
export interface CrewTypeAvailabilityContext {
|
||||||
general: General;
|
general: General;
|
||||||
nation: Nation | null;
|
nation: Nation | null;
|
||||||
|
|||||||
@@ -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> = {}
|
||||||
|
): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user