Files
sganggs 0ced21d321 feedback7: merge fb7-loadout — per-operator 潜能 (1–6) and 练度 (未精英化 / 精英1 / 精英2 / 精英2 Lv.60) set in 干员调配, defaults 潜能 6 and 精英2 Lv.60 (the owner's decision of 2026-10-08); PR #301 ported as one aligned list; #398 the skill frame, #400 REA-Y's heal text
- Conflicts (the branch started at ae22335c): the four i18n packs (union of keys), game.js DetailPanel props (ops + the
  voices' voice=true), detailPanel.js resolveDetail (cultOpts + the voices' tap), match/views.js (both helpers),
  docs/PLAYING.md and design/network.md merged line by line against the base. test/match/bosshp.test.js's ATK formula
  now includes the unit's 练度 multiplier (cultMul).
- Grok review: MERGE AFTER FIXES — the note and the module card at the set potential fixed on the branch.
- Golden: every owned operator ×1.1 ATK / DEF / max HP by default — roster 49, bonds 46, fields 22, matches 18, diy 134
  (stand-in battles and prototype picks unchanged). Broad node --test 5666 (0 fail after the bosshp formula; 2 skipped);
  lint 0 errors; typecheck; i18n strict clean (1152 msgids).
2026-10-08 22:35:33 +08:00

219 lines
13 KiB
JavaScript
Raw Permalink 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);
// the 练度 multiplier (0.2.2, the unit's own cultMul: default 精英2 Lv.60 = ×1.1) is a separate factor, after 直接乘算
const cult = u.cultMul ? u.cultMul.atk : 1;
const want = Math.max(0, ((u.base.atk + (add.atkFlat ?? 0)) * Math.max(0, 1 + (add.atkPct ?? 0)) + (add.atkFinal ?? 0)) * (mul.atkMul ?? 1) * cult);
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();
});