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
This commit is contained in:
1 parent
d4f868cd19
commit
15291c2293
104 files changed
+5539
-662
No files matched your search
@@ -127,7 +127,7 @@ test(`${nm('enemy_1404_msnip')}: 直击 — a unit in line is shot (arts ATK×at
|
||||
assert.ok(e.findBuff('ab:revealed') || h.b.time > s.initCooldown + s.bb.duration);
|
||||
});
|
||||
|
||||
test(`${nm('enemy_10034_cnvsax')}: never attacks while stealthed; once blocked it counter-attacks and burns around itself`, () => {
|
||||
test(`${nm('enemy_10034_cnvsax')}: never attacks while stealthed; once blocked it counter-attacks and burns its locked target`, () => {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }] });
|
||||
h.step();
|
||||
const free = put(h, 'enemy_10034_cnvsax', [9, 5]); // next to the wall, not blocked
|
||||
@@ -137,7 +137,7 @@ test(`${nm('enemy_10034_cnvsax')}: never attacks while stealthed; once blocked i
|
||||
h.run(5);
|
||||
const w = h.unit('t_wall');
|
||||
assert.ok(e.stats.attacks > 0);
|
||||
assert.ok(w.elem.burn > 0, 'fire aura burns');
|
||||
assert.ok(w.elem.burn > 0, '狂欢式演奏 burns its target (the blocker)');
|
||||
});
|
||||
|
||||
test(`${nm('enemy_9008_acbunn')}: attacks several targets at once while stealthed`, () => {
|
||||
@@ -483,7 +483,11 @@ test(`${nm('enemy_9007_acelem')}: harder to target (taunt −1); parasitises its
|
||||
h.run(2.6);
|
||||
const w = h.unit('t_wall');
|
||||
assert.ok(w.findBuff('ab:parasite'));
|
||||
approx(w.s.elemTakenMul, tb('enemy_9007_acelem', '1.ep_damage_scale'));
|
||||
// "受到的元素损伤提高至130%": an elementHit multiplier on the host's gauge fills (SIM.md §7.2), not the elemTakenMul mod
|
||||
assert.equal(w.s.elemTakenMul, 1);
|
||||
const n0 = w.elem.neural;
|
||||
h.b.dealDamage(null, w, { type: 'element', element: 'neural', amount: 100 });
|
||||
approx(w.elem.neural - n0, 100 * tb('enemy_9007_acelem', '1.ep_damage_scale'), 1e-9);
|
||||
approx(w.stats.taken, 2 * e.s.atk * tb('enemy_9007_acelem', '1.atk_scale'), 1e-6);
|
||||
killed(h, e, null);
|
||||
assert.ok(!w.findBuff('ab:parasite'));
|
||||
@@ -1140,14 +1144,16 @@ test(`${nm('enemy_10044_wintun')}: fast with its barrel; first hit ×${skb('enem
|
||||
approx(e.s.dodgePhys, skb('enemy_10044_wintun', 'BlockedBoom').bb.prob);
|
||||
});
|
||||
|
||||
test(`${nm('enemy_10045_parrot')}: 近地悬浮; sprints when first hit`, () => {
|
||||
test(`${nm('enemy_10045_parrot')}: 近地悬浮; sprints when first hit (final ×M0SpeedUp.move_speed — PRTS "最终提升至300%")`, () => {
|
||||
const h = arena();
|
||||
h.step();
|
||||
const e = put(h, 'enemy_10045_parrot', [10, 7], { move: true });
|
||||
const v = e.s.moveSpeed;
|
||||
assert.ok(e.mem.ab.float && e.s.flags.unblockable);
|
||||
assert.ok(e.findBuff('ab:float') && e.s.flags.unblockable && e.isFlying && e.motion === 'WALK');
|
||||
h.b.dealDamage(null, e, { amount: 1, type: 'true' });
|
||||
approx(e.s.moveSpeed, v * (1 + tb('enemy_10045_parrot', 'M0SpeedUp.move_speed')));
|
||||
approx(e.s.moveSpeed, v * tb('enemy_10045_parrot', 'M0SpeedUp.move_speed'));
|
||||
h.run(tb('enemy_10045_parrot', 'M0SpeedUp.duration') + 0.1);
|
||||
approx(e.s.moveSpeed, v);
|
||||
});
|
||||
|
||||
test(`${nm('enemy_10087_hlchgr')}: spends ammo every ${tb('enemy_10087_hlchgr', 'SkillTrigger.interval')} s for permanent speed and ATK`, () => {
|
||||
@@ -1173,7 +1179,9 @@ test(`${nm('enemy_10116_ymgtop')}: spinning phase deals ATK×${tb('enemy_10116_y
|
||||
h.run(skb('enemy_10116_ymgtop', 'SwitchModeTrigger').initCooldown + 3.05);
|
||||
const w = h.unit('t_wall');
|
||||
assert.ok(w.stats.taken >= 2 * e.s.atk * tb('enemy_10116_ymgtop', 'RotateDamage.attack@atk_scale') - 1e-6);
|
||||
h.b.displace(e, { x: -1, y: 0 }, 0.5, { force: 3 }); // pushed back towards the wall: still in reach
|
||||
// pushed towards the wall (to x 6.8): still in reach, not in contact (0.8 > block radius 0.71: the wall would
|
||||
// block it)
|
||||
h.b.displace(e, { x: -1, y: 0 }, 0.2, { force: 3 });
|
||||
h.step();
|
||||
const t0 = w.stats.taken;
|
||||
h.run(3);
|
||||
|
||||
@@ -491,12 +491,16 @@ test('coin / refresh counters: 溯光星源 121 per 3 spent, 阿罗玛 122 / 安
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// price, items, refreshes, funds
|
||||
|
||||
test('SERVER_CHESS_PRICE: 至简 13 costs its bb.price (2; the text says 1), 红豆 04 (hidden) costs 1', () => {
|
||||
test('SERVER_CHESS_PRICE: 购买价格为1 — bb.price is the discount off the tier price (至简 Ⅲ 3 − 2, 红豆 Ⅰ 2 − 1; user playtest #5)', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_PRICE', 'SERVER_CHESS_PRICE')) {
|
||||
const n = Number(/购买价格为(\d+)/.exec(g.desc)?.[1]);
|
||||
assert.equal(n, 1, `${gid}: the official text`);
|
||||
for (const owner of owners) {
|
||||
const s = setup();
|
||||
s.ps.shop.slots[0] = { kind: 'chess', id: owner, basePrice: s.m.gd.chessPrice(owner), frozen: false, sold: false };
|
||||
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), g.bb.price, `${gid} ${owner}`);
|
||||
const tierPrice = s.m.gd.chessPrice(owner);
|
||||
assert.equal(tierPrice - g.bb.price, n, `${gid} ${owner}: tier price − bb.price = the text's price`);
|
||||
s.ps.shop.slots[0] = { kind: 'chess', id: owner, basePrice: tierPrice, frozen: false, sold: false };
|
||||
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), n, `${gid} ${owner}`);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
@@ -504,20 +508,25 @@ test('SERVER_CHESS_PRICE: 至简 13 costs its bb.price (2; the text says 1), 红
|
||||
s.ps.shop.slots[0] = { kind: 'chess', id: 'chess_char_3_13_a', basePrice: 3, frozen: false, sold: false };
|
||||
const funds = s.ps.funds;
|
||||
assert.deepEqual(s.m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
|
||||
assert.equal(s.ps.funds, funds - 2);
|
||||
assert.equal(s.ps.funds, funds - 1, '至简 costs 1');
|
||||
assert.equal(GR('garrison_13_a').bb.price, 2);
|
||||
assert.equal(GR('garrison_13_b').bb.price, 2);
|
||||
});
|
||||
|
||||
test('regression: 购买价格 replaces the base price but keeps discounts applied earlier in the dispatch (远见-style −1)', () => {
|
||||
test('regression: 购买价格为1 runs before the other price modifiers (远见 −1 never below 1 leaves 至简 at 1)', () => {
|
||||
const s = setup();
|
||||
s.m.dispatcher.registry.global('gar_test_disc', { onPrice(ctx) { ctx.modifyPrice(-1); } });
|
||||
const seen = [];
|
||||
s.m.dispatcher.registry.global('gar_test_seen', { onPrice(ctx, ev) { seen.push(ev.price); } });
|
||||
try {
|
||||
s.ps.shop.slots[0] = { kind: 'chess', id: 'chess_char_3_13_a', basePrice: 3, frozen: false, sold: false };
|
||||
s.ps.shop.slots[1] = { kind: 'chess', id: 'chess_char_1_02_a', basePrice: 2, frozen: false, sold: false };
|
||||
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), 1, '至简 2 − 1');
|
||||
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), 1);
|
||||
assert.deepEqual(seen, [1], 'a global modifier already sees 至简 at 1');
|
||||
s.ps.counters['bondaddon:visi:disc'] = 2; // 远见 150 layers: every chess −1, never below 1
|
||||
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), 1, '至简 3 − 2 = 1; 远见 leaves it at 1');
|
||||
assert.equal(s.ps.priceOf(s.ps.shop.slots[1]), 1, 'other chess: 2 − 1');
|
||||
} finally {
|
||||
s.m.dispatcher.registry.unregister('global:gar_test_disc');
|
||||
s.m.dispatcher.registry.unregister('global:gar_test_seen');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -746,7 +746,9 @@ test('夕 S3 写意胜形: BAT +0.4 s, ATK +, splash 1.7; every attack summons /
|
||||
test('夕 S3 写意胜形: unblocked enemies first (the blocked one is attacked only without another choice)', () => {
|
||||
const h = run({
|
||||
defs: { enemies: { enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e7, speed: 1 }), enemy_dummy: dummy('enemy_dummy') }, chess: { t_block: ally('t_block', { stats: { blockCnt: 1 } }) } },
|
||||
units: [entry('chess_char_5_12_a', 'skchr_dusk_3', { row: 10, col: 3 }), { chessId: 't_block', row: 9, col: 5 }],
|
||||
// (the blocker at col 4: the walker stops 0.71 tile in front of it — block radius, user playtest #5 — on col 5,
|
||||
// inside 夕's 3-column range)
|
||||
units: [entry('chess_char_5_12_a', 'skchr_dusk_3', { row: 10, col: 3 }), { chessId: 't_block', row: 9, col: 4 }],
|
||||
enemies: [{ key: 'enemy_walk', route: 0 }, { key: 'enemy_dummy', pos: [10, 6] }],
|
||||
});
|
||||
const u = h.unit('chess_char_5_12_a');
|
||||
|
||||
@@ -441,7 +441,7 @@ test('1_14 格雷伊: 静电场 停顿 on the attacked target, ×talent_scale du
|
||||
done(h1); done(h2);
|
||||
});
|
||||
|
||||
test('1_15 盟约·辅助干员 (hidden): 迭代元素 18 % ATK 神经损伤 per hit; skill 2 targets + ASPD; elite boost vs ELITE', () => {
|
||||
test('1_15 盟约·辅助干员 (hidden): 迭代元素 18 % ATK 神经 + 灼燃 + 凋亡 per damage; skill 2 targets + ASPD; elite boost vs ELITE', () => {
|
||||
const id = 'chess_char_1_15_a', bb = bbOf(id), t = tal(id);
|
||||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 6] }] });
|
||||
const u = h.unit(id);
|
||||
@@ -450,7 +450,9 @@ test('1_15 盟约·辅助干员 (hidden): 迭代元素 18 % ATK 神经损伤 per
|
||||
approx(u.s.aspd - u.base.aspd, bb.attack_speed);
|
||||
assert.equal(h.hooksOf('attack').find((c) => c.attacker === u).targets.length, bb['attack@max_target']);
|
||||
const el = dealt(h, u, (c) => c.type === 'element');
|
||||
assert.ok(el.length >= 2 && el.every((c) => c.dmg.element === 'neural'));
|
||||
assert.ok(el.length >= 6);
|
||||
// every damage she deals attaches all three, in the official order (神经 "(优先)", then 灼燃, then 凋亡)
|
||||
for (let i = 0; i + 2 < el.length; i += 3) assert.deepEqual(el.slice(i, i + 3).map((c) => c.dmg.element), ['neural', 'burn', 'apoptosis']);
|
||||
for (const c of el) approx(c.amount, u.s.atk * t.ep_damage_ratio);
|
||||
done(h);
|
||||
|
||||
@@ -724,7 +726,32 @@ test('2_05 哈洛德: 重症优先 heals the heaviest element load first with ×
|
||||
assert.equal(first.targets[0], elem, 'element-damaged ally first');
|
||||
approx(elem.elem.neural, 900 - u.s.atk * tbOf(id).ep_heal_ratio * bb.trait_scale, 'recovery ×1.6');
|
||||
h.run(0.3);
|
||||
approx(elem.s.elemTakenMul, 1 - t.ep_damage_resistance, '我即军营');
|
||||
// 我即军营 cuts the 元素损伤 (gauge fill) of an ally over half, on the hit itself — not elemTakenMul (元素伤害 / 元素脆弱)
|
||||
assert.ok(elem.elem.neural > 500, 'still over half');
|
||||
const b0 = elem.elem.burn;
|
||||
h.b.dealDamage(null, elem, { type: 'element', element: 'burn', amount: 100 });
|
||||
approx(elem.elem.burn - b0, 100 * (1 - t.ep_damage_resistance), '我即军营');
|
||||
approx(elem.s.elemTakenMul, 1, 'no 元素伤害 / 元素脆弱 change');
|
||||
// under half: no cut
|
||||
const n0 = low.elem.neural;
|
||||
h.b.dealDamage(null, low, { type: 'element', element: 'neural', amount: 100 });
|
||||
approx(low.elem.neural - n0, 100, 'gauge under half: full fill');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('2_05 哈洛德 (user playtest #5 #3): 侵蚀 counts as element damage for 重症优先 and 我即军营', () => {
|
||||
const id = 'chess_char_2_05_a', t = tal(id);
|
||||
const h = run({ units: [{ chessId: id, row: 10, col: 4, carryState: READY }, { chessId: 'chess_char_1_02_a', row: 10, col: 5 }, { chessId: 'chess_char_1_10_a', row: 11, col: 5 }] });
|
||||
const u = h.unit(id), low = h.unit('chess_char_1_02_a'), eroded = h.unit('chess_char_1_10_a');
|
||||
h.step();
|
||||
low.hp = low.s.maxHp * 0.3;
|
||||
eroded.elem.erosion = 900;
|
||||
h.step();
|
||||
const first = h.hooksOf('attack').find((c) => c.attacker === u);
|
||||
assert.equal(first.targets[0], eroded, 'the eroded ally is the heaviest element load');
|
||||
const e0 = eroded.elem.erosion;
|
||||
h.b.dealDamage(null, eroded, { type: 'element', element: 'erosion', amount: 100 });
|
||||
approx(eroded.elem.erosion - e0, 100 * (1 - t.ep_damage_resistance), '我即军营 on an eroded ally over half');
|
||||
done(h);
|
||||
});
|
||||
|
||||
|
||||
@@ -191,9 +191,11 @@ test('3_06 菲莱: 冥河诅咒 no attacks, HP +, counter arts + apoptosis (cd),
|
||||
const u = h.unit(id);
|
||||
h.run(0.2);
|
||||
const e = h.enemy('enemy_h');
|
||||
approx(u.s.elemTakenMul, 1 - t0.damage_resistance);
|
||||
const sp0 = u.skill.sp;
|
||||
// 神河谕使: a 元素损伤 multiplier on the hit (the gauge fill), not elemTakenMul (元素伤害 / 元素脆弱)
|
||||
approx(u.s.elemTakenMul, 1);
|
||||
const sp0 = u.skill.sp, a0 = u.elem.apoptosis;
|
||||
h.b.dealDamage(e, u, { type: 'element', element: 'apoptosis', amount: 10 });
|
||||
approx(u.elem.apoptosis - a0, 10 * (1 - t0.damage_resistance), 1e-6, 'element taken −damage_resistance');
|
||||
approx(u.skill.sp, sp0 + t0.sp, 1e-6, '+SP on apoptosis');
|
||||
fill(u);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 10));
|
||||
|
||||
@@ -972,7 +972,7 @@ test('圣约送葬人 T1 受选之人: the extra attack consumes no ammo and nev
|
||||
clean(h);
|
||||
});
|
||||
|
||||
test('元素伤害 uses the engine elemental type: × elemTakenMul, never × trueTakenMul (烛煌 熔点引爆, 妮芙 失魂)', () => {
|
||||
test('元素伤害 uses the engine elemental type: × elementalTakenMul (元素脆弱), never × trueTakenMul (烛煌 熔点引爆, 妮芙 失魂)', () => {
|
||||
// one battle per burst: after a burst the enemy's gauges stay locked for its 爆发冷却 (damage.js)
|
||||
const arena = () => {
|
||||
const h = makeBattle({
|
||||
@@ -983,7 +983,7 @@ test('元素伤害 uses the engine elemental type: × elemTakenMul, never × tru
|
||||
h.step();
|
||||
const e = h.enemy('enemy_dummy');
|
||||
h.b.addBuff(e, { key: 't:true', mods: { trueTakenMul: 2 } });
|
||||
h.b.addBuff(e, { key: 't:elem', mods: { elemTakenMul: 1.5 } });
|
||||
h.b.addBuff(e, { key: 't:elem', mods: { elementalTakenMul: 1.5 } });
|
||||
return { h, e, blaze: h.unit('chess_char_5_03_a'), nymph: h.unit('chess_char_5_22_a') };
|
||||
};
|
||||
const a = arena();
|
||||
@@ -991,14 +991,14 @@ test('元素伤害 uses the engine elemental type: × elemTakenMul, never × tru
|
||||
const melt = tagged(a.h, 'blazeMelt', a.e);
|
||||
assert.equal(melt.length, 1);
|
||||
assert.equal(melt[0].type, 'elemental');
|
||||
approx(melt[0].amount, a.blaze.s.atk * tal(a.blaze, 0).ep_damage_scale * 1.5, 1e-6, 'meltdown × elemTakenMul only');
|
||||
approx(melt[0].amount, a.blaze.s.atk * tal(a.blaze, 0).ep_damage_scale * 1.5, 1e-6, 'meltdown × elementalTakenMul only');
|
||||
clean(a.h);
|
||||
const { h, e, nymph } = arena();
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 1000 });
|
||||
assert.ok(h.runUntil(() => tagged(h, 'nymphSoul', e).length > 0, 6));
|
||||
const soul = tagged(h, 'nymphSoul', e)[0];
|
||||
assert.equal(soul.type, 'elemental');
|
||||
approx(soul.amount, nymph.s.atk * tal(nymph, 0).element_atk_scale * 1.5, 1e-6, '失魂 × elemTakenMul only');
|
||||
approx(soul.amount, nymph.s.atk * tal(nymph, 0).element_atk_scale * 1.5, 1e-6, '失魂 × elementalTakenMul only');
|
||||
clean(h);
|
||||
});
|
||||
|
||||
|
||||
@@ -143,14 +143,14 @@ test('#12 炎佑: 元素脆弱 aura within 1.5 of itself, immune to element dama
|
||||
const [near, far] = h.enemies();
|
||||
assert.ok(near.findBuff('elemFragile'), '1.2 away: 元素脆弱');
|
||||
assert.ok(!far.findBuff('elemFragile'), '3.2 away: none');
|
||||
assert.ok(Math.abs(near.s.elemTakenMul - 1.2) < 1e-9, 'element damage ×1.2');
|
||||
assert.ok(Math.abs(near.s.elementalTakenMul - 1.2) < 1e-9, '元素伤害 ×1.2');
|
||||
// element damage on the dragon is cancelled
|
||||
h.b.dealDamage(near, y, { type: 'element', element: 'burn', amount: 800 });
|
||||
assert.equal(y.elem.burn, 0, 'no burn gauge on 炎佑');
|
||||
// normal hits: burn = ATK × 0.2 (× the 1.2 fragility) per hit
|
||||
// normal hits: burn = ATK × 0.2 per hit — the 1.2 元素脆弱 (2.damage_scale) raises 元素伤害, never the gauge fill
|
||||
const before = near.elem.burn;
|
||||
assert.ok(h.runUntil(() => near.elem.burn > before, 5));
|
||||
const tal = h.b.data.rawToken(TOKEN_IDS.yanyou).talents.bb;
|
||||
assert.ok(Math.abs(near.elem.burn - before - y.s.atk * tal['2.ep_damage_ratio'] * tal['2.damage_scale']) < 1e-6, `burn per hit ${near.elem.burn - before}`);
|
||||
assert.ok(Math.abs(near.elem.burn - before - y.s.atk * tal['2.ep_damage_ratio']) < 1e-6, `burn per hit ${near.elem.burn - before}`);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
// 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');
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
// test/content/playtest5_followups.test.js — user playtest #5, cross-workstream follow-ups of the integration
|
||||
// (docs/DESIGN.md §19): the element pipeline rules the workstreams left to the integrator, 塑心 S2's timing and
|
||||
// 纯烬艾雅法拉's per-source 氤氲.
|
||||
// * damage.js applyElement refuses a target with no HP left (hasHp — one shared helper): a rider in a `damaged` hook
|
||||
// of a killing blow cannot burst the corpse, whatever content adds it.
|
||||
// * 脆弱 (dmgTakenMul) does not scale 元素伤害 — gamedata_const ba.fragile "受到的物理、法术、真实伤害提升"; 元素脆弱
|
||||
// (elementalTakenMul) does.
|
||||
// * element healing lowers EVERY element type by the amount, each on its own — PRTS 菲莱 备注: "清除元素损伤" = "一次等同
|
||||
// 于自身最大元素值(通常为1000)的全类型元素损伤治疗".
|
||||
// * 塑心 S2 安魂的弥撒: "…凋亡损伤生效于当次触发的伤害之前,该造成的凋亡损伤的来源始终为塑心" (PRTS 备注) — the 凋亡 lands
|
||||
// before the partner's damage, so a killing blow still carries it.
|
||||
// * 纯烬艾雅法拉 氤氲 is keyed per 纯烬 (two of them keep their own stacks and heal source).
|
||||
|
||||
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 { hasHp } from '../../server/sim/damage.js';
|
||||
|
||||
const SRC = getDefaultSource();
|
||||
const raw = (id) => SRC.rawChess(id);
|
||||
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 dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
|
||||
const plain = (id, o = {}) => chessRec({ id, skill: null, stats: { maxHp: 1e6, ...(o.stats || {}) }, ...o });
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// damage.js
|
||||
|
||||
test('applyElement refuses a target with no HP left: a rider on a killing blow bursts nothing (pipeline guard)', () => {
|
||||
for (const lethal of [true, false]) {
|
||||
let riders = 0;
|
||||
const h = makeBattle({
|
||||
content: 'none', autoFinish: false, timeLimit: 30,
|
||||
defs: { enemies: { enemy_fu: dummy('enemy_fu', { hp: 1000 }) } },
|
||||
enemies: [{ key: 'enemy_fu', pos: [10, 6] }],
|
||||
// a content rider with no HP check of its own: 1000 灼燃 on every damage it sees
|
||||
setup: (b) => b.on('damaged', (c) => {
|
||||
if (c.type === 'element' || c.type === 'elemental' || c.target.side !== 'enemy') return;
|
||||
riders++;
|
||||
b.dealDamage(null, c.target, { type: 'element', element: 'burn', amount: 1000 });
|
||||
}),
|
||||
});
|
||||
h.step();
|
||||
const e = h.enemy('enemy_fu');
|
||||
h.b.dealDamage(null, e, { amount: lethal ? 5000 : 10, type: 'true' });
|
||||
assert.equal(riders, 1, 'the rider ran');
|
||||
if (lethal) {
|
||||
assert.equal(e.alive, false);
|
||||
assert.equal(e.elem.burn, 0, 'no fill on the corpse');
|
||||
assert.equal(h.hooksOf('elementBurst').length, 0, 'no burst on the corpse');
|
||||
assert.equal(h.hooksOf('fatal').length, 1, 'one fatal');
|
||||
assert.equal(h.hooksOf('kill').length, 1, 'one kill');
|
||||
} else {
|
||||
assert.equal(h.hooksOf('elementBurst').length, 1, 'a living target still bursts');
|
||||
}
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
assert.equal(hasHp(null), false);
|
||||
assert.equal(hasHp({ alive: true, hp: 0 }), false);
|
||||
assert.equal(hasHp({ alive: true, hp: 0, bossPool: { hp: 5 } }), true, 'a boss: its pool');
|
||||
assert.equal(hasHp({ alive: false, hp: 5 }), false);
|
||||
});
|
||||
|
||||
test('脆弱 does not scale 元素伤害 (ba.fragile: 物理、法术、真实); 元素脆弱 does', () => {
|
||||
const h = makeBattle({ content: 'none', autoFinish: false, timeLimit: 30, defs: { enemies: { enemy_fu: dummy('enemy_fu') } }, enemies: [{ key: 'enemy_fu', pos: [10, 6] }] });
|
||||
h.step();
|
||||
const e = h.enemy('enemy_fu');
|
||||
const loss = (d) => { const hp0 = e.hp; h.b.dealDamage(null, e, d); return hp0 - e.hp; };
|
||||
h.b.applyStatus(e, 'fragile', { duration: 10, value: 0.5 });
|
||||
approx(loss({ amount: 1000, type: 'true' }), 1500, '脆弱 on true damage');
|
||||
approx(loss({ amount: 1000, type: 'elemental', element: 'burn' }), 1000, '脆弱 skips 元素伤害');
|
||||
h.b.applyStatus(e, 'elemFragile', { duration: 10, value: 0.2 });
|
||||
approx(loss({ amount: 1000, type: 'elemental', element: 'burn' }), 1200, '元素脆弱');
|
||||
approx(loss({ amount: 1000, type: 'true' }), 1500, '元素脆弱 skips true damage');
|
||||
});
|
||||
|
||||
test('element healing lowers every element type by the amount, each on its own (PRTS 菲莱 备注)', () => {
|
||||
const h = makeBattle({ content: 'none', autoFinish: false, timeLimit: 30, defs: { chess: { g: plain('g') } }, units: [{ chessId: 'g', row: 10, col: 4 }] });
|
||||
h.step();
|
||||
const g = h.unit('g');
|
||||
Object.assign(g.elem, { burn: 300, neural: 500, apoptosis: 0 });
|
||||
approx(h.b.reduceElement(g, 400), 700, 'total removed');
|
||||
assert.deepEqual([g.elem.burn, g.elem.neural, g.elem.apoptosis], [0, 100, 0], 'each type −400 (was: 400 in total)');
|
||||
Object.assign(g.elem, { burn: 300, neural: 500 });
|
||||
approx(h.b.reduceElement(g, 200, 'neural'), 200, 'one element');
|
||||
assert.deepEqual([g.elem.burn, g.elem.neural], [300, 300]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 塑心 S2
|
||||
|
||||
test('塑心 S2 安魂的弥撒: the partner\'s 凋亡 lands before its damage (PRTS 备注) — a killing blow still carries it', () => {
|
||||
const id = 'chess_char_6_09_a', sid = 'skchr_cello_2';
|
||||
const s2 = raw(id).skills.find((s) => s.skillId === sid);
|
||||
for (const hp of [1e7, 50]) {
|
||||
const seq = [];
|
||||
const h = makeBattle({
|
||||
seed: 7, autoFinish: false, timeLimit: 60,
|
||||
defs: { chess: { big: plain('big', { stats: { atk: 2000 } }) }, enemies: { enemy_fu: dummy('enemy_fu', { hp }) } },
|
||||
units: [{ chessId: id, row: 10, col: 4, skillIndex: s2.index, carryState: { sp: 999 } }, { chessId: 'big', row: 10, col: 5 }],
|
||||
enemies: [{ key: 'enemy_fu', pos: [10, 6] }],
|
||||
setup: (b) => {
|
||||
b.on('elementHit', (c) => { if (c.dmg.element === 'apoptosis' && c.target.side === 'enemy') seq.push(['el', c.source?.defId, c.target.hp]); });
|
||||
b.on('damaged', (c) => { if (c.source?.defId === 'big' && c.type === 'phys') seq.push(['dmg', 'big', c.target.hp]); });
|
||||
},
|
||||
});
|
||||
const u = h.unit(id), big = h.unit('big');
|
||||
assert.ok(h.runUntil(() => u.skill.active && u.mem.celloPartner === big, 5), 'S2 running, partner picked');
|
||||
assert.ok(h.runUntil(() => seq.some((x) => x[0] === 'dmg'), 10), 'the partner hits');
|
||||
const i = seq.findIndex((x) => x[0] === 'dmg');
|
||||
assert.ok(i > 0 && seq[i - 1][0] === 'el' && seq[i - 1][1] === id, `the 凋亡 (source 塑心) right before the damage: ${JSON.stringify(seq.slice(0, i + 1))}`);
|
||||
assert.ok(seq[i - 1][2] > 0, 'the target still had its HP when the 凋亡 landed');
|
||||
if (hp < 1000) assert.equal(h.enemy('enemy_fu')?.alive ?? false, false, 'the killing blow landed after it');
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 纯烬艾雅法拉 氤氲
|
||||
|
||||
test('纯烬艾雅法拉 氤氲 is keyed per 纯烬: two of them keep their own stacks and each heals as itself', () => {
|
||||
const id = 'chess_char_6_20_a';
|
||||
const h = makeBattle({
|
||||
seed: 5, autoFinish: false, timeLimit: 60,
|
||||
defs: { chess: { g: plain('g') } },
|
||||
// the ward stands outside both ranges: only the manual heals below reach it
|
||||
units: [{ chessId: id, uid: 1, row: 12, col: 2 }, { chessId: id, uid: 2, row: 12, col: 3 }, { chessId: 'g', uid: 3, row: 9, col: 10 }],
|
||||
});
|
||||
h.step();
|
||||
const a = h.unit(1), b = h.unit(2), g = h.unit(3);
|
||||
h.b.heal(a, g, 1);
|
||||
h.b.heal(b, g, 1);
|
||||
h.b.heal(b, g, 1);
|
||||
const ma = g.findBuff(`agoat2:mist:${a.id}`), mb = g.findBuff(`agoat2:mist:${b.id}`);
|
||||
assert.ok(ma && mb, 'one 氤氲 per 纯烬');
|
||||
assert.deepEqual([ma.stacks, mb.stacks], [1, 2]);
|
||||
assert.equal(ma.source, a);
|
||||
assert.equal(mb.source, b);
|
||||
});
|
||||
@@ -478,7 +478,7 @@ test('炎佑: spawnYanyou — flying ally, bond stats, 3 targets with burn + ele
|
||||
assert.ok(burned, 'burn gauge');
|
||||
const frag = h.enemies().find((e) => e.findBuff('elemFragile'));
|
||||
assert.ok(frag, 'elemental fragility (元素脆弱 status)');
|
||||
approx(frag.s.elemTakenMul, 1.2, 1e-9);
|
||||
approx(frag.s.elementalTakenMul, 1.2, 1e-9);
|
||||
assert.ok(h.runUntil(() => y.skill.activations >= 1, 10), '祛恶之焰');
|
||||
approx(y.skill.lastStart, 15, 1.5, 'first flame after initCooldown');
|
||||
h.run(2);
|
||||
@@ -885,8 +885,8 @@ test('炎佑 details: plain spawnToken works (enemy-shaped record), burn gauge =
|
||||
assert.equal(y.motion, 'FLY');
|
||||
const e = h.enemy('enemy_dummy');
|
||||
assert.ok(h.runUntil(() => y.stats.attacks >= 1 && e.elem.burn > 0, 5));
|
||||
// the hit's own 元素脆弱 (×1.2) lands before its burn fill
|
||||
approx(e.elem.burn, 1000 * 0.2 * 1.2, 1e-6, 'gauge per hit');
|
||||
// 元素脆弱 raises 元素伤害 only ("受到的元素伤害提升"), never the gauge fill (damage.js)
|
||||
approx(e.elem.burn, 1000 * 0.2, 1e-6, 'gauge per hit');
|
||||
assert.ok(h.runUntil(() => h.hooksOf('elementBurst').length > 0, 20), 'burn burst');
|
||||
const burst = h.hooksOf('damaged').find((c) => c.type === 'elemental' && c.target === e);
|
||||
assert.ok(burst, 'elemental burst damage');
|
||||
|
||||
Reference in new issue
Block a user