내부 밸런스 비교 CLI와 조건별 대조 실험을 추가한다
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
// Internal experiment design. No router, database, server seed or gameplay mutation.
|
||||
export const DESIGN_VERSION = 'balance-lab-v2';
|
||||
export const BUILDS = {
|
||||
martial: { leadership: 90, strength: 90, intel: 30 },
|
||||
scholar: { leadership: 90, strength: 30, intel: 90 },
|
||||
command: { leadership: 150, strength: 30, intel: 30 },
|
||||
hybrid: { leadership: 30, strength: 90, intel: 90 },
|
||||
};
|
||||
export const BASE_UNITS = [1100, 1200, 1300, 1400, 1500];
|
||||
export const nativeBuild = (unit) =>
|
||||
Math.floor(unit / 100) === 14 ? 'scholar' : Math.floor(unit / 100) === 15 ? 'command' : 'martial';
|
||||
|
||||
export function general(id, unit, build, crew = 7000) {
|
||||
return {
|
||||
no: id,
|
||||
name: `fixture-${id}`,
|
||||
nation: id,
|
||||
city: id,
|
||||
turntime: '2000-01-01 00:00:00',
|
||||
personal: null,
|
||||
special: 'None',
|
||||
special2: null,
|
||||
crew,
|
||||
crewtype: unit,
|
||||
atmos: 100,
|
||||
train: 100,
|
||||
...BUILDS[build],
|
||||
intel_exp: 0,
|
||||
book: null,
|
||||
strength_exp: 0,
|
||||
weapon: null,
|
||||
injury: 0,
|
||||
leadership_exp: 0,
|
||||
horse: null,
|
||||
item: null,
|
||||
explevel: 0,
|
||||
experience: 1000,
|
||||
dedication: 1000,
|
||||
officer_level: 1,
|
||||
officer_city: 0,
|
||||
gold: 100000,
|
||||
rice: 100000,
|
||||
dex1: 1000,
|
||||
dex2: 1000,
|
||||
dex3: 1000,
|
||||
dex4: 1000,
|
||||
dex5: 1000,
|
||||
defence_train: 0,
|
||||
recent_war: null,
|
||||
warnum: 0,
|
||||
killnum: 0,
|
||||
killcrew: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function city(id, wall) {
|
||||
return {
|
||||
city: id,
|
||||
nation: id,
|
||||
supply: 1,
|
||||
name: `fixture-city-${id}`,
|
||||
pop: 50000,
|
||||
agri: 1000,
|
||||
comm: 1000,
|
||||
secu: 1000,
|
||||
def: wall,
|
||||
wall,
|
||||
trust: 100,
|
||||
level: 5,
|
||||
pop_max: 100000,
|
||||
agri_max: 2000,
|
||||
comm_max: 2000,
|
||||
secu_max: 2000,
|
||||
def_max: Math.max(2000, wall),
|
||||
wall_max: Math.max(2000, wall),
|
||||
dead: 0,
|
||||
state: 0,
|
||||
conflict: '{}',
|
||||
};
|
||||
}
|
||||
|
||||
function nation(id, type = 'che_중립') {
|
||||
return {
|
||||
type,
|
||||
tech: 3000,
|
||||
level: 1,
|
||||
capital: id,
|
||||
nation: id,
|
||||
name: `fixture-nation-${id}`,
|
||||
gold: 100000,
|
||||
rice: 100000,
|
||||
gennum: 10,
|
||||
};
|
||||
}
|
||||
|
||||
export function makePayload(unitSet, cell, seed, treatment = true) {
|
||||
const { build, opponentBuild = build, unit, opponent, mode, role, budget = 'equal-crew' } = cell;
|
||||
if (!BUILDS[build] || !BUILDS[opponentBuild]) throw new Error('Unknown build');
|
||||
const lookup = (id) => {
|
||||
const found = unitSet.crewTypes.find((entry) => entry.id === id);
|
||||
if (!found) throw new Error(`Unknown unit ${id}`);
|
||||
return found;
|
||||
};
|
||||
const count = (id, buildKey) =>
|
||||
budget === 'capacity'
|
||||
? BUILDS[buildKey].leadership * 100
|
||||
: budget === 'equal-gold'
|
||||
? Math.min(BUILDS[buildKey].leadership * 100, Math.floor((7000 * 9) / lookup(id).cost))
|
||||
: Math.min(7000, BUILDS[buildKey].leadership * 100);
|
||||
const subject = general(role === 'attack' ? 1 : 2, unit, build, count(unit, build));
|
||||
const other = general(role === 'attack' ? 2 : 1, opponent, opponentBuild, count(opponent, opponentBuild));
|
||||
if (treatment) Object.assign(subject, cell.generalPatch ?? {});
|
||||
if (cell.condition === 'depleted') {
|
||||
subject.crew = Math.floor(subject.crew * 0.5);
|
||||
subject.train = 70;
|
||||
subject.atmos = 70;
|
||||
}
|
||||
const attack = role === 'attack' ? subject : other;
|
||||
const defend = role === 'attack' ? other : subject;
|
||||
const payload = {
|
||||
action: 'battle',
|
||||
repeatCnt: 1,
|
||||
seed,
|
||||
year: 200,
|
||||
month: 1,
|
||||
attackerGeneral: attack,
|
||||
attackerCity: city(1, 1000),
|
||||
attackerNation: nation(1),
|
||||
defenderGenerals:
|
||||
mode === 'siege'
|
||||
? []
|
||||
: mode === 'line'
|
||||
? [defend, { ...defend, no: 3, name: 'fixture-3' }, { ...defend, no: 4, name: 'fixture-4' }]
|
||||
: [defend],
|
||||
defenderCity: city(2, mode === 'field' ? 0 : (cell.wall ?? 1000)),
|
||||
defenderNation: nation(2),
|
||||
unitSet,
|
||||
scenarioEffect: null,
|
||||
config: {
|
||||
armPerPhase: 500,
|
||||
maxTrainByCommand: 100,
|
||||
maxAtmosByCommand: 100,
|
||||
maxTrainByWar: 110,
|
||||
maxAtmosByWar: 150,
|
||||
maxGeneralStat: 255,
|
||||
statUpgradeLimit: 30,
|
||||
maxTechLevel: 12,
|
||||
castleCrewTypeId: 1000,
|
||||
armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 },
|
||||
},
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
};
|
||||
if (treatment && cell.nationTrait) {
|
||||
payload[role === 'attack' ? 'attackerNation' : 'defenderNation'].type = cell.nationTrait;
|
||||
}
|
||||
if (mode === 'siege' && role !== 'attack') throw new Error('Siege subject must attack');
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function summarize(values) {
|
||||
if (!values.length || values.some((value) => !Number.isFinite(value))) throw new Error('Invalid samples');
|
||||
const n = values.length;
|
||||
const mean = values.reduce((a, b) => a + b, 0) / n;
|
||||
const variance = n > 1 ? values.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1) : null;
|
||||
// Normal-approximation interval, descriptive screening only; never a multiple-testing gate.
|
||||
const half = variance === null ? null : 1.96 * Math.sqrt(variance / n);
|
||||
const sorted = values.toSorted((a, b) => a - b);
|
||||
return {
|
||||
n,
|
||||
mean,
|
||||
sd: variance === null ? null : Math.sqrt(variance),
|
||||
ci95: half === null ? null : [mean - half, mean + half],
|
||||
p10: sorted[Math.floor((n - 1) * 0.1)],
|
||||
p50: sorted[Math.floor((n - 1) * 0.5)],
|
||||
p90: sorted[Math.floor((n - 1) * 0.9)],
|
||||
min: sorted[0],
|
||||
max: sorted[n - 1],
|
||||
};
|
||||
}
|
||||
|
||||
export function observe(outcome, payload, role) {
|
||||
const subjectId = role === 'attack' ? 1 : 2;
|
||||
const report = outcome.reports.find((entry) => entry.type === 'general' && entry.id === subjectId);
|
||||
if (!report) throw new Error('Subject report missing');
|
||||
const ownStart = role === 'attack' ? payload.attackerGeneral : payload.defenderGenerals[0];
|
||||
const ownEnd = role === 'attack' ? outcome.attacker : outcome.defenders[0];
|
||||
const enemyReports = outcome.reports.filter((entry) => entry.type === 'general' && entry.id !== subjectId);
|
||||
const fieldKills = enemyReports.reduce((sum, entry) => sum + entry.dead, 0);
|
||||
const wall = outcome.reports.find((entry) => entry.type === 'city');
|
||||
const enemyStart =
|
||||
role === 'attack'
|
||||
? payload.defenderGenerals.reduce((sum, entry) => sum + entry.crew, 0)
|
||||
: payload.attackerGeneral.crew;
|
||||
const enemyEnd =
|
||||
role === 'attack' ? outcome.defenders.reduce((sum, entry) => sum + entry.crew, 0) : outcome.attacker.crew;
|
||||
return {
|
||||
// Fractional loss difference is bounded and comparable across troop budgets, unlike kill/death ratios.
|
||||
exchange: enemyStart ? fieldKills / enemyStart - report.dead / ownStart.crew : null,
|
||||
fieldKills,
|
||||
dead: report.dead,
|
||||
remaining: ownEnd.crew,
|
||||
rice: ownStart.rice - ownEnd.rice,
|
||||
goldGain: ownEnd.gold - ownStart.gold,
|
||||
experienceGain: ownEnd.experience - ownStart.experience,
|
||||
dedicationGain: ownEnd.dedication - ownStart.dedication,
|
||||
injury: ownEnd.injury - ownStart.injury,
|
||||
phase: report.phase ?? 0,
|
||||
wallDamage: role === 'attack' ? (wall?.dead ?? 0) : 0,
|
||||
breakthrough: Number(outcome.conquered),
|
||||
annihilated: Number(ownEnd.crew === 0),
|
||||
enemyAnnihilated: enemyStart ? Number(enemyEnd === 0) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPlan(catalog, suite) {
|
||||
const cells = [];
|
||||
const add = (cell) => cells.push({ id: String(cells.length).padStart(5, '0'), ...cell });
|
||||
if (suite === 'families' || suite === 'tiers') {
|
||||
const candidates =
|
||||
suite === 'families'
|
||||
? catalog.units.filter((unit) => BASE_UNITS.includes(unit.id))
|
||||
: catalog.units.filter((unit) =>
|
||||
unit.requirements.some((req) => req.type === 'ReqTech' && req.tech === 3000)
|
||||
);
|
||||
for (const unit of candidates)
|
||||
for (const enemy of candidates) {
|
||||
for (const budget of ['equal-crew', 'equal-gold', 'capacity'])
|
||||
for (const role of ['attack', 'defend']) {
|
||||
add({
|
||||
category: suite,
|
||||
key: String(unit.id),
|
||||
unit: unit.id,
|
||||
opponent: enemy.id,
|
||||
build: nativeBuild(unit.id),
|
||||
opponentBuild: nativeBuild(enemy.id),
|
||||
budget,
|
||||
role,
|
||||
mode: 'field',
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (suite === 'interactions') {
|
||||
const pairs = [
|
||||
['che_공성', 'che_공성_묵자', 1501],
|
||||
['che_공성', 'che_행동_서촉지형도', 1501],
|
||||
['che_반계', 'che_서적_07_사마법', 1400],
|
||||
['che_반계', 'che_서적_12_산해경', 1400],
|
||||
['che_격노', 'che_격노_구정신단경', 1100],
|
||||
['che_집중', 'che_환술_논어집해', 1400],
|
||||
['che_환술', 'che_집중_전국책', 1400],
|
||||
['che_척사', 'che_척사_오악진형도', 1100],
|
||||
['che_위압', 'che_위압_조목삭', 1100],
|
||||
['che_징병', 'che_징병_낙주', 1501],
|
||||
];
|
||||
for (const [trait, key, unit] of pairs) {
|
||||
const item = catalog.items.find((entry) => entry.key === key);
|
||||
if (!item) throw new Error(`Missing interaction item ${key}`);
|
||||
for (const mode of ['field', 'siege', 'line'])
|
||||
for (const condition of ['ready', 'depleted']) {
|
||||
for (const wall of mode === 'siege' ? [1000, 5000] : [1000])
|
||||
add({
|
||||
category: 'interaction',
|
||||
key: `${trait}+${key}`,
|
||||
unit,
|
||||
build: nativeBuild(unit),
|
||||
opponent: 1400,
|
||||
opponentBuild: 'scholar',
|
||||
role: 'attack',
|
||||
mode,
|
||||
condition,
|
||||
paired: true,
|
||||
wall,
|
||||
generalPatch: { special2: trait, [item.slot]: key },
|
||||
factorialPatches: [{ special2: trait }, { [item.slot]: key }],
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (suite === 'units') {
|
||||
for (const unit of catalog.units)
|
||||
for (const build of Object.keys(BUILDS)) {
|
||||
for (const budget of ['equal-crew', 'equal-gold']) {
|
||||
for (const opponent of BASE_UNITS)
|
||||
for (const role of ['attack', 'defend']) {
|
||||
add({
|
||||
category: 'unit',
|
||||
key: String(unit.id),
|
||||
unit: unit.id,
|
||||
build,
|
||||
budget,
|
||||
opponent,
|
||||
role,
|
||||
mode: 'field',
|
||||
});
|
||||
}
|
||||
for (const wall of [1000, 5000])
|
||||
add({
|
||||
category: 'unit',
|
||||
key: String(unit.id),
|
||||
unit: unit.id,
|
||||
build,
|
||||
budget,
|
||||
opponent: 1100,
|
||||
role: 'attack',
|
||||
mode: 'siege',
|
||||
wall,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const treatments =
|
||||
suite === 'items'
|
||||
? catalog.items.map((item) => ({
|
||||
category: 'item',
|
||||
key: item.key,
|
||||
generalPatch: { [item.slot]: item.key },
|
||||
}))
|
||||
: suite === 'traits'
|
||||
? catalog.war.map((trait) => ({
|
||||
category: 'war',
|
||||
key: trait.key,
|
||||
generalPatch: { special2: trait.key },
|
||||
}))
|
||||
: suite === 'nations'
|
||||
? catalog.nations.map((trait) => ({
|
||||
category: 'nation',
|
||||
key: trait.key,
|
||||
nationTrait: trait.key,
|
||||
}))
|
||||
: null;
|
||||
if (!treatments) throw new Error(`Unknown suite ${suite}`);
|
||||
for (const treatment of treatments)
|
||||
for (const [build, unit] of [
|
||||
['martial', 1100],
|
||||
['martial', 1200],
|
||||
['martial', 1300],
|
||||
['scholar', 1400],
|
||||
['command', 1501],
|
||||
]) {
|
||||
for (const opponent of [1100, 1200, 1300, 1400, 1500])
|
||||
for (const role of ['attack', 'defend']) {
|
||||
add({
|
||||
...treatment,
|
||||
unit,
|
||||
build,
|
||||
opponent,
|
||||
opponentBuild: nativeBuild(opponent),
|
||||
role,
|
||||
mode: 'field',
|
||||
paired: true,
|
||||
});
|
||||
}
|
||||
for (const wall of [1000, 5000])
|
||||
add({
|
||||
...treatment,
|
||||
unit,
|
||||
build,
|
||||
opponent: 1100,
|
||||
role: 'attack',
|
||||
mode: 'siege',
|
||||
wall,
|
||||
paired: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
Reference in New Issue
Block a user