Files
ark-stronghold-protocol/test/content/playtest5_elements.test.js
T
sganggs 15291c2293 Playtest #5 fixes: hovering enemies, target order, blocking hand-over, 联防 knocked-out, elements, huge-boss hit area, maps, gear icon, phone HUD, 至简 price
- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them;
  PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air
- #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the
  redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included
- #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only
  raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed
- #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the
  blocker dies, withdraws or is stunned; melee blockers attack what they block
- #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右,
  operators before summons; special priorities break ties by taunt then deploy order
- #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views
- #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages
  audited against the official level files
- #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones
- #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable
- #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects
- DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and
  every browser suite green; dev recordings regenerated
2026-09-30 19:12:36 +08:00

232 lines
14 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.
// User playtest #5 report #3 (operator side, workstream W3a): element damage (元素损伤) dealt by the player side and the
// operator-side gauge modifiers, against the official rules.
// * 盟约·辅助干员 迭代元素 — every damage she deals attaches 神经 + 灼燃 + 凋亡 (18 % ATK each, 神经 first); the kit used
// to fill 神经 only (its "first element not bursting" pick was dead: a burst locks every gauge). "造成伤害时": a
// dodged / fully absorbed hit attaches nothing. Elite RIT-X: ep_damage_ratio_boss vs elite / leader — the talent
// only.
// * 菲莱 精锐 PRP-X "阻挡敌人时,自身造成的元素损伤提升15%" — every element fill she deals (a carried 灼燃维式重锤 too);
// S2 冥河诅咒's counter area = range x-4 (the 3×3 tiles around her, PRTS 备注).
// * 受到的元素损伤降低 (菲莱 神河谕使, 哈洛德 我即军营, 纯烬艾雅法拉 火山灰疗愈) — a 元素损伤 multiplier on the hit, not
// elemTakenMul (元素伤害 / 元素脆弱: gamedata_const termDescriptionDict ba.elementfragile "受到的元素伤害提升").
// * a killing blow attaches no element (迭代元素, 灼燃维式重锤, 炎佑, 妮芙's S2 rider): their `damaged` hooks run before the
// kill, while the target is `alive` at 0 HP — a fill there used to burst the corpse (a second fatal, 烛煌's heal).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { PITHST_ELEMENTS } from '../../server/sim/content/kits/tier6.js';
import { spawnYanyou } from '../../server/sim/content/tokens.js';
import { COLS } from '../../server/sim/constants.js';
const SRC = getDefaultSource();
const raw = (id) => SRC.rawChess(id);
const tal = (id, i = 0) => raw(id).talents.filter((t) => t.index !== -1)[i].bb;
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} ≠ ${b}`);
const HAMMER = 'chess_item_4_09_e_a'; // 灼燃维式重锤: arts damage attaches 10 % of it as 灼燃损伤
const DEFS = {
enemies: {
enemy_w3a: dummy('enemy_w3a'), enemy_w3a_elite: dummy('enemy_w3a_elite', { rank: 'ELITE' }),
enemy_w3a_hit: dummy('enemy_w3a_hit', { atk: 150, bat: 0.5 }), enemy_w3a_fly: dummy('enemy_w3a_fly', { motion: 'FLY' }),
enemy_w3a_low: dummy('enemy_w3a_low', { hp: 5 }),
},
chess: { t_guard: chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { maxHp: 5000 }, skill: null }) },
};
const battle = (o) => makeBattle({ defs: DEFS, timeLimit: 120, autoFinish: false, seed: 5, ...o });
const PITH = 'chess_char_1_15_a', PITH_B = 'chess_char_1_15_b';
// ---------------------------------------------------------------------------------------------------------------------
// 盟约·辅助干员
test('#3 盟约·辅助干员: each damage attaches 18 % ATK of 神经, 灼燃 and 凋亡 (in that order); alone, 神经 bursts', () => {
assert.deepEqual([...PITHST_ELEMENTS], ['neural', 'burn', 'apoptosis']);
const h = battle({ units: [{ chessId: PITH, row: 10, col: 4 }], enemies: [{ key: 'enemy_w3a', pos: [10, 5] }] });
const u = h.unit(PITH);
h.step();
const e = h.enemy('enemy_w3a');
const r = tal(PITH).ep_damage_ratio;
assert.ok(h.runUntil(() => e.elem.neural > 0, 5), 'first hit');
for (const el of PITHST_ELEMENTS) approx(e.elem[el], u.s.atk * r, `${el} = 18 % ATK`);
// the three gauges fill together; 神经 is applied first, so it is the one that bursts (PRTS 元素)
assert.ok(h.runUntil(() => h.hooksOf('elementBurst').length > 0, 40), 'a burst');
assert.equal(h.hooksOf('elementBurst')[0].element, 'neural');
assert.equal(h.hooksOf('elementBurst').length, 1, 'the burst locks the other two');
checkInvariants(h.b);
});
test('#3 盟约·辅助干员: her 灼燃 / 凋亡 complete the gauges her team builds (was: 神经 only)', () => {
for (const el of ['burn', 'apoptosis']) {
const h = battle({ units: [{ chessId: PITH, row: 10, col: 4 }], enemies: [{ key: 'enemy_w3a', pos: [10, 8] }] });
const u = h.unit(PITH);
h.step();
const e = h.enemy('enemy_w3a');
for (const k of Object.keys(e.elem)) e.elem[k] = 0;
e.elem[el] = 950; // e.g. 余's burn / 塑心's apoptosis so far
h.b.dealDamage(u, e, { amount: 100, type: 'arts', canDodge: false });
const b = h.hooksOf('elementBurst').filter((c) => c.target === e);
assert.equal(b.length, 1, `${el} burst`);
assert.equal(b[0].element, el);
const one = u.s.atk * tal(PITH).ep_damage_ratio;
approx(e.elem.neural, one, '神经 filled first');
// 灼燃 comes before 凋亡: it fills when 凋亡 bursts; the burst locks 凋亡 when 灼燃 bursts
if (el === 'apoptosis') approx(e.elem.burn, one, '灼燃 before the 凋亡 burst');
else assert.equal(e.elem.apoptosis, 0, 'the burst locks the element after it');
}
});
test('#3 盟约·辅助干员 "造成伤害时": a dodged or fully absorbed hit attaches no element; element damage never re-triggers it', () => {
for (const guard of [{ key: 't:barrier', shield: 1e9 }, { key: 't:dodge', mods: { dodgeArts: 1 } }]) {
const h = battle({ units: [{ chessId: PITH, row: 10, col: 4 }], enemies: [{ key: 'enemy_w3a', pos: [10, 5] }] });
const u = h.unit(PITH);
h.step();
const e = h.enemy('enemy_w3a');
for (const k of Object.keys(e.elem)) e.elem[k] = 0;
h.b.addBuff(e, guard);
const atk0 = u.stats.attacks;
h.run(6);
assert.ok(u.stats.attacks >= atk0 + 2, 'she attacked');
assert.equal(e.elem.neural + e.elem.burn + e.elem.apoptosis, 0, `${guard.key}: nothing attached`);
}
const h = battle({ units: [{ chessId: PITH, row: 10, col: 4 }], enemies: [{ key: 'enemy_w3a', pos: [10, 8] }] });
const u = h.unit(PITH);
h.step();
const e = h.enemy('enemy_w3a');
for (const k of Object.keys(e.elem)) e.elem[k] = 0;
h.b.dealDamage(u, e, { type: 'element', element: 'burn', amount: 10 });
h.b.dealDamage(u, e, { type: 'elemental', element: 'burn', amount: 10 });
assert.equal(e.elem.neural, 0, 'element fills / 元素伤害 are not "damage she deals" for 迭代元素');
});
test('#3 盟约·辅助干员 (elite): the RIT-X ratio vs elite enemies is the talent only — a carried 灼燃维式重锤 stays 10 %', () => {
const h = battle({ units: [{ chessId: PITH_B, row: 10, col: 4, items: [HAMMER] }], enemies: [{ key: 'enemy_w3a_elite', pos: [10, 8] }] });
const u = h.unit(PITH_B);
h.step();
const e = h.enemy('enemy_w3a_elite');
for (const k of Object.keys(e.elem)) e.elem[k] = 0;
const t = tal(PITH_B);
assert.ok(t.ep_damage_ratio_boss > t.ep_damage_ratio);
h.b.dealDamage(u, e, { amount: 1000, type: 'arts', canDodge: false });
approx(e.elem.neural, u.s.atk * t.ep_damage_ratio_boss, 'talent ×1.18 vs elite');
approx(e.elem.burn, u.s.atk * t.ep_damage_ratio_boss + 1000 * 0.1, 'talent + hammer (10 % of the arts damage, no ×1.18)');
});
test('#3 a killing blow attaches no element: no burst on the corpse, one fatal, no 烛煌 熔点引爆 heal', () => {
const BLAZE = 'chess_char_5_03_a', NYMPH = 'chess_char_5_22_a';
const cases = [
['盟约·辅助干员 迭代元素', [{ chessId: PITH, row: 10, col: 4 }], (h) => h.unit(PITH), {}],
['灼燃维式重锤', [{ chessId: 't_guard', row: 10, col: 4, items: [HAMMER] }], (h) => h.unit('t_guard'), {}],
['炎佑', [], (h) => spawnYanyou(h.b, 'p1', { atk: 400, hp: 5000 })[0], {}],
['妮芙 S2 凋亡', [{ chessId: NYMPH, row: 11, col: 3 }], (h) => h.unit(NYMPH), { isSkill: true, isAttack: true }],
];
for (const [label, units, srcOf, flags] of cases) {
const h = battle({ units: [...units, { chessId: BLAZE, row: 11, col: 6 }], enemies: [{ key: 'enemy_w3a_low', pos: [10, 12] }] });
h.step();
const src = srcOf(h), blaze = h.unit(BLAZE), e = h.enemy('enemy_w3a_low');
assert.ok(e.alive && src && src.deployed && blaze.deployed, label);
// 灼燃 / 凋亡 one fill from bursting (a burn burst would trigger 烛煌 熔点引爆); 神经 empty (迭代元素 applies it first)
for (const k of Object.keys(e.elem)) e.elem[k] = k === 'burn' || k === 'apoptosis' ? 990 : 0;
blaze.hp = blaze.s.maxHp * 0.5;
h.b.dealDamage(src, e, { amount: 1000, type: 'arts', canDodge: false, ...flags });
assert.equal(e.alive, false, `${label}: killed`);
assert.equal(h.hooksOf('elementBurst').filter((c) => c.target === e).length, 0, `${label}: no burst on the corpse`);
assert.equal(h.hooksOf('fatal').filter((c) => c.unit === e).length, 1, `${label}: one fatal`);
assert.equal(h.hooksOf('kill').filter((c) => c.victim === e).length, 1, `${label}: one kill`);
assert.equal(blaze.hp, blaze.s.maxHp * 0.5, `${label}: no 熔点引爆 heal`);
checkInvariants(h.b);
}
// control: the same hit on a living target still attaches (and bursts)
const h = battle({ units: [{ chessId: PITH, row: 10, col: 4 }], enemies: [{ key: 'enemy_w3a', pos: [10, 12] }] });
h.step();
const u = h.unit(PITH), e = h.enemy('enemy_w3a');
for (const k of Object.keys(e.elem)) e.elem[k] = k === 'burn' ? 990 : 0;
h.b.dealDamage(u, e, { amount: 1000, type: 'arts', canDodge: false });
assert.deepEqual(h.hooksOf('elementBurst').filter((c) => c.target === e).map((c) => c.element), ['burn'], 'alive: 灼燃 bursts');
});
// ---------------------------------------------------------------------------------------------------------------------
// 菲莱
test('#3 菲莱 (elite): PRP-X ×1.15 applies to every element fill she deals while blocking — a carried 灼燃维式重锤 too', () => {
const id = 'chess_char_3_06_b';
const scale = raw(id).trait.bb.ep_damage_scale;
assert.ok(scale > 1);
for (const [pos, blocking] of [[[10, 5], true], [[10, 8], false]]) {
const h = battle({ units: [{ chessId: id, row: 10, col: 5, items: [HAMMER] }], enemies: [{ key: 'enemy_w3a', pos }] });
const u = h.unit(id);
h.run(0.2);
const e = h.enemy('enemy_w3a');
assert.equal(u.blocking.includes(e), blocking);
for (const k of Object.keys(e.elem)) e.elem[k] = 0;
h.b.dealDamage(u, e, { amount: 1000, type: 'arts', canDodge: false });
approx(e.elem.burn, 1000 * 0.1 * (blocking ? scale : 1), blocking ? 'hammer burn ×1.15 while blocking' : 'not blocking: ×1');
}
});
test('#3 菲莱 S2 冥河诅咒: the counter hits the ground enemies of the 3×3 tiles around her (range x-4), not a 1.5 radius', () => {
const id = 'chess_char_3_06_a';
const h = battle({
units: [{ chessId: id, row: 10, col: 5, carryState: { sp: 999 } }],
enemies: [
{ key: 'enemy_w3a_hit', pos: [10, 5] }, // blocked, attacks her → the counter
{ key: 'enemy_w3a', pos: [10.45, 6.45] }, // tile (10, 6) of the 3×3, 1.52 tiles away
{ key: 'enemy_w3a', pos: [10, 7] }, // two tiles away
{ key: 'enemy_w3a_fly', pos: [11, 6] }, // flying: not a ground enemy
],
});
const u = h.unit(id);
assert.ok(h.runUntil(() => u.skill.active, 10), 'S2');
const [near, far] = h.b.enemies.filter((x) => x.defId === 'enemy_w3a').sort((a, b) => a.x - b.x);
const fly = h.enemy('enemy_w3a_fly');
const ep = raw(id).skill.bb.ep_damage_ratio;
assert.ok(h.runUntil(() => near.elem.apoptosis > 0, 10), 'the off-centre enemy of the 3×3 is hit');
approx(near.elem.apoptosis, u.s.atk * ep, 'apoptosis = ep_damage_ratio × ATK');
assert.equal(far.elem.apoptosis, 0, 'two tiles away: not hit');
assert.equal(fly.elem.apoptosis, 0, 'flying: not hit');
});
// ---------------------------------------------------------------------------------------------------------------------
// 受到的元素损伤降低: gauge only
test('#3 纯烬艾雅法拉 火山灰疗愈: allies in range take −12 % 元素损伤 (×talent_scale in S3) — the gauge, not elemTakenMul', () => {
const id = 'chess_char_6_20_a';
const h = battle({ units: [{ chessId: id, row: 10, col: 4 }, { chessId: 't_guard', row: 10, col: 5 }] });
const u = h.unit(id), g = h.unit('t_guard');
h.run(0.6);
const er = tal(id, 1).ep_damage_resistance;
assert.ok(g.findBuff('agoat2:ash'), 'in her range');
const n0 = g.elem.neural;
h.b.dealDamage(null, g, { type: 'element', element: 'neural', amount: 100 });
approx(g.elem.neural - n0, 100 * (1 - er), '−12 %');
approx(g.s.elemTakenMul, 1, 'no 元素伤害 / 元素脆弱 change');
// S3 (the default skill): the talent ×talent_scale
g.hp = g.s.maxHp * 0.5;
u.skill.sp = u.skill.spCost;
assert.ok(h.runUntil(() => u.skill.active, 10), 'S3');
h.run(0.6);
const b0 = g.elem.burn;
h.b.dealDamage(null, g, { type: 'element', element: 'burn', amount: 100 });
approx(g.elem.burn - b0, 100 * Math.max(0, 1 - er * raw(id).skill.bb.talent_scale), 'S3: ×talent_scale');
});
test('#3 纯烬艾雅法拉 氤氲: the bonus heal recovers heal_scale × the trait ep_heal_ratio × ATK of 元素损伤 per stack (was: heal_scale × ATK)', () => {
const id = 'chess_char_6_20_a';
// the guard stands outside her range: only the manual heals below reach it
const h = battle({ units: [{ chessId: id, row: 12, col: 2 }, { chessId: 't_guard', row: 9, col: 10 }] });
const u = h.unit(id), g = h.unit('t_guard');
h.step();
assert.ok(!u.rangeKeySet.has(g.tileR * COLS + g.tileC), 'guard out of her range');
const t0 = tal(id, 0), ratio = raw(id).trait.bb.ep_heal_ratio;
assert.ok(ratio > 0 && ratio < 1);
g.elem.neural = 900;
h.b.heal(u, g, 1); // a normal heal of hers → 1 stack
const n0 = g.elem.neural;
h.run(1.01);
approx(n0 - g.elem.neural, u.s.atk * t0.heal_scale * ratio, 'one stack: 10 % × 50 % ATK');
h.b.heal(u, g, 1); h.b.heal(u, g, 1); // 3 stacks (max), the duration refreshed
const mist = g.findBuff(`agoat2:mist:${u.id}`); // keyed per 纯烬 (two of them keep their own stacks)
assert.equal(mist.stacks, t0.max_stack_cnt);
const n1 = g.elem.neural;
h.run(1.01);
approx(n1 - g.elem.neural, u.s.atk * t0.heal_scale * ratio * t0.max_stack_cnt, '3 stacks');
});