Files
core2026/tools/balance-lab/followup.mjs
T

187 lines
8.8 KiB
JavaScript

import { BASE_UNITS, nativeBuild } from './design.mjs';
export const FOLLOWUP_SUITES = ['conditional', 'premium', 'sensitivity', 'recruit', 'attrition', 'counter'];
export function buildFollowupPlan(catalog, suite) {
const cells = [];
const add = (cell) => cells.push({ id: String(cells.length).padStart(5, '0'), ...cell });
const subjects = [1100, 1200, 1300, 1400, 1501];
if (suite === 'conditional') {
// Same effect registry, but opponents meet region/city conditions absent from basic-family screens.
const candidates = [
...catalog.war.map((trait) => ({ key: trait.key, generalPatch: { special2: trait.key } })),
...catalog.items.map((item) => ({ key: item.key, generalPatch: { [item.slot]: item.key } })),
];
for (const candidate of candidates)
for (const unit of subjects)
for (const opponent of [1104, 1407]) {
for (const role of ['attack', 'defend'])
add({
...candidate,
category: suite,
unit,
opponent,
build: nativeBuild(unit),
opponentBuild: nativeBuild(opponent),
role,
mode: 'field',
paired: true,
});
}
for (const opponent of [1100, 1101, 1104, 1407])
for (const role of ['attack', 'defend']) {
add({
category: suite,
key: 'che_척사+che_척사_오악진형도',
unit: 1100,
build: 'martial',
opponent,
opponentBuild: nativeBuild(opponent),
role,
mode: 'field',
paired: true,
generalPatch: { special2: 'che_척사', item: 'che_척사_오악진형도' },
factorialPatches: [{ special2: 'che_척사' }, { item: 'che_척사_오악진형도' }],
});
}
} else if (suite === 'premium') {
const keys = [
'che_명마_12_옥란백용구',
'che_명마_07_백상',
'che_불굴_상편',
'che_위압_조목삭',
'che_회피_태평요술',
];
for (const key of keys) {
const item = catalog.items.find((entry) => entry.key === key);
if (!item) throw new Error(`Unknown premium candidate ${key}`);
// Horse: persistent buyable +6 horse. Tool: empty slot and recurring consumable are separate controls.
const controls = item.slot === 'horse' ? ['che_명마_06_흑색마'] : [null, 'che_훈련_청주'];
for (const control of controls)
for (const unit of subjects)
for (const opponent of [1100, 1200, 1300, 1400, 1104, 1407]) {
for (const role of ['attack', 'defend'])
add({
category: suite,
key,
unit,
opponent,
build: nativeBuild(unit),
opponentBuild: nativeBuild(opponent),
role,
mode: 'field',
paired: true,
baseline: control ?? 'empty',
generalPatch: { [item.slot]: key },
controlPatch: { [item.slot]: control },
});
}
}
} else if (suite === 'counter') {
for (const special2 of [null, 'che_반계', 'che_격노'])
for (const unit of subjects)
for (const opponent of [1100, 1300, 1400, 1407])
for (const role of ['attack', 'defend'])
add({
category: suite,
key: 'che_진압_박혁론',
unit,
opponent,
build: nativeBuild(unit),
opponentBuild: nativeBuild(opponent),
role,
mode: 'field',
paired: true,
condition: special2 ?? 'no-opponent-trait',
opponentPatch: { special2 },
generalPatch: { item: 'che_진압_박혁론' },
});
} else if (suite === 'attrition') {
for (const key of ['che_명마_12_옥란백용구', 'che_불굴_상편']) {
const item = catalog.items.find((entry) => entry.key === key);
if (!item) throw new Error(`Unknown attrition candidate ${key}`);
const control = item.slot === 'horse' ? 'che_명마_06_흑색마' : null;
for (const unit of subjects)
for (const crewFraction of [1, 0.5, 0.25])
for (const opponent of [1100, 1200, 1300, 1407])
for (const role of ['attack', 'defend'])
add({
category: suite,
key,
unit,
opponent,
build: nativeBuild(unit),
opponentBuild: nativeBuild(opponent),
role,
mode: 'field',
paired: true,
budget: 'recruited',
wallet: 100000,
quality: 'trained',
crewFraction,
condition: `remaining-${crewFraction}`,
baseline: control ?? 'empty',
generalPatch: { [item.slot]: key },
controlPatch: { [item.slot]: control },
});
}
} else if (suite === 'sensitivity') {
for (const [condition, patch] of Object.entries({
early: { year: 183, tech: 1000, dex: 0, readiness: 80, statScale: 0.8 },
trained: { year: 200, tech: 3000, dex: 10000, readiness: 100 },
late: { year: 220, tech: 6000, dex: 50000, readiness: 110, statScale: 1.4 },
}))
for (const unit of BASE_UNITS)
for (const opponent of BASE_UNITS) {
for (const role of ['attack', 'defend'])
add({
category: suite,
key: String(unit),
unit,
opponent,
build: nativeBuild(unit),
opponentBuild: nativeBuild(opponent),
role,
mode: 'field',
condition,
...patch,
});
}
} else if (suite === 'recruit') {
const variants = [
{ key: 'che_징병', generalPatch: { special2: 'che_징병' } },
{ key: 'che_징병_낙주', generalPatch: { item: 'che_징병_낙주' } },
{
key: 'che_명마_12_옥란백용구',
generalPatch: { horse: 'che_명마_12_옥란백용구' },
controlPatch: { horse: 'che_명마_06_흑색마' },
},
...['보병', '궁병', '기병'].map((name) => ({
key: `che_${name}`,
generalPatch: { special2: `che_${name}` },
})),
];
for (const candidate of variants)
for (const unit of subjects)
for (const wallet of [1500, 5000]) {
for (const quality of ['fresh', 'trained'])
for (const opponent of [1100, 1300, 1400]) {
add({
...candidate,
category: suite,
unit,
opponent,
build: nativeBuild(unit),
opponentBuild: nativeBuild(opponent),
role: 'attack',
mode: 'field',
paired: true,
budget: 'recruited',
wallet,
quality,
});
}
}
} else throw new Error(`Unknown follow-up suite ${suite}`);
return cells;
}