316 lines
13 KiB
JavaScript
316 lines
13 KiB
JavaScript
// Calculation-only use of the production recruitment resolver. No world mutation or DB access.
|
|
export function domainContext(payload, nation, time) {
|
|
return {
|
|
time,
|
|
maxTechLevel: 12,
|
|
nation: {
|
|
id: nation.nation,
|
|
typeCode: nation.type,
|
|
gold: nation.gold,
|
|
rice: nation.rice,
|
|
meta: { tech: nation.tech },
|
|
},
|
|
general: {
|
|
id: payload.no,
|
|
name: payload.name,
|
|
nationId: payload.nation,
|
|
cityId: payload.city,
|
|
troopId: 0,
|
|
stats: { leadership: payload.leadership, strength: payload.strength, intelligence: payload.intel },
|
|
role: {
|
|
specialWar: payload.special2,
|
|
specialDomestic: payload.special,
|
|
personality: payload.personal,
|
|
items: { horse: payload.horse, weapon: payload.weapon, book: payload.book, item: payload.item },
|
|
},
|
|
crew: 0,
|
|
crewTypeId: payload.crewtype,
|
|
injury: payload.injury,
|
|
gold: payload.gold,
|
|
rice: payload.rice,
|
|
train: payload.train,
|
|
atmos: payload.atmos,
|
|
experience: payload.experience,
|
|
dedication: payload.dedication,
|
|
officerLevel: payload.officer_level,
|
|
age: 20,
|
|
npcState: 0,
|
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
|
meta: {},
|
|
},
|
|
};
|
|
}
|
|
|
|
export function economicTools(logic, CommandResolver, items, war, nations) {
|
|
const itemMap = new Map(items.map((item) => [item.key, item]));
|
|
const warMap = new Map(war.map((trait) => [trait.key, trait]));
|
|
const nationMap = new Map(nations.map((trait) => [trait.key, trait]));
|
|
const modules = (payload, nation) =>
|
|
[
|
|
nationMap.get(nation.type),
|
|
warMap.get(payload.special2),
|
|
...['horse', 'weapon', 'book', 'item'].map((slot) => itemMap.get(payload[slot])),
|
|
].filter(Boolean);
|
|
function quote(payload, nation, time, unit, { gold = 5000, rice = 5000, population = 20000 } = {}) {
|
|
const context = domainContext(payload, nation, time);
|
|
const resolver = new CommandResolver(modules(payload, nation), {});
|
|
const capacity = resolver.resolveFullLeadership(context) * 100;
|
|
// Search the actual rounded resolver costs; population constraint uses the trait-adjusted draw.
|
|
let low = 99,
|
|
high = Math.floor(resolver.resolveLeadership(context) * 100);
|
|
const feasible = (amount) => {
|
|
const cost = resolver.getCost(context, unit.id, amount, unit);
|
|
return (
|
|
cost.gold <= gold &&
|
|
cost.rice <= rice &&
|
|
resolver.getRecruitPopulation(context, cost.applied) <= population
|
|
);
|
|
};
|
|
while (low < high) {
|
|
const middle = Math.ceil((low + high) / 2);
|
|
if (feasible(middle)) low = middle;
|
|
else high = middle - 1;
|
|
}
|
|
if (low < 100) return { capacity, crew: 0, gold: 0, rice: 0, population: 0, train: 0, atmos: 0 };
|
|
const cost = resolver.getCost(context, unit.id, low, unit);
|
|
return {
|
|
capacity,
|
|
crew: cost.applied,
|
|
gold: cost.gold,
|
|
rice: cost.rice,
|
|
population: resolver.getRecruitPopulation(context, cost.applied),
|
|
train: resolver.getTrain(context, unit),
|
|
atmos: resolver.getAtmos(context, unit),
|
|
};
|
|
}
|
|
function recruit(payload, cell) {
|
|
const reports = new Map();
|
|
for (const [general, nation] of [
|
|
[payload.attackerGeneral, payload.attackerNation],
|
|
...payload.defenderGenerals.map((general) => [general, payload.defenderNation]),
|
|
]) {
|
|
const unit = payload.unitSet.crewTypes.find((entry) => entry.id === general.crewtype);
|
|
const result = quote(general, nation, payload.time, unit, {
|
|
gold: cell.wallet,
|
|
rice: 5000,
|
|
population: 20000,
|
|
});
|
|
if (!result.crew) throw new Error('Recruitment budget cannot field the minimum 100 troops');
|
|
general.crew = result.crew;
|
|
general.gold = cell.wallet - result.gold;
|
|
general.rice = 5000 - result.rice;
|
|
general.train = cell.quality === 'fresh' ? result.train : 100;
|
|
general.atmos = cell.quality === 'fresh' ? result.atmos : 100;
|
|
reports.set(general.no, result);
|
|
}
|
|
const subject = cell.role === 'attack' ? payload.attackerGeneral : payload.defenderGenerals[0];
|
|
const report = reports.get(subject.no);
|
|
if (cell.crewFraction !== undefined) {
|
|
if (!(cell.crewFraction > 0 && cell.crewFraction <= 1)) throw new Error('Invalid remaining troop fraction');
|
|
// Prior attrition is a controlled snapshot, not a simulated earlier battle. Recruitment was paid in full.
|
|
subject.crew = Math.floor(subject.crew * cell.crewFraction);
|
|
report.remainingBeforeBattle = subject.crew;
|
|
}
|
|
return report;
|
|
}
|
|
function profile(payload, nation, time, module) {
|
|
const context = domainContext(payload, nation, time);
|
|
const pipeline = new logic.GeneralActionPipeline([module]);
|
|
const output = {
|
|
key: module.key,
|
|
stats: {},
|
|
statBaselines: {},
|
|
opposeStats: {},
|
|
domestic: {},
|
|
strategies: {},
|
|
events: Object.keys(module.eventHandlers ?? {}),
|
|
};
|
|
for (const stat of [
|
|
'leadership',
|
|
'strength',
|
|
'intelligence',
|
|
'experience',
|
|
'dedication',
|
|
'addDex',
|
|
'injuryProb',
|
|
'sabotageAttack',
|
|
'sabotageDefence',
|
|
]) {
|
|
const baseline = stat === 'injuryProb' ? 0.2 : 100;
|
|
output.statBaselines[stat] = baseline;
|
|
output.stats[stat] = pipeline.onCalcStat(context, stat, baseline);
|
|
output.opposeStats[stat] = pipeline.onCalcOpposeStat(context, stat, baseline);
|
|
}
|
|
for (const action of [
|
|
'농업',
|
|
'상업',
|
|
'기술',
|
|
'수비',
|
|
'성벽',
|
|
'치안',
|
|
'민심',
|
|
'인구',
|
|
'조달',
|
|
'계략',
|
|
'징병',
|
|
'모병',
|
|
'징집인구',
|
|
]) {
|
|
output.domestic[action] = Object.fromEntries(
|
|
['score', 'cost', 'rice', 'success', 'fail', 'train', 'atmos'].map((kind) => {
|
|
const baseline = ['success', 'fail'].includes(kind) ? 0.2 : 100;
|
|
return [
|
|
kind,
|
|
{ baseline, value: pipeline.onCalcDomestic(context, action, kind, baseline, { armType: 1 }) },
|
|
];
|
|
})
|
|
);
|
|
}
|
|
for (const action of ['의병모집', '급습', '수몰'])
|
|
output.strategies[action] = Object.fromEntries(
|
|
['delay', 'globalDelay'].map((kind) => [kind, pipeline.onCalcStrategic(context, action, kind, 100)])
|
|
);
|
|
return output;
|
|
}
|
|
return { quote, recruit, profile };
|
|
}
|
|
|
|
export function lifecycleProfiles(logic, common, payload, modules, seed, samples = 512) {
|
|
const rows = [];
|
|
for (const module of modules) {
|
|
const events = Object.keys(module.eventHandlers ?? {});
|
|
if (events.some((event) => event !== 'item.sold')) throw new Error(`Uncovered lifecycle event: ${module.key}`);
|
|
if (events.includes('item.sold'))
|
|
for (const year of [180, 200, 220]) {
|
|
let personal = 0,
|
|
national = 0,
|
|
gold = 0;
|
|
for (let index = 0; index < samples; index++) {
|
|
const context = domainContext(payload.attackerGeneral, payload.attackerNation, {
|
|
year,
|
|
month: 1,
|
|
startYear: 180,
|
|
});
|
|
const beforePersonal = context.general.gold + context.general.rice;
|
|
const beforeNational = context.nation.gold + context.nation.rice;
|
|
context.rng = new common.RandUtil(common.LiteHashDRBG.build(`${seed}/sale/${index}`));
|
|
logic.dispatchGeneralActionEventHandlers(
|
|
module.eventHandlers,
|
|
context,
|
|
logic.createGeneralActionEvent('item.sold', { itemKey: module.key, slot: module.slot })
|
|
);
|
|
personal += context.general.gold + context.general.rice - beforePersonal;
|
|
national += context.nation.gold + context.nation.rice - beforeNational;
|
|
gold += Number(context.general.gold > payload.attackerGeneral.gold);
|
|
}
|
|
rows.push({
|
|
key: module.key,
|
|
kind: 'sale-bonus',
|
|
year,
|
|
samples,
|
|
personalGain: personal / samples,
|
|
nationGain: national / samples,
|
|
goldFraction: gold / samples,
|
|
});
|
|
}
|
|
if (module.getPreTurnExecuteTriggerList) {
|
|
const totals = [0, 0, 0];
|
|
for (let index = 0; index < samples; index++) {
|
|
const context = domainContext(payload.attackerGeneral, payload.attackerNation, payload.time);
|
|
const patients = [
|
|
context.general,
|
|
{ ...structuredClone(context.general), id: 2 },
|
|
{ ...structuredClone(context.general), id: 3, nationId: 2 },
|
|
];
|
|
for (const general of patients) general.injury = 30;
|
|
context.worldView = { listGenerals: () => patients };
|
|
context.rng = new common.RandUtil(common.LiteHashDRBG.build(`${seed}/heal/${index}`));
|
|
const triggerContext = logic.createGeneralTriggerContext(context);
|
|
module.getPreTurnExecuteTriggerList(context)?.fire(triggerContext, {});
|
|
patients.forEach((patient, index) => {
|
|
totals[index] += Number(patient.injury === 0);
|
|
});
|
|
}
|
|
rows.push({
|
|
key: module.key,
|
|
kind: 'pre-turn-heal',
|
|
samples,
|
|
self: totals[0] / samples,
|
|
ally: totals[1] / samples,
|
|
enemy: totals[2] / samples,
|
|
});
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
export function strategyPlans(VolunteerResolver, payload, nations, items) {
|
|
const item = items.find((entry) => entry.key === 'che_전략_평만지장도');
|
|
if (!item) throw new Error('Missing strategy item');
|
|
return nations.flatMap((nation) =>
|
|
[false, true].flatMap((equipped) =>
|
|
[10, 30, 60].map((generals) => {
|
|
const context = domainContext(
|
|
payload.attackerGeneral,
|
|
{ ...payload.attackerNation, type: nation.key },
|
|
payload.time
|
|
);
|
|
const resolver = new VolunteerResolver([nation, ...(equipped ? [item] : [])], {
|
|
initialNationGenLimit: 10,
|
|
});
|
|
const delay = resolver.getPostDelay(context, generals);
|
|
const globalDelay = resolver.getGlobalDelay(context);
|
|
const horizon = 240;
|
|
return {
|
|
key: nation.key,
|
|
equipped,
|
|
generals,
|
|
delay,
|
|
globalDelay,
|
|
horizon,
|
|
// Only one repeated strategy, resources/volunteer limit unconstrained. Not all-strategy throughput.
|
|
usesWithResources: 1 + Math.floor((horizon - 1) / Math.max(delay, globalDelay)),
|
|
};
|
|
})
|
|
)
|
|
);
|
|
}
|
|
|
|
// Controlled planning model, not the monthly national simulation: fixed starting budget and
|
|
// turn cap expose resource vs labour bottlenecks while retaining real nation-hook coefficients.
|
|
export function nationPlans(profiles) {
|
|
const mixes = {
|
|
development: ['농업', '상업', '기술'],
|
|
defence: ['수비', '성벽', '치안'],
|
|
recovery: ['민심', '인구', '치안'],
|
|
};
|
|
return profiles.flatMap((profile) =>
|
|
Object.entries(mixes).flatMap(([role, actions]) =>
|
|
[600, 1200, 2400].map((budget) => {
|
|
let remaining = budget,
|
|
score = 0,
|
|
turns = 0;
|
|
while (turns < 12) {
|
|
const action = actions[turns % actions.length];
|
|
const metric = profile.domestic[action];
|
|
if (remaining + 1e-8 < metric.cost) break;
|
|
remaining -= metric.cost;
|
|
score += metric.score;
|
|
turns++;
|
|
}
|
|
return {
|
|
key: profile.key,
|
|
role,
|
|
budget,
|
|
turns,
|
|
score,
|
|
spent: budget - remaining,
|
|
goldIncomePer1000: profile.income.gold * 10,
|
|
riceIncomePer1000: profile.income.rice * 10,
|
|
populationGainPer1000: profile.income.pop * 10,
|
|
};
|
|
})
|
|
)
|
|
);
|
|
}
|