Files
ark-stronghold-protocol/test/match/bosshp.test.js
T
sganggs 4e58481706 feedback5 WY: the Final Assault / Hidden Core leader pool is bloodPoint × the players alive when the fight starts (solo × 1), replacing 「保持固定血量」; 胄's drone share passes 限伤 (from PR #209 by @qingjingshenghuo; the owner's decision of 2026-10-06)
Ported by hand from PR #209 by @qingjingshenghuo (its one-line data/config.json was not taken):
- server/match/gamedata.js bossPoolShareOf (used by GameData.bossPoolShare and by finalAssault.bossPoolHp for a plain
  game-data object): solo × `solo` (1); co-op with `perPlayer` true × `coop` (1) × the players alive at the boss
  round's start (bots and AI 托管 seats count, eliminated / departed seats do not; at most aliveFull 4; a count left
  out = a full team). The fixed pool stays restorable through config: bossHpScale perPlayer false + solo 0.25 (and its
  optional aliveScaling).
- tools/build-data.mjs: the global bossHpScale carries the rule (perPlayer true, solo 1); the modes' entries keep only
  bloodPointKey / unaffectedByEnemyScale. data/config.json regenerated offline — JSON compare: only bossHpScale (global
  and the 9 mode entries) changed.
- The problem #209 documented: a 胄 drone death is 2 % of the pool and 限伤 cancels a hit of ceil ≥ 300000, so the
  hidden 胄 终极 pool at 3 / 4 players (21.6M / 28.8M) would cancel every drone (432000 / 576000). The drone link is
  a share of the pool, no hit: Battle.loseHp takes noHitLimit and the link passes the limit [ASSUMED: the official
  client checks every damage modifier and PRTS calls it 真实伤害, but which max HP it reads is undocumented]; a part's
  传递 and every hit stay under the limit.

Golden (regenerated; why: the pool size):
- fields: boss-boss_1..7-pair/-solo and hidden-boss_8..10-pair/-solo (all 20 boss / hidden scenarios) — a pair
  field's pool × 2 (two players), a solo template's × 4 (solo × 1, was × 0.25); boss-boss_1-pair, boss-boss_1-solo,
  boss-boss_3-pair, boss-boss_3-solo, boss-boss_5-solo, boss-boss_7-pair and hidden-boss_9-solo now end at the
  200 s cap instead of clearing; the two 联防 scenarios are unchanged.
- matches: solo-FUNNY-2, solo-NORMAL-1, solo-HARD-1, solo-HARD-2 now lose the Final Assault (solo leaders have 4× the
  HP: team LP 0 with 93088–176395 of the pool left); solo-FUNNY-1, solo-NORMAL-2, solo-HARD-9-boosted (its Hidden Core
  too), coop2-NORMAL-3, coop4-FUNNY-6, coop2-ABYSS-10-boosted, coop2-NORMAL-70-diy still win with more leader damage
  (pool × players alive; solo-FUNNY-1 / solo-HARD-9-boosted end with 6 / 1 team LP less). Unchanged:
  coop2-NORMAL-8-serverrun and coop2-NORMAL-14-standins (one player left at R14: × 1 as before) and the matches that
  end before R14; roster / bonds / standins / diy unchanged.

Tests: test/match/boss-hp-scaling.test.js (from PR #209, adapted — every leader and difficulty × 1–4 players and solo;
R14 / R15 leave out two departed players; AI 托管 and bots count, an eliminated player does not; config restores the
fixed pool, the opposite of the PR's test; the plain-object fallback agrees; only the leader's HP scales);
test/content/enemies_bosses.test.js (hidden 胄 终极 at 3 / 4 players: the drone share lands, a hit of the same size is
cancelled — fails without the change). Updated on purpose: finalAssault, balance, bosshp, connection, docs-consistency
(the §20.9 record points to §25.13.4; the data carries perPlayer true), sim/playtest6_limits (the drone share lands
past the line), match/playtest6-bosspool (the 5 % knob halved to keep the 2-player pool's size: that test is about the
pool's arithmetic). Docs: PLAYING §7, DATA, META, BALANCE, SIM, design engine.md / network.md, the §20.9 / §20.10
records, research 00 / 01 / 08, the code comments, DESIGN §25.13.4 and the §25.10 table.
2026-10-06 21:41:49 +08:00

217 lines
13 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.
// Final Assault leader HP vs the official game (user report after playtest #6: "终极 / 绝境 leaders fell within a few
// seconds; with our layers the official leader never dies that fast"). DESIGN §20.10.
// * Pool: one pool for every boss field (official tip "最终攻势中,所有人将一起对敌方领袖造成伤害"), bloodPoint[difficulty]
// of data/bosses.json (= activity_table bossInfoDict bloodPoint / Normal / Hard / Abyss of the current data; PRTS
// 盟约记录's leader table is the older 11月18日 revision, 铳 险境 and 胄 / 铳 / 萨米 绝境 differ, no 终极 column) per
// player alive when the fight starts (DESIGN §25.13.4: the owner's decision of 2026-10-06, adopting PR #209).
// * Damage: the 卫戍 systems' "+X%" attribute bonuses are 直接乘算 — summed, not compounded (PRTS 盟约记录 / 游戏数据基础);
// v2.5 compounded them, which made stacked lineups kill the leaders 1.2–3× faster (more with more layers).
// Real bot matches to the Final Assault (real sim, server-run fields): every operator fighting the leader carries its
// bond / strategy / equipment bonuses in the additive bucket, both fields drain the one pool exactly once per hit.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { makeMatch, DATA } from './harness.js';
import { aggregateMods } from '../../server/sim/buffs.js';
import { bondBb } from '../../server/sim/content/bonds/addon/battle.js';
import { DataSource } from '../../server/sim/simdata.js';
import { createBattleFromSpec } from '../../server/sim/spec.js';
import { SharedBossPool } from '../../server/match/finalAssault.js';
/** 4 AI seats, co-op, LP 400 (they reach R14), +`layers` on every active bond at the boss round's prep. */
function toFinalAssault({ difficulty, seed, bossId, layers = 0 }) {
const seats = [0, 1, 2, 3].map((i) => ({ seat: i, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true }));
// instant: false — the boss fields wait for the test to step them (server-run, real sim)
const h = makeMatch({ mode: 'coop', difficulty, seats, seed, captureFrames: false, instant: false });
const m = h.m;
m.bossId = bossId;
m.start();
let last = '';
h.run(() => {
const k = `${m.phase}:${m.round}`;
if (k !== last) {
last = k;
if (m.phase === PHASE.PREP && m.round === 1) for (const ps of m.players.values()) ps.lp = 400;
if (layers && m.phase === PHASE.PREP && m.round === m.gd.bossRound) {
for (const ps of m.alivePlayers()) {
for (const id of m.gd.bondIds) if (ps.bonds[id] && ps.bonds[id].active) ps.layers[id] = (ps.layers[id] || 0) + layers;
ps.recompute();
}
}
}
return h.ended != null || m.phase === PHASE.FINAL_ASSAULT;
}, { maxSteps: 8e6 });
assert.equal(m.phase, PHASE.FINAL_ASSAULT, 'reached the Final Assault');
return h;
}
// activity_table act2autochess.bossInfoDict [bloodPoint, bloodPointNormal, bloodPointHard, bloodPointAbyss] — the local
// cache and upstream ArknightsGameData master (zh_CN) were identical on 2026-10-01. PRTS 盟约记录's leader table is the
// older 11月18日 revision (铳 险境 450 000, 胄 / 铳 / 萨米 绝境 1 600 000 / 870 000 / 1 800 000, no 终极, no 卢西恩).
const OFFICIAL_BLOOD_POINT = {
boss_1: [247500, 675000, 1800000, 3600000], boss_2: [225000, 400000, 800000, 3000000],
boss_3: [285000, 708750, 2000000, 4000000], boss_4: [307500, 708750, 2100000, 4200000],
boss_5: [200000, 390000, 780000, 3000000], boss_6: [285000, 705000, 1990000, 3980000],
boss_7: [277500, 787500, 2000000, 4000000], boss_8: [750000, 937500, 3600000, 7200000],
boss_9: [675000, 900000, 2800000, 3950000], boss_10: [825000, 1012500, 3800000, 7600000],
};
test('leader HP data = the current official bossInfoDict for every leader and difficulty', () => {
assert.deepEqual(Object.keys(DATA.bosses).sort(), Object.keys(OFFICIAL_BLOOD_POINT).sort());
for (const [id, want] of Object.entries(OFFICIAL_BLOOD_POINT)) {
const bp = DATA.bosses[id].bloodPoint;
assert.deepEqual([bp.FUNNY, bp.NORMAL, bp.HARD, bp.ABYSS], want, id);
}
});
const SYSTEM_KEY = /^(bond|item|band|choice):/;
const MUL_STATS = ['atkMul', 'defMul', 'hpMul'];
test('终极 Final Assault (+200 layers per active bond): one pool (bloodPoint × 4 alive) for both fields, bonuses additive, each hit once', () => {
const h = toFinalAssault({ difficulty: 'ABYSS', seed: 2, bossId: 'boss_1', layers: 200 });
const m = h.m;
const pool = m.bossPool;
assert.equal(m.alivePlayers().length, 4);
assert.equal(pool.maxHp, DATA.bosses.boss_1.bloodPoint.ABYSS * 4, 'four alive: 4 × the data value (14 400 000)');
const fields = m.fields.filter((f) => f.battle);
assert.equal(fields.length, 2, 'two pair fields');
// one second into the fight: every operator's stats
for (const f of fields) for (let i = 0; i < 30; i++) f.battle.step();
let ops = 0, withBonds = 0;
for (const f of fields) {
for (const u of f.battle.allyUnits) {
if (u.kind !== 'op' || !u.alive) continue;
ops++;
const sys = u.buffs.filter((b) => SYSTEM_KEY.test(String(b.key)) && b.mods);
if (sys.length) withBonds++;
for (const b of sys) {
for (const k of MUL_STATS) assert.ok(!(k in b.mods), `${u.name}: ${b.key} is a 直接乘算 bonus, not ${k} (${JSON.stringify(b.mods)})`);
}
const { add, mul } = aggregateMods(u.buffs);
const want = Math.max(0, ((u.base.atk + (add.atkFlat ?? 0)) * Math.max(0, 1 + (add.atkPct ?? 0)) + (add.atkFinal ?? 0)) * (mul.atkMul ?? 1));
assert.ok(Math.abs(u.s.atk - want) <= 1e-6 * Math.max(1, want), `${u.name}: ATK = ((base + flat) × (1 + Σ%) + 最终加算) × Π(提升至/runes)`);
}
}
assert.ok(ops >= 12 && withBonds >= 8, `the bots' lineups carry bond / item bonuses (${withBonds} of ${ops})`);
m.dispose();
});
test('绝境 Final Assault: both pair fields drain the one pool, every hit exactly once', () => {
const h = toFinalAssault({ difficulty: 'HARD', seed: 3, bossId: 'boss_5' });
const m = h.m;
assert.equal(m.bossPool.maxHp, DATA.bosses.boss_5.bloodPoint.HARD * 4, 'four alive: 4 × the data value');
assert.equal(m.gd.bossPoolHp('boss_5', 2), DATA.bosses.boss_5.bloodPoint.HARD * 2, 'two alive: 2 × the data value');
const pool = m.bossPool;
const fields = m.fields.filter((f) => f.battle);
assert.equal(fields.length, 2);
assert.ok(fields.every((f) => f.battle.sharedBoss === pool || f.battle.sharedBoss.pool === pool), 'one pool behind both fields');
const before = pool.hp;
const dealt = new Map();
// (element gauge fills — type 'element', 元素损伤 — fill the leader's gauge, not its HP)
for (const f of fields) f.battle.on('damaged', (c) => { if (c.target && c.target.bossPool && c.type !== 'element') dealt.set(f.fieldId, (dealt.get(f.fieldId) || 0) + c.amount); }, { priority: -1e9 });
for (let i = 0; i < 600; i++) for (const f of fields) if (!f.battle.finished) f.battle.step(); // 20 game s
const sum = [...dealt.values()].reduce((a, b) => a + b, 0);
assert.ok(dealt.get('b1') > 0 && dealt.get('b2') > 0, `both fields hit the leader (${[...dealt]})`);
assert.ok(Math.abs((before - pool.hp) - Math.min(before, sum)) <= 1e-6 * before, `the pool lost exactly the hits (${before - pool.hp} vs ${sum})`);
m.dispose();
});
test('终极 Final Assault vs 假想敌:胄 (seeded bot match): both players\' 奥术 never multiply on the leader; a drone costs it 2 % of the pool', () => {
// DESIGN §20.10: one 奥术 instance per target (the strongest — PRTS 作战机制 同名buff, 巴哈姆特 12316 "共享型buff會跟對面搶");
// 死亡集群's "最大生命值2%" = the leader's shown max HP, the pool (DRONE_LINK_BASE 'pool' [ASSUMED]): 288 000 at 终极
// with 4 alive (14 400 000)
// the bots' boards follow every draw of the match (the elite-to-board merge, DESIGN §20.11, moved seed 7 to 12; the
// 战术决策 drawn with replacement moved 12 on): the first of these seeds whose bots pair two 奥术 players
const pairOf = (m) => m.fields.filter((f) => f.battle).find((f) => f.players.length === 2 && f.players.every((pid) => f.battle.getPlayer(pid).bonds.arcaneShip?.active));
let h = null;
for (const seed of [13, 7, 11, 21, 23]) {
h = toFinalAssault({ difficulty: 'ABYSS', seed, bossId: 'boss_1' });
if (pairOf(h.m)) break;
h.m.dispose();
h = null;
}
assert.ok(h, 'precondition: a pair field where both players run 奥术 (bot lineups of one of the seeds)');
const m = h.m;
const fields = m.fields.filter((f) => f.battle);
const bb = bondBb('arcaneShip');
const both = pairOf(m);
const allowed = [];
for (const pid of both.players) {
const b = both.battle.getPlayer(pid).bonds.arcaneShip;
const v = bb.base_damage_scale + bb.damage_scale_per_stack * b.layers;
allowed.push(v, v * bb.power_weak_scale);
}
const links = [];
for (const f of fields) {
f.battle.on('damaged', (c) => {
if (c.target && c.target.bossPool && c.type === 'true' && c.dmg && !c.dmg.origin && c.dmg.tags && c.dmg.tags.includes('hpLoss')) links.push(c.amount);
}, { priority: -1e9 });
}
let withArcane = 0;
for (let i = 0; i < 1200; i++) { // 40 game s
for (const f of fields) if (!f.battle.finished) f.battle.step();
for (const e of both.battle.enemies) {
if (!e.alive || !e.isBoss) continue;
const arc = e.buffs.filter((x) => String(x.key).startsWith('bond:arcaneShip'));
assert.ok(arc.length <= 1, `one 奥术 instance on the leader (${arc.map((x) => x.key)})`);
if (!arc.length) continue;
withArcane++;
const v = arc[0].mods.artsTakenMul;
assert.ok(allowed.some((a) => Math.abs(a - v) < 1e-9), `the 奥术 value is one player's (${v} ∉ ${allowed}), never a product`);
}
}
assert.ok(withArcane > 100, `the leader carried 奥术 (${withArcane} samples)`);
assert.ok(links.length >= 1, 'drones were shot down');
for (const x of links) assert.equal(x, m.bossPool.maxHp * 0.02, 'drone link = 2 % × the pool');
assert.equal(m.bossPool.maxHp, DATA.bosses.boss_1.bloodPoint.ABYSS * 4, 'the 14 400 000 pool of 4 alive players');
m.dispose();
});
test('绝境 Hidden Core vs 假想敌:铳 (隐秘核心): a 碎铳之簧 passes every damage it takes to the pool 1:1, 无来源, credited', () => {
// DESIGN §20.10: PRTS 碎铳之簧 "受到伤害时令…假想敌:铳受到等量的无来源生命流失" (v2.5: half); real match → Hidden Core specs
const seats = [0, 1, 2, 3].map((i) => ({ seat: i, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true }));
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', seats, seed: 7, captureFrames: false, instant: false, clientCombat: true });
const m = h.m;
m.bossId = 'boss_5';
m.hiddenBossId = 'boss_9';
const specs = [];
const orig = m._ccField.bind(m);
m._ccField = (o) => { const f = orig(o); if (f.kind === 'hidden') specs.push(f.spec); return f; };
m.start();
let last = '';
h.run(() => {
const k = `${m.phase}:${m.round}`;
if (k !== last) {
last = k;
if (m.phase === PHASE.PREP && m.round === 1) for (const ps of m.players.values()) ps.lp = 400;
if (m.phase === PHASE.PREP && m.round === m.gd.bossRound) {
for (const ps of m.alivePlayers()) {
for (const id of m.gd.bondIds) if (ps.bonds[id] && ps.bonds[id].active) ps.layers[id] = (ps.layers[id] || 0) + 100;
ps.recompute();
}
}
}
return h.ended != null || m.phase === PHASE.HIDDEN_CORE;
}, { maxSteps: 8e6 });
assert.equal(m.phase, PHASE.HIDDEN_CORE, 'reached the Hidden Core');
assert.ok(specs.length >= 1);
const pool = new SharedBossPool(m.bossPool.maxHp);
assert.equal(pool.maxHp, DATA.bosses.boss_9.bloodPoint.HARD * m.alivePlayers().length, 'the hidden 铳 pool = bloodPoint × the players alive at its start');
const b = createBattleFromSpec(specs[0], new DataSource(DATA, null), { sharedBoss: pool, recordEvents: false, quiet: true });
// the springs stand from the start, 铳 enters ≈ 10 game s later
for (let i = 0; i < 1200 && !b.enemies.some((e) => e.alive && e.isBoss); i++) b.step();
const springs = b.enemies.filter((e) => e.alive && /enemy_90(18|19|20)_actrp/.test(e.defId));
const gun = b.enemies.find((e) => e.alive && e.isBoss);
const op = b.allyUnits.find((u) => u.kind === 'op' && u.alive);
assert.ok(springs.length >= 1 && gun && op, 'springs, 铳 and an operator on the field');
const seen = [];
b.on('damaged', (c) => { if (c.target === gun) seen.push(c); }, { priority: -1e9 });
const sp = springs.find((x) => x.defId === 'enemy_9019_actrpb') || springs[0];
const pool0 = pool.hp;
const dealt = b.dealDamage(op, sp, { amount: 50000, type: 'true', canDodge: false });
assert.ok(dealt > 0, 'the spring took damage');
assert.ok(Math.abs((pool0 - pool.hp) - dealt) < 1e-6, `the pool lost exactly what the spring took (${pool0 - pool.hp} vs ${dealt})`);
assert.ok(seen.length === 1 && seen[0].source === null && seen[0].credit === op, '无来源, credited to the operator');
m.dispose();
});