merge: scenario 2400 long-run parity

This commit is contained in:
2026-08-04 18:19:53 +00:00
30 changed files with 517 additions and 183 deletions
@@ -27,6 +27,10 @@ export const resolveConstraintEnv = (
month: world.currentMonth,
startYear,
relYear,
// Ref asks each concrete command for its full constraints while the AI
// is still choosing a command. Cost-bearing commands therefore need
// the current yearly game_env.develcost at this boundary as well.
develCost: env.develCost,
openingPartYear: env.openingPartYear,
minAvailableRecruitPop: env.minAvailableRecruitPop,
...(Number.isFinite(killturn) ? { killturn } : {}),
@@ -1,18 +1,19 @@
import { GeneralActionPipeline } from '@sammo-ts/logic';
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js';
import type { GeneralAI } from '../core.js';
import { asRecord, readMetaNumber, valueFit } from '../../aiUtils.js';
export const do금쌀구매 = (ai: GeneralAI) => {
const traceEnabled = (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id));
const traceEnabled = [
...(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []),
...(process.env.GUI_PARITY_CORE_TRACE_GENERAL_IDS?.split(',') ?? []),
].includes(String(ai.general.id));
const trace = (stage: string, values: Record<string, unknown> = {}) => {
if (!traceEnabled) {
return;
}
process.stderr.write(
`AI_ECONOMY_TRACE ${JSON.stringify({ generalId: ai.general.id, stage, ...values })}\n`
);
process.stderr.write(`AI_ECONOMY_TRACE ${JSON.stringify({ generalId: ai.general.id, stage, ...values })}\n`);
};
const city = ai.city;
if (!city) {
@@ -55,35 +56,26 @@ export const do금쌀구매 = (ai: GeneralAI) => {
const tech = readMetaNumber(asRecord(ai.nation?.meta ?? {}), 'tech', 0);
const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership);
const crewAmount = fullLeadership * 100;
const rawGoldCost = crewType ? (crewType.cost * getTechCost(tech) * crewAmount) / 100 : 0;
const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []);
const goldCost = Math.round(
actionPipeline.onCalcDomestic(
{
general: ai.general,
nation: ai.nation ?? undefined,
...(ai.worldRef
? {
worldView: {
listGenerals: () => ai.worldRef!.listGenerals(),
listGeneralsByCity: (cityId: number) =>
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
listNations: () => ai.worldRef!.listNations(),
},
}
: {}),
time: {
year: ai.world.currentYear,
month: ai.world.currentMonth,
startYear: ai.startYear,
},
},
'징병',
'cost',
rawGoldCost,
{ armType: crewType?.armType ?? 0 }
) * (ai.generalPolicy.can('모병') ? 2 : 1)
);
const recruitContext = {
general: ai.general,
nation: ai.nation ?? undefined,
...(ai.worldRef
? {
worldView: {
listGenerals: () => ai.worldRef!.listGenerals(),
listGeneralsByCity: (cityId: number) =>
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
listNations: () => ai.worldRef!.listNations(),
},
}
: {}),
time: { year: ai.world.currentYear, month: ai.world.currentMonth, startYear: ai.startYear },
};
const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv);
const goldCost = crewType
? recruitment.getCost(recruitContext, crewType.id, crewAmount, crewType).gold *
(ai.generalPolicy.can('모병') ? 2 : 1)
: 0;
const riceCost = crewType ? (crewType.rice * getTechCost(tech) * crewAmount) / 100 : 0;
trace('recruit-cost', {
crewTypeId: crewType?.id ?? null,
@@ -145,7 +137,9 @@ export const do금쌀구매 = (ai: GeneralAI) => {
ai.aiConst.maxResourceActionAmount
);
if (amount >= ai.nationPolicy.minimumResourceActionAmount) {
return ai.buildGeneralCandidate('che_군량매매', { buyRice: false, amount }, '금쌀구매');
const result = ai.buildGeneralCandidate('che_군량매매', { buyRice: false, amount }, '금쌀구매');
trace('sell', { amount, minimumResourceActionAmount: ai.nationPolicy.minimumResourceActionAmount, result });
return result;
}
}
@@ -51,12 +51,7 @@ const getFullLeadership = (ai: GeneralAI, general: TurnGeneral): number => {
return Math.max(0, Math.min(general.stats.leadership + officerBonus, maxStat));
};
const getCrewGoldCost = (
ai: GeneralAI,
general: TurnGeneral,
baseMultiplier: number,
finalMultiplier = 1
): number => {
const getCrewGoldCost = (ai: GeneralAI, general: TurnGeneral, baseMultiplier: number, finalMultiplier = 1): number => {
const crewType = findCrewTypeById(ai.unitSet, general.crewTypeId ?? ai.commandEnv.defaultCrewTypeId);
const tech = readMetaNumber(asRecord(ai.nation?.meta), 'tech', 0);
// Ref evaluates costWithTech() first, including its `/ 100`, and then
@@ -64,16 +59,15 @@ const getCrewGoldCost = (
// Keeping that operation order is observable at exact resource boundaries
// (for example 3036 versus 3036.0000000000005).
return (
((((crewType?.cost ?? 0) * getTechCost(tech) * getFullLeadership(ai, general)) / 100) * 100 *
baseMultiplier) *
(((crewType?.cost ?? 0) * getTechCost(tech) * getFullLeadership(ai, general)) / 100) *
100 *
baseMultiplier *
finalMultiplier
);
};
const sortedByResource = (generals: Record<number, TurnGeneral>, resource: ResourceName, descending = false) =>
Object.values(generals).sort((lhs, rhs) =>
descending ? rhs[resource] - lhs[resource] : lhs[resource] - rhs[resource]
);
const sortByResource = (generals: TurnGeneral[], resource: ResourceName, descending = false) =>
generals.sort((lhs, rhs) => (descending ? rhs[resource] - lhs[resource] : lhs[resource] - rhs[resource]));
const canUseGeneral = (general: TurnGeneral): boolean =>
readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`) > 5;
@@ -88,9 +82,10 @@ export const do유저장긴급포상 = (ai: GeneralAI) => {
['gold', ai.nationPolicy.reqHumanWarUrgentGold],
['rice', ai.nationPolicy.reqHumanWarUrgentRice],
];
const userWarGenerals = Object.values(ai.userWarGenerals);
for (const [resKey, minimum] of resourceMap) {
const generals = sortedByResource(ai.userWarGenerals, resKey);
const generals = sortByResource(userWarGenerals, resKey);
for (const [index, general] of generals.entries()) {
if (general[resKey] >= minimum) {
break;
@@ -112,7 +107,10 @@ export const do유저장긴급포상 = (ai: GeneralAI) => {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]);
candidates.push([
{ destGeneralId: general.id, amount, isGold: resKey === 'gold' },
generals.length - index,
]);
}
}
@@ -139,12 +137,13 @@ export const do유저장포상 = (ai: GeneralAI) => {
ai.nationPolicy.reqHumanDevelRice,
],
];
const userGenerals = Object.values(ai.userGenerals);
for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
if (nation[resKey] < nationMinimum) {
continue;
}
const generals = sortedByResource(ai.userGenerals, resKey);
const generals = sortByResource(userGenerals, resKey);
for (const [index, general] of generals.entries()) {
if (general[resKey] >= warMinimum) {
break;
@@ -171,7 +170,10 @@ export const do유저장포상 = (ai: GeneralAI) => {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]);
candidates.push([
{ destGeneralId: general.id, amount, isGold: resKey === 'gold' },
generals.length - index,
]);
}
}
@@ -188,12 +190,13 @@ export const doNPC긴급포상 = (ai: GeneralAI) => {
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold / 2],
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice / 2],
];
const npcWarGenerals = Object.values(ai.npcWarGenerals);
for (const [resKey, nationMinimum, minimum] of resourceMap) {
if (nation[resKey] < nationMinimum) {
continue;
}
const generals = sortedByResource(ai.npcWarGenerals, resKey);
const generals = sortByResource(npcWarGenerals, resKey);
for (const [index, general] of generals.entries()) {
if (general[resKey] >= minimum) {
break;
@@ -215,7 +218,10 @@ export const doNPC긴급포상 = (ai: GeneralAI) => {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]);
candidates.push([
{ destGeneralId: general.id, amount, isGold: resKey === 'gold' },
generals.length - index,
]);
}
}
@@ -232,13 +238,15 @@ export const doNPC포상 = (ai: GeneralAI) => {
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
];
const npcWarGenerals = Object.values(ai.npcWarGenerals);
const npcCivilGenerals = Object.values(ai.npcCivilGenerals);
for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
if (nation[resKey] < nationMinimum) {
continue;
}
const warGenerals = sortedByResource(ai.npcWarGenerals, resKey);
const civilGenerals = sortedByResource(ai.npcCivilGenerals, resKey);
const warGenerals = sortByResource(npcWarGenerals, resKey);
const civilGenerals = sortByResource(npcCivilGenerals, resKey);
const weightBase = Math.max(warGenerals.length, civilGenerals.length);
for (const [index, general] of warGenerals.entries()) {
if (general[resKey] >= warMinimum) {
@@ -308,9 +316,11 @@ export const doNPC몰수 = (ai: GeneralAI) => {
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
];
const npcWarGenerals = Object.values(ai.npcWarGenerals);
const npcCivilGenerals = Object.values(ai.npcCivilGenerals);
for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
for (const general of sortedByResource(ai.npcCivilGenerals, resKey, true)) {
for (const general of sortByResource(npcCivilGenerals, resKey, true)) {
if (general[resKey] <= civilMinimum * 1.5) {
break;
}
@@ -326,7 +336,7 @@ export const doNPC몰수 = (ai: GeneralAI) => {
continue;
}
const takeSmallAmount = nation[resKey] >= nationMinimum;
for (const general of sortedByResource(ai.npcWarGenerals, resKey, true)) {
for (const general of sortByResource(npcWarGenerals, resKey, true)) {
if (general[resKey] <= warMinimum * (takeSmallAmount ? 2 : 1)) {
break;
}