Files
ark-stronghold-protocol/test/match/balance.test.js
T
sganggs 620f88c0d8 Playtest #6 follow-up (v2.5.1): leader HP review — 直接乘算, one debuff instance, 999 layer cap, 限伤; elite to the board; 假想敌:胄 kit
- co-op leaders died within seconds: leader HP is the official data (40 values pinned); bond / strategy / item /
  机变 / 特质 "+X%" bonuses now add up (PRTS 直接乘算) instead of compounding; a same-named enemy debuff keeps one
  (strongest) instance per target (奥术 across a pair field); leader parts pass damage 1:1; fixed co-op pool (the user's call)
- official limits read from the client: each bond stops at 999 layers (MAX_GARRISON_STACK); a leader hit of 300000+ in
  boss / hidden battles is cancelled (MAX_BATTLE_DAMAGE, 限伤) — shared/constants.js switches, research 11
- a merge that consumes a deployed copy puts the elite on that tile and facing (user-confirmed); gold target highlight,
  promotion effect
- 假想敌:胄 per PRTS: one shell, shells within range, any drone death costs 2 % max HP, 剑/锤 sorties and 瘫痪
- DESIGN §20.10–§20.14; 2842 unit tests, all 28 browser suites green; Chrome = Node on boss fields
2026-10-02 04:19:28 +08:00

108 lines
5.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Balance: NO custom balance any more (research 08 §6: the official numbers only; data/tuning.json keeps result titles)
// and the competent-board model of tools/balance.mjs, which now only MEASURES difficulty (docs/BALANCE.md).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { GameData } from '../../server/match/gamedata.js';
import { buildNormalWave, setupMatchWaves } from '../../server/match/waves.js';
import { computeBonds } from '../../server/match/bondsMeta.js';
import { createRng } from '../../server/sim/rng.js';
import { DATA } from './harness.js';
import {
CURVES, planBoard, applyBoard, makeMatch, runNormal, runBoss, withoutTuning, waveNeeds, summarizeNormal,
} from '../../tools/balance.mjs';
const RAW = withoutTuning(DATA);
test('no custom balance: legacy tuning multipliers are ignored; enemy scale = the PRTS table; leader pool = bloodPoint', () => {
const tuning = {
modes: {
'*': { enemyHpMul: 0.5, enemyAtkMul: 0.9 },
mode_multi_hard: { enemyHpMul: { 3: 0.8, '*': 0.7 }, enemySpeedMul: 1.1, bossHpMul: { boss_1: 0.5, hidden: 0.25, '*': 0.9 }, flyPlaceholders: 'drop' },
},
};
const hard = new GameData({ ...RAW, tuning }, 'mode_multi_hard');
const base = new GameData(RAW, 'mode_multi_hard');
for (const r of [1, 3, 9, 13]) {
assert.deepEqual(hard.enemyScale(r), base.enemyScale(r));
const e = DATA.config.modes.mode_multi_hard.enemyScale[r];
assert.deepEqual(hard.enemyScale(r), { hpMul: e.hp, atkMul: e.atk, speedMul: e.speed }, `R${r}: the config (PRTS) table`);
}
assert.equal(hard.bossHpMul('boss_1'), 1);
// co-op: one pool = bloodPoint[difficulty] whatever the alive count (× alive / 4 only with config aliveScaling, off —
// DESIGN §20.10); solo: ×0.25 [ASSUMED, flagged]
for (const [modeId, key] of [['mode_multi_funny', 'FUNNY'], ['mode_multi_normal', 'NORMAL'], ['mode_multi_hard', 'HARD'], ['mode_multi_abyss', 'ABYSS']]) {
const gd = new GameData(RAW, modeId);
for (const b of ['boss_1', 'boss_5', 'boss_8']) {
assert.equal(gd.bossPoolHp(b), DATA.bosses[b].bloodPoint[key], `${modeId} ${b}`);
assert.equal(gd.bossPoolHp(b, 4), DATA.bosses[b].bloodPoint[key], `${modeId} ${b} four alive`);
assert.equal(gd.bossPoolHp(b, 2), DATA.bosses[b].bloodPoint[key], `${modeId} ${b} two alive: still the data value`);
}
}
assert.equal(DATA.config.bossHpScale.aliveScaling, false);
assert.equal(DATA.config.bossHpScale.aliveAssumed, true);
const solo = new GameData(RAW, 'mode_single_hard');
assert.equal(solo.bossPoolHp('boss_2'), Math.round(DATA.bosses.boss_2.bloodPoint.HARD * 0.25));
assert.equal(DATA.config.bossHpScale.soloAssumed, true);
// the waves carry exactly the table
const setup = setupMatchWaves(hard, createRng(3));
const w = buildNormalWave(hard, createRng(3), setup.factions, 3);
for (const s of w.spawns) assert.equal(s.mods.hpMul, base.baseEnemyScale(3).hpMul);
});
test('data/tuning.json holds only title rules (the enemy / leader multipliers were removed)', () => {
const t = DATA.tuning;
assert.ok(t && typeof t === 'object', 'data/tuning.json is loaded as data.tuning');
assert.deepEqual(Object.keys(t).filter((k) => !k.startsWith('_')).sort(), ['titles', 'version']);
for (const [id, o] of Object.entries(t.titles || {})) {
assert.ok(DATA.config.titles.some((x) => x.id === id), id);
assert.ok(['max', 'min'].includes(o.rule));
}
});
test('competent board model: curves (level, units, elites, items), a core bond active with its layers, wave-aware roles', () => {
const m = makeMatch({ data: RAW, mode: 'coop', difficulty: 'NORMAL', seed: 5, players: 1, rehearsal: 0 });
for (const r of [3, 8, 12]) {
m.round = r;
m.wave = buildNormalWave(m.gd, m.rngWaves, m.factions, r);
const rng = createRng(100 + r);
const plan = planBoard(m, r, rng);
assert.equal(plan.level, CURVES.level[r]);
assert.equal(plan.units.length, CURVES.units[r]);
assert.equal(new Set(plan.units).size, plan.units.length, 'distinct chess');
for (const id of plan.units) {
assert.ok(m.pool.has(id), `${id} not banned this match`);
assert.ok(m.gd.tierOf(id) <= plan.level, `${id} tier ≤ shop level`);
}
assert.ok(plan.elites.size >= Math.floor(CURVES.elites[r]) && plan.elites.size <= Math.ceil(CURVES.elites[r]));
const ps = m.order[0];
applyBoard(m, ps, plan, r, rng);
assert.equal(ps.deployCount, plan.units.length, 'every unit deployed');
const bonds = computeBonds(m.gd, ps);
assert.ok(plan.core && bonds[plan.core].active, `R${r}: core bond ${plan.core} active`);
const L = ps.layers[plan.core];
assert.ok(L >= CURVES.coreLayers[r] * 0.8 - 1 && L <= CURVES.coreLayers[r] * 1.2 + 1, `R${r}: core layers ${L}`);
const flyers = waveNeeds(m.gd, m.wave.spawns).flyShare;
const aa = [...ps.board.values()].filter((p) => p.kind === 'chess' && m.gd.chess(p.id).canHitFly && m.gd.chess(p.id).attackKind !== 'heal').length;
if (flyers > 0.2) assert.ok(aa >= 2, `R${r}: anti-air for a flying wave (${aa})`);
}
m.dispose();
});
test('runNormal / runBoss return consistent samples (real sim, full content)', () => {
const a = runNormal({ data: RAW, mode: 'coop', difficulty: 'FUNNY', round: 1, seed: 7, rehearsal: 0 });
assert.equal(a.round, 1);
assert.ok(a.killed + a.leaks <= a.total);
assert.ok(a.goalLeaks <= a.leaks);
assert.equal(a.timeLimit, 90, 'R1: 45 real seconds of the 2× battle');
assert.ok(a.time > 0 && a.time <= a.timeLimit + 1e-6);
const again = runNormal({ data: RAW, mode: 'coop', difficulty: 'FUNNY', round: 1, seed: 7, rehearsal: 0 });
assert.deepEqual(again, a, 'deterministic per seed');
const s = summarizeNormal([a, again]);
assert.equal(s.leaksAvg, a.leaks);
const b = runBoss({ data: RAW, mode: 'solo', difficulty: 'FUNNY', round: 9, seed: 3, tMax: 30 });
assert.equal(b.players, 1);
assert.ok(b.pool > 0 && b.dmg150 >= 0 && b.dmg150 <= b.pool);
assert.ok(b.ratio150 >= 0 && b.ratio150 <= 1);
assert.equal(b.teamLp0, 15);
});