// 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, }; }) ) ); }