feedback7: merge fb7-owner-sim — community reports via the owner: 白铁's 铁钳号 opens only the skills that act on it (圣约送葬人 spends and stacks), 伊内丝 S3 after a 突袭 jump, 引星棘刺 alchemy units as HP regen (不屈者 / 禁疗), 不可对空 effects spare flying allies (炎佑), 望's 跟子 at battle start, 香槟炸弹 unhealable / no HP loss (owner decision) and not set off by unrevealed 隐匿 or sleeping enemies

- Grok review: MERGE AFTER FIXES — the HIGH finding (skills opened on the device but spending nothing) fixed on the branch
  (69d791f9: skills.js allyTargetsOk, per-kit allyTargets flags).
- Golden union OK; broad node --test 5500 (5499 pass, 0 fail, 1 skipped); lint 0 errors.
This commit is contained in:
sganggs committed 2026-10-08 21:14:27 +08:00
commit f5595a7a31
43 files changed
+1129 -356

No files matched your search

@@ -0,0 +1,106 @@
// test/content/feedback7-allytarget-kits.test.js — a skill that 白铁's 铁钳号·原型机 (a registered ally target) opens must act on
// it (Grok's review of round 22: 蕾缪安 S3, 艾丽妮 S3, 陈 S2 and 引星棘刺 S2 opened on the device alone and were spent on
// nothing — their kits picked enemies only). The owner's rule (2026-10-08): the device draws aggro like an enemy. So:
// - these four kits now take ally targets in their selectors (the hits are cancelled by the device, its 受击回复 counts;
// 蕾缪安's locks spend her bullets);
// - a skill whose effect does not reach it — one that picks its own victims and has no kit flag `allyTargets: true` — is
// not opened by it (skills.js `allyTargetsOk`), e.g. 德克萨斯 S2 剑雨: it waits for an enemy.
// Run: node --test test/content/feedback7-allytarget-kits.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const CRAB = 'token_10027_ironmn_pile3';
const IRON = { uid: 2, diy: { slot: 6, charId: 'char_4072_ironmn', skillIndex: 2 }, elite: true, row: 12, col: 9, dir: 'RIGHT' };
const crabAt = (col) => ({ uid: 10, kind: 'token', tokenId: CRAB, ownerUid: 2, row: 10, col, dir: 'RIGHT' });
const diy = (charId, skillIndex) => ({ uid: 1, diy: { slot: 6, charId, skillIndex }, elite: true, row: 10, col: 4, dir: 'RIGHT' });
function field(unit, extra = {}) {
const h = makeBattle({
timeLimit: 60, autoFinish: false, seed: 3, flags: { dpPerSec: 0, dpMax: 999, dpInit: 99 },
hooks: ['hit', 'skillStart', 'skillEnd', 'ammoUsed'], captureNoisy: true,
defs: { enemies: { e_d: enemyRec({ key: 'e_d', hp: 1e7, speed: 0, def: 0, atk: 0 }) } },
units: [unit, IRON, crabAt(5)], ...extra,
});
h.step();
const u = h.unit(1);
const crab = h.b.allyUnits.find((a) => a.defId === CRAB);
return { h, u, crab };
}
/** Hits of `u`'s skill on the device (cancelled by it). */
const skillHits = (h, u, crab) => h.hooksOf('hit').filter((c) => c.target === crab && (c.source === u || c.credit === u) && c.dmg?.isSkill);
test('蕾缪安 S3: on the device alone she locks it, spends her bullets on it and ends with the last one', () => {
const { h, u, crab } = field({ uid: 1, chessId: 'chess_char_6_01_a', skillIndex: 2, row: 10, col: 4, dir: 'RIGHT' });
assert.equal(u.skill.id, 'skchr_lemuen_3');
u.skill.gainSp(999, 'test');
const ammo = u.skill.ammo;
assert.ok(h.runUntil(() => u.skill.activations > 0, 5), 'opened');
assert.ok(h.runUntil(() => !u.skill.active, 15), 'ended');
assert.ok(h.hooksOf('skillEnd').some((c) => c.unit === u && c.reason === 'ammo'), 'by its bullets');
assert.ok(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length >= ammo, `${ammo} bullets spent`);
assert.equal(crab.hp, crab.s.maxHp);
checkInvariants(h.b);
});
test('艾丽妮 S3 / 陈 S2: on the device alone their strikes land on it (cancelled)', () => {
for (const [charId, si, id] of [['char_4009_irene', 2, 'skchr_irene_3'], ['char_010_chen', 1, 'skchr_chen_2']]) {
const { h, u, crab } = field(diy(charId, si));
assert.equal(u.skill.id, id);
u.skill.gainSp(999, 'test');
assert.ok(h.runUntil(() => u.skill.activations > 0, 5), `${id} opened`);
h.run(4);
assert.ok(skillHits(h, u, crab).length >= 1, `${id} hit the device`);
assert.equal(crab.hp, crab.s.maxHp);
checkInvariants(h.b);
}
});
test('引星棘刺 S2: on the device alone the alchemy unit lands on it, not on the far tile ahead', () => {
const { h, u, crab } = field({ uid: 1, chessId: 'chess_char_5_15_a', skillIndex: 1, row: 10, col: 4, dir: 'RIGHT' });
assert.equal(u.skill.id, 'skchr_thorn2_2');
u.skill.gainSp(999, 'test');
assert.ok(h.runUntil(() => u.skill.activations > 0, 5), 'opened');
const z = u.mem.zones?.[0];
assert.ok(z, 'a zone');
assert.ok(Math.abs(z.x - crab.x) < 0.3 && Math.abs(z.y - crab.y) < 0.3, `thrown at the device (${z.x}, ${z.y})`);
checkInvariants(h.b);
});
test('a skill whose effect picks enemies only (德克萨斯 S2 剑雨) is not opened by the device; an enemy opens it', () => {
const { h, u } = field({ uid: 1, chessId: 'chess_char_1_08_a', skillIndex: 1, row: 10, col: 4, dir: 'RIGHT' });
assert.equal(u.skill.id, 'skchr_texas_2');
u.skill.gainSp(999, 'test');
const ch = u.skill.charges;
h.run(5);
assert.equal(u.skill.activations, 0, 'kept for an enemy');
assert.equal(u.skill.charges, ch);
h.spawn('e_d', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.activations > 0, 5), 'an enemy opens it');
checkInvariants(h.b);
});
test('the engine rule: the device opens a skill acting through the attacks or flagged allyTargets — not an onStart-only or a no-attack one', () => {
const id = 'test_cast_a';
const opens = (skill) => {
const h = makeBattle({
defs: { chess: { [id]: chessRec({ id, profession: 'SNIPER', rangeGrid: [[0, 0], [0, 1]], skill: { skillType: 'MANUAL', spCost: 1, initSp: 1 } }) } },
kits: { [id]: () => ({ skill: { spCost: 1, initSp: 1, trigger: 'DEFAULT', ...skill } }) },
units: [{ uid: 1, chessId: id, row: 10, col: 4, dir: 'RIGHT' }, IRON, crabAt(5)],
timeLimit: 20, autoFinish: false, seed: 2, flags: { dpPerSec: 0, dpMax: 999 },
});
h.step();
const u = h.unit(1);
u.skill.gainSp(999, 'test');
h.run(3);
checkInvariants(h.b);
return u.skill.activations > 0;
};
assert.equal(opens({ kind: 'duration', duration: 3 }), true, 'a timed skill that keeps attacking');
assert.equal(opens({ kind: 'instant', attack: { atkScale: 2 } }), true, 'a cast that is the next attack');
assert.equal(opens({ kind: 'instant', onStart() {} }), false, 'an onStart-only cast');
assert.equal(opens({ kind: 'duration', duration: 3, attack: { noAttack: true } }), false, 'a timed skill that stops attacking');
assert.equal(opens({ kind: 'instant', onStart() {}, allyTargets: true }), true, 'flagged by its kit');
assert.equal(opens({ kind: 'duration', duration: 3, allyTargets: false }), false, 'opted out by its kit');
});
@@ -0,0 +1,67 @@
// test/content/feedback7-champagne-hold.test.js — 琳琅诗怀雅 S2's 香槟炸弹 (token_10031_swire2_gdtrap) under the owner's
// decision D2 of 2026-10-08: it holds 禁疗, loses no HP (活性源石, damage, a 流失) and never leaves because of its HP; its
// 见面礼 is unchanged (the first ground enemy that touches it, then it is gone). Both kits: 琳琅诗怀雅's own bomb and the
// token kit (content/tokens.js champagne, a bomb spawned without her kit).
// Run: node --test test/content/feedback7-champagne-hold.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const TRAP = 'token_10031_swire2_gdtrap';
const SW = 'chess_char_3_04_a';
/** 琳琅诗怀雅 on (9,5) of a field whose only free x-6 tile is (9,6) — 活性源石 when `infected`; she throws one bomb there. */
function herBomb({ infected = false, units = [], enemies = [] } = {}) {
const row9 = infected ? '##ERRriRrrSrrrrrrrS##' : '##ERRrrRrrSrrrrrrrS##';
const h = makeBattle({
flat: { rows: { 9: row9, 10: '##hrrRrrrrfrrrrrrrf##', 11: '##hrrRrrrrfrrrrrrrf##' } },
defs: { enemies: { e_w: enemyRec({ key: 'e_w', hp: 1e6, speed: 1, def: 0, atk: 0 }) } },
timeLimit: 60, autoFinish: false, hooks: ['heal', 'death', 'damaged'], captureNoisy: true,
units: [{ uid: 1, chessId: SW, row: 9, col: 5 }, ...units], enemies,
});
h.step();
h.unit(1).mem.coins = 1;
assert.ok(h.runUntil(() => h.b.allyUnits.some((t) => t.alive && t.defId === TRAP), 6), 'she threw a bomb');
const bomb = h.b.allyUnits.find((t) => t.alive && t.defId === TRAP);
assert.deepEqual([bomb.tileR, bomb.tileC], [9, 6]);
return { h, bomb, sw: h.unit(1) };
}
test('香槟炸弹 (her kit): no HP loss on 活性源石, 禁疗, a lethal 流失 or hit leaves it at full HP and on the field', () => {
const { h, bomb } = herBomb({ infected: true });
assert.ok(bomb.s.flags.untargetable && bomb.s.flags.noHeal && bomb.s.flags.healFree, '不可选中 + 禁疗');
assert.ok(!bomb.s.flags.invulnerable, 'no 无敌 flag (no badge on the client)');
h.run(3.2);
assert.equal(bomb.hp, bomb.s.maxHp, '活性源石 takes nothing');
assert.equal(h.b.heal(h.unit(1), bomb, 500), 0, 'no heal reaches it');
assert.equal(h.b.injuredAlliesInKeys([9 * 21 + 6], h.unit(1)).length, 0, 'no healer picks it');
h.b.dealDamage(null, bomb, { amount: 1e6, type: 'true', tags: ['test'] });
h.b.loseHp(bomb, 1e6, { tags: ['test'] });
h.step();
assert.ok(bomb.alive && bomb.deployed, 'not gone because of its HP');
assert.equal(bomb.hp, bomb.s.maxHp);
assert.equal(h.hooksOf('death').filter((c) => c.unit === bomb).length, 0);
checkInvariants(h.b);
});
test('香槟炸弹 (her kit): its 见面礼 is unchanged — the first ground enemy touching it is hit and slowed, then the bomb is gone', () => {
const { h, bomb } = herBomb({ enemies: [{ key: 'e_w', route: 0, time: 1 }] });
assert.ok(h.runUntil(() => !bomb.alive, 30), 'set off');
const e = h.b.enemies.find((x) => x.alive);
assert.ok(e && e.hp < 1e6 && e.findBuff('sluggish'), 'hit + 停顿');
assert.ok(h.hooksOf('death').some((c) => c.unit === bomb && c.reason === 'expired'), 'used up');
checkInvariants(h.b);
});
test('香槟炸弹 (the token kit, spawned without her kit): the same 禁疗, no HP loss, no knock-out', () => {
const h = makeBattle({ timeLimit: 30, autoFinish: false, hooks: ['death'], units: [{ uid: 1, chessId: SW, row: 12, col: 3 }] });
h.step();
const bomb = h.b.spawnToken(h.unit(1), TRAP, 9, 6);
assert.ok(bomb && bomb.s.flags.noHeal && bomb.s.flags.healFree && bomb.s.flags.untargetable);
h.b.loseHp(bomb, 1e6, { tags: ['test'] });
h.b.dealDamage(null, bomb, { amount: 1e6, type: 'true', tags: ['test'] });
h.step();
assert.ok(bomb.alive && bomb.hp === bomb.s.maxHp);
assert.equal(h.b.heal(h.unit(1), bomb, 500), 0);
checkInvariants(h.b);
});
@@ -0,0 +1,56 @@
// test/content/feedback7-champagne-stealth.test.js — 琳琅诗怀雅's 香槟炸弹 does not go off on a 隐匿 enemy that is neither
// revealed nor blocked (community report: 「香槟炸到没有破隐的隐匿敌人了」). PRTS 作战机制 §隐匿: "隐匿效果使得获得该效果的单位无法被
// 任何敌方的能力索敌选中" — the bomb's pages carry no 无视隐匿 note, and the other contact traps (多萝西's 共振装置, 望's 棋子)
// select their enemies so too. A revealed (反隐) or blocked one, and every visible ground enemy, still set it off.
// Run: node --test test/content/feedback7-champagne-stealth.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const TRAP = 'token_10031_swire2_gdtrap';
const trapHits = (h, e) => h.hooksOf('damaged').filter((c) => c.target === e && c.dmg?.tags?.includes('trap'));
test('her bombs: a 隐匿 walker crosses one unharmed; a visible walker after it sets it off', () => {
const h = makeBattle({
timeLimit: 120, autoFinish: false, hooks: ['damaged', 'death'], captureNoisy: true,
defs: { enemies: { e_w: enemyRec({ key: 'e_w', hp: 1e6, speed: 1, def: 0, atk: 0 }) } },
units: [{ uid: 1, chessId: 'chess_char_3_04_a', row: 9, col: 5 }],
});
h.step();
const sw = h.unit(1);
const bombAt = (r, c) => h.b.allyUnits.find((t) => t.alive && t.defId === TRAP && t.tileR === r && t.tileC === c);
for (let i = 0; i < 15 && !bombAt(9, 7); i++) { sw.mem.coins = 3; h.run(2); }
const bomb = bombAt(9, 7);
assert.ok(bomb, 'a bomb on (9,7), on the walkers\' road before her block');
const hidden = h.spawn('e_w', { route: 0 });
h.b.addBuff(hidden, { key: 'test:stealth', persist: true, flags: { stealth: true } });
assert.ok(h.runUntil(() => hidden.x < 6.6, 20), 'it walked over (9,7)');
assert.ok(bomb.alive, 'not set off');
assert.equal(trapHits(h, hidden).length, 0);
const seen = h.spawn('e_w', { route: 0 });
assert.ok(h.runUntil(() => !bomb.alive, 20), 'a visible walker sets it off');
assert.ok(trapHits(h, seen).length > 0 && seen.findBuff('sluggish'));
checkInvariants(h.b);
});
test('a bomb (the token kit) under a standing 隐匿 enemy waits; revealed (反隐), it goes off', () => {
const h = makeBattle({
timeLimit: 30, autoFinish: false, hooks: ['damaged'], captureNoisy: true,
defs: { enemies: { e_d: enemyRec({ key: 'e_d', hp: 1e6, speed: 0, def: 0, atk: 0 }) } },
units: [{ uid: 1, chessId: 'chess_char_3_04_a', row: 12, col: 3 }], enemies: [{ key: 'e_d', pos: [9, 7] }],
});
h.step();
const e = h.b.enemies.find((x) => x.alive);
h.b.addBuff(e, { key: 'test:stealth', persist: true, flags: { stealth: true } });
const bomb = h.b.spawnToken(h.unit(1), TRAP, 9, 7);
assert.ok(bomb);
h.run(2);
assert.ok(bomb.alive && !e.blockedBy, 'a 隐匿 enemy on its tile does not set it off');
assert.equal(trapHits(h, e).length, 0);
h.b.addBuff(e, { key: 'test:reveal', persist: true, flags: { reveal: true } });
h.step(2);
assert.ok(!bomb.alive, 'revealed: it goes off');
assert.ok(trapHits(h, e).length > 0);
checkInvariants(h.b);
});
+69
View File
@@ -0,0 +1,69 @@
// test/content/feedback7-ines-raid.test.js — 伊内丝 S3 独影归途 under 突袭 (community report: 伊内丝激活突袭瞬移以后3技能被吞).
// S3 costs no SP and runs from every deployment but the first (skill_table skchr_ines_3: PASSIVE, spCost 0, "部署后…";
// her kit casts it in its deploy hook). The 突袭 jump (activity_table bondeffect_raid "保留技力立即再部署") retreats her —
// ending the running S3 — and redeploys her with the SP kept (Battle.redeploy keepSp): that snapshot held 0 charges and
// overwrote the charge reset() gives a free skill at every deployment, so the landing could not recast S3. A skill that
// costs SP still keeps its SP / charges.
// Run: node --test test/content/feedback7-ines-raid.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
function field(skillIndex) {
const landings = [];
const h = makeBattle({
defs: { enemies: { e_walk: enemyRec({ key: 'e_walk', hp: 1e7, speed: 0.5, atk: 0 }) } },
timeLimit: 20, autoFinish: false, seed: 1, flags: { dpInit: 99, dpPerSec: 0, dpMax: 999 },
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 0 } },
units: [{ uid: 1, chessId: 'chess_char_4_04_a', row: 12, col: 3, dir: 'RIGHT', skillIndex }],
enemies: [{ key: 'e_walk', time: 1.2, routeIndex: 0, count: 1 }],
hooks: ['skillStart', 'skillEnd', 'death'],
setup: (b) => {
// priority 1000: after the keepSp restore, before her kit's deploy hook (which casts S3)
b.on('deploy', ({ unit }) => {
if (unit.uid !== 1 || b.time < 0.5) return;
const sk = unit.skill;
landings.push({ t: b.time, tile: [unit.tileR, unit.tileC], charges: sk.charges, sp: sk.sp, active: sk.active });
}, { priority: 1000 });
},
});
h.step();
return { h, landings, u: h.b.allyUnits.find((x) => x.uid === 1) };
}
test('伊内丝 S3 starts again on the 突袭 landing: the free charge reset() gave is not overwritten by the kept 0', () => {
const { h, landings, u } = field(2);
assert.equal(u.skill.id, 'skchr_ines_3');
h.run(4);
assert.ok(h.hooksOf('death').some((c) => c.unit === u && c.reason === 'raid'), 'she jumped');
assert.equal(landings.length, 1);
assert.notDeepEqual(landings[0].tile, [12, 3], 'landed beside the enemy');
assert.equal(landings[0].charges, 1, 'armed at the landing');
const starts = h.hooksOf('skillStart').filter((c) => c.unit === u);
assert.equal(starts.length, 2, 'the second deployment and the landing');
assert.ok(Math.abs(starts[1].t - landings[0].t) < 1e-9);
assert.equal(u.skill.active, true);
assert.ok(u.skill.timeLeft > 5, `a fresh S3 (${u.skill.timeLeft.toFixed(2)} s left)`);
assert.ok(u.findBuff('ines:s3'), 'its ATK buff');
checkInvariants(h.b);
});
test('突袭 still keeps the SP of a skill that costs SP (伊内丝 S2: a full charge, and a partial bar)', () => {
const full = field(1);
assert.equal(full.u.skill.id, 'skchr_ines_2');
full.u.skill.gainSp(999, 'test');
full.h.run(4);
assert.equal(full.landings.length, 1);
assert.equal(full.landings[0].charges, 1);
assert.equal(full.landings[0].sp, full.u.skill.spCost);
checkInvariants(full.h.b);
const part = field(1);
const initSp = part.u.skill.sp;
part.h.run(12);
assert.equal(part.landings.length, 1, 'the idle trigger');
assert.equal(part.landings[0].charges, 0);
assert.ok(part.landings[0].sp > initSp + 5, `kept ${part.landings[0].sp} (a bar that started at ${initSp})`);
checkInvariants(part.h.b);
});
@@ -0,0 +1,55 @@
// test/content/feedback7-thorn2-regen.test.js — 引星棘刺's alchemy units restore HP as 生命回复速度 (community report:
// 炼金师分支干员的炼金单元无法给不屈者等禁疗的干员提供生命恢复). S1 度算浪波 "每秒回复相当于攻击力…的生命值" and S2 解构涌潮
// "友方单位每秒恢复相当于攻击力…的生命" carry 锡人 S2's wording and blackboard key (hp_recovery_per_sec_ratio), and PRTS 锡人
// 备注 says that recovery "为增加目标的“生命回复速度”属性,不受治疗加成和禁疗影响" — an hpRegen buff, which 不屈者 (no heal
// from others) and 禁疗 units take too. Up to 0.2.1 both were heals (battle.heal), which those units refuse.
// Run: node --test test/content/feedback7-thorn2-regen.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
/** 引星棘刺 (skill `skillIndex`) beside 角峰 (10,5), 折桠 — 不屈者 — (10,6) and a 禁疗 角峰 (10,7); her skill cast at once. */
function field(skillIndex) {
const h = makeBattle({
defs: { enemies: { e_dummy: enemyRec({ key: 'e_dummy', hp: 1e9, speed: 0, atk: 0 }) } },
timeLimit: 20, autoFinish: false, seed: 2, flags: { dpInit: 99, dpPerSec: 0, dpMax: 999 }, hooks: ['heal'], captureNoisy: true,
units: [
{ uid: 1, chessId: 'chess_char_5_15_a', row: 10, col: 4, dir: 'RIGHT', skillIndex },
{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 5 },
{ uid: 3, chessId: 'chess_char_2_17_a', row: 10, col: 6 },
{ uid: 4, chessId: 'chess_char_1_02_a', row: 10, col: 7 },
],
});
h.step();
const [thorn, plain, unyield, banned] = [1, 2, 3, 4].map((uid) => h.b.allyUnits.find((u) => u.uid === uid));
h.b.addBuff(banned, { key: 'test:healFree', status: 'healFree', flags: { noHeal: true, healFree: true } }); // 禁疗
plain.hp = plain.s.maxHp * 0.3;
unyield.hp = unyield.s.maxHp * 0.2; // S1 picks the lowest HP ratio: its 3×3 covers all three
banned.hp = banned.s.maxHp * 0.3;
const hp0 = new Map([plain, unyield, banned].map((a) => [a, a.hp]));
h.spawn('e_dummy', { pos: [10, 6] });
thorn.skill.gainSp(999, 'test');
assert.ok(thorn.skill.activate('manual'), thorn.skill.id);
h.run(3.2);
return { h, thorn, plain, unyield, banned, gained: (a) => a.hp - hp0.get(a) };
}
for (const [skillIndex, id, key] of [[0, 'skchr_thorn2_1', 'hp_recovery_per_sec_ratio'], [1, 'skchr_thorn2_2', 'hp_recovery_per_sec_ratio_chr']]) {
test(`引星棘刺 ${id}: the alchemy unit's 生命回复速度 reaches 不屈者 (折桠) and 禁疗 operators as much as the others`, () => {
const { h, thorn, plain, unyield, banned, gained } = field(skillIndex);
assert.equal(thorn.skill.id, id);
assert.equal(unyield.profile.noHeal, true, '不屈者: no heal from others');
const perSec = thorn.s.atk * thorn.def.skill.bb[key];
for (const a of [plain, unyield, banned]) {
const regen = a.buffs.find((b) => b.source === thorn && b.mods?.hpRegen > 0);
assert.ok(regen, `${a.defId}: an hpRegen buff`);
assert.ok(Math.abs(regen.mods.hpRegen - perSec) < 1e-6, `${regen.mods.hpRegen} ≈ ${perSec}`);
assert.ok(gained(a) > perSec, `${a.defId} recovered ${gained(a).toFixed(1)}`);
}
assert.ok(Math.abs(gained(unyield) - gained(plain)) < 1, 'the same for 不屈者');
assert.ok(Math.abs(gained(banned) - gained(plain)) < 1, 'the same under 禁疗');
assert.equal(h.hooksOf('heal').filter((c) => c.source === thorn).length, 0, 'no heal of hers (治疗加成 does not apply)');
checkInvariants(h.b);
});
}
@@ -0,0 +1,88 @@
// test/content/feedback7-yanyou-noair.test.js — 炎佑 is a flying ally (PRTS “炎佑” 行动方式 飞行): the enemy effects PRTS marks
// 不可对空 never reach it (community report: 炎祐好像吃到了不该吃到的地面伤害). The death blasts of 高能源石虫 / 冰爆源石虫 /
// 卷心籽 ("…无视迷彩,不可对空"), 水遁忍者's 漩涡形态 ("每秒对半径1.0范围内的所有我方单位造成…(无视迷彩,不可对空)"), “萨科塔昂首”'s
// 祈祷邀约 ("令全场我方单位(不可对空,无视迷彩)获得15s【受邀祈祷】"), 鼎沸's pulses ("攻击范围内的所有我方单位每秒受到…(不可对空)")
// and the attack of “斩胄之剑” / “破胄之锤” ("普通攻击对攻击范围内的所有我方单位造成…物理普通伤害,不可对空") took it until 0.2.1; a
// ground operator in reach still does.
// Run: node --test test/content/feedback7-yanyou-noair.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, checkInvariants } from '../helpers/battleHarness.js';
import { spawnYanyou } from '../../server/sim/content/tokens.js';
/** 角峰 on (10,5), the 炎佑 dragon moved onto (10,6). */
function field(seed = 4) {
const h = makeBattle({ timeLimit: 60, autoFinish: false, seed, hooks: ['damaged'], captureNoisy: true,
units: [{ uid: 1, chessId: 'chess_char_1_02_a', row: 10, col: 5 }] });
h.step();
const dragon = spawnYanyou(h.b, 'p1', { count: 1 })[0];
assert.ok(dragon && dragon.isFlying, 'a flying ally');
assert.ok(h.b.relocate(dragon, 10, 6));
return { h, dragon, op: h.unit(1) };
}
const still = (e) => { e.base.moveSpeed = 0; e.s.moveSpeed = 0; };
const tough = (e) => { e.base.maxHp = 1e9; e.s.maxHp = 1e9; e.hp = 1e9; };
/** Damage `src` dealt to `t` other than its normal attacks. */
const taken = (h, t, src) => h.hooksOf('damaged').filter((c) => c.target === t && c.source === src && !c.dmg?.isAttack).reduce((s, c) => s + c.amount, 0);
for (const [name, key, prep] of [
['高能源石虫', 'enemy_1021_bslime', null],
['冰爆源石虫', 'enemy_1067_snslime', null],
['卷心籽 (ignited)', 'enemy_10065_ftzlc', (e) => { e.mem.ab.ignited = true; }],
]) {
test(`${name}'s death blast (不可对空) spares 炎佑 and still hits a ground operator`, () => {
const { h, dragon, op } = field();
const e = h.spawn(key, { pos: [10, 6] });
still(e);
if (prep) prep(e);
h.step();
const hp0 = dragon.hp;
h.b.dealDamage(null, e, { amount: e.hp + 10, type: 'true', tags: ['test'] });
h.run(0.3);
assert.equal(e.alive, false);
assert.ok(taken(h, op, e) > 0, 'the ground operator is hit');
assert.equal(taken(h, dragon, e), 0, 'the flyer is not');
assert.equal(dragon.hp, hp0);
if (key === 'enemy_1067_snslime') {
assert.ok(op.findBuff('cold'), '寒冷 on the ground operator');
assert.equal(dragon.findBuff('cold'), null, 'no 寒冷 on the flyer');
}
checkInvariants(h.b);
});
}
test('水遁忍者\'s 漩涡形态 (不可对空) hits the ground operator beside it, never 炎佑', () => {
const { h, dragon, op } = field(6);
const e = h.spawn('enemy_10116_ymgtop', { pos: [10, 6] });
still(e);
tough(e);
assert.ok(h.runUntil(() => taken(h, op, e) > 0, 40), 'the vortex reached the operator');
h.run(3);
assert.equal(taken(h, dragon, e), 0);
checkInvariants(h.b);
});
test('“萨科塔昂首”\'s 祈祷邀约 (不可对空) slows the ground operator, not 炎佑', () => {
const { h, dragon, op } = field(7);
const e = h.spawn('enemy_10085_hllevi_2', { pos: [10, 8] });
still(e);
tough(e);
assert.ok(h.runUntil(() => !!op.findBuff('ab:roar'), 40), 'the ground operator is invited');
assert.equal(dragon.findBuff('ab:roar'), null);
checkInvariants(h.b);
});
for (const [name, key] of [['鼎沸', 'enemy_10054_cjhot'], ['“斩胄之剑”', 'enemy_9014_acstma'], ['“破胄之锤”', 'enemy_9015_acstmb']]) {
test(`${name} (不可对空) hits the ground operator beside it, never 炎佑`, () => {
const { h, dragon, op } = field(8);
const e = h.spawn(key, { pos: [10, 6] });
still(e);
tough(e);
h.run(12);
const dealt = (t) => h.hooksOf('damaged').filter((c) => c.target === t && c.source === e).reduce((s, c) => s + c.amount, 0);
assert.ok(dealt(op) > 0, 'the ground operator is hit');
assert.equal(dealt(dragon), 0, 'the flyer is not');
checkInvariants(h.b);
});
}
+5 -2
View File
@@ -1000,7 +1000,7 @@ test('凛御银灰 S3 变革已至: skill range, line attacks bird_atk_scale ×
// =================================================================================================================
// 引星棘刺
test('引星棘刺 S1 度算浪波: an alchemy unit on the lowest-HP ally: DEF +def and hp ratio × ATK/s on the 3×3 around it (+3 s 心相)', () => {
test('引星棘刺 S1 度算浪波: an alchemy unit on the lowest-HP ally: DEF +def and 生命回复速度 hp ratio × ATK/s on the 3×3 around it (+3 s 心相)', () => {
for (const id of pair('15')) {
const h = run({
defs: { chess: { t_low: ally('t_low', { stats: { def: 100 } }), t_near: ally('t_near', { stats: { def: 100 } }), t_far: ally('t_far', { stats: { def: 100 } }) }, enemies: { enemy_dummy: dummy('enemy_dummy') } },
@@ -1019,7 +1019,10 @@ test('引星棘刺 S1 度算浪波: an alchemy unit on the lowest-HP ally: DEF +
h.run(1.1);
for (const a of [low, near]) assert.equal(a.s.def, 100 + bb.def, a.defId);
assert.equal(far.s.def, 100);
approx(heals(h, u, (c) => c.target === near)[0].amount, u.s.atk * bb.hp_recovery_per_sec_ratio);
// 生命回复速度 (an hpRegen buff per unit, like 锡人 S2), not a heal of hers
for (const a of [low, near]) approx(a.buffs.find((b) => b.key === z.key)?.mods.hpRegen, u.s.atk * bb.hp_recovery_per_sec_ratio, a.defId);
assert.equal(far.buffs.some((b) => b.key === z.key), false);
assert.equal(heals(h, u, () => true).length, 0);
mute(h, u);
h.run(z.dur);
assert.ok(!u.mem.zones.includes(z), 'expired');
+2 -2
View File
@@ -711,7 +711,7 @@ test('凛御银灰 S2 auto-cast: SKILL_RANGE — an enemy inside the skill range
});
// ------------------------------------------------------------------------------------------------------------------
test('引星棘刺 S2: alchemy unit (+3 s with an op in range) — 120 % ATK arts/s, healing ×0.5 on ground enemies, heals allies; T1 ATK +10 %; T2 ASPD ±10 on long roads', () => {
test('引星棘刺 S2: alchemy unit (+3 s with an op in range) — 120 % ATK arts/s, healing ×0.5 on ground enemies, allies recover (生命回复速度); T1 ATK +10 %; T2 ASPD ±10 on long roads', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() }, chess: { t_near: ally('t_near') } },
units: [{ chessId: 'chess_char_5_15_a', row: 10, col: 3 }, { chessId: 't_near', row: 11, col: 5 }],
@@ -734,7 +734,7 @@ test('引星棘刺 S2: alchemy unit (+3 s with an op in range) — 120 % ATK art
const ticks = tagged(h, 'alchemy', e);
assert.ok(ticks.length >= 1);
approx(ticks[0].amount, u.s.atk * bb.atk_scale, 1e-6);
assert.ok(near.hp > 5000, 'allies inside heal');
assert.ok(near.hp > 5000, 'allies inside recover');
h.run(bb.projectile_delay_time + t0.projectile_extend);
assert.equal(u.mem.zones.length, 0, 'expired');
clean(h);
+56 -19
View File
@@ -101,12 +101,23 @@ test('望 in every 自选 form: her kit, stats + module attributes, 3-3, blocks
assert.equal(validateDiyPicks({ [SLOT[6]]: { charId: WANG, skillIndex: 2, uniEquipId: X } }, { data, kitted: KITTED_CHARS }).ok, true);
});
test('铸子: a lone 棋子 never sets off; two side by side activate each other — an enemy (air too) on one sets it off and it is used up; it stays active once its partner is gone', () => {
{ // lone
const { h, st } = field({ tier: 6, elite: true, skill: 1, stones: [[9, 6]] });
test('铸子: the battle-start deployment of a placed 棋子 is its 手动部署 — one 跟子 next to it, so a lone 棋子 is live; with no free neighbour it never sets off; two side by side activate each other — an enemy (air too) on one sets it off and it is used up; it stays active once its partner is gone', () => {
{ // lone: its 跟子 上 of it (its neighbours are road, 下 is off the field) — they connect
const { h, u, st } = field({ tier: 6, elite: true, skill: 1, stones: [[9, 6]] });
assert.deepEqual(placed(h), [[10, 6]], 'the battle start drops one 跟子, 上 first among equal tiles');
assert.equal(u.mem.wang.stock, 6, 'the 棋子 takes one of her 7; the 跟子 none');
h.spawn('enemy_dummy', { pos: [9, 6] });
h.step();
assert.ok(!st[0].alive && stoneHits(h).length > 0, 'a lone 棋子 with its 跟子 sets off');
done(h);
}
{ // no free neighbour (operators on 上 / 右 / 左, 下 off the field): no 跟子, never sets off
const others = [[10, 6], [9, 7], [9, 5]].map(([row, col], i) => ({ uid: 10 + i, chessId: 'chess_char_1_01_a', row, col }));
const { h, st } = field({ tier: 6, elite: true, skill: 1, stones: [[9, 6]], others });
assert.equal(placed(h).length, 0, 'nowhere to put it');
h.spawn('enemy_dummy', { pos: [9, 6] });
h.run(3);
assert.equal(stoneHits(h).length, 0, 'a lone 棋子');
assert.equal(stoneHits(h).length, 0, 'a 棋子 with no partner');
assert.ok(st[0].alive);
done(h);
}
@@ -115,7 +126,7 @@ test('铸子: a lone 棋子 never sets off; two side by side activate each other
h.step();
const a = at(st, 9, 6), b = at(st, 9, 7);
assert.ok(a.mem.wangNode.act && b.mem.wangNode.act, 'connected ⇒ active');
assert.equal(placed(h).length, 0, 'the battle-start deployment drops no 跟子');
assert.deepEqual(placed(h), [[10, 6], [10, 7]], 'the battle start drops one 跟子 上 of each');
const fl = h.spawn('enemy_fly', { pos: [9, 7] });
h.step();
assert.ok(!b.alive && a.alive, 'the flyer set off its 棋子, which is used up');
@@ -142,11 +153,10 @@ test('铸子: a spent 棋子 comes back on its tile 2 s later for its cost (TRP-
const dp0 = h.b.players[0].dp;
assert.ok(h.runUntil(() => b.alive, 1), 'back');
approx(h.b.players[0].dp, dp0 - (mod === X ? 2 : 3), `${mod ?? 'none'}: its cost`);
// the 跟子: 上 of (10,7) is (11,7) — deployable ground; no enemy anywhere ⇒ undeployable first: (10,7)'s neighbours on
// the flat stage are all deployable road but 右 (10,8) … the order 上 > 右 > 下 > 左 among equals
const f = u.mem.wang.followers;
assert.equal(f.length, 1, 'one 跟子');
assert.deepEqual([f[0].r, f[0].c], [11, 7], '上 first among equal tiles');
// the battle start dropped one 跟子 上 of each (equal road tiles: 上 > 右 > 下 > 左); the return of (10,7) finds its 上
// (11,7) taken by its own and (10,6) by the other 棋子, so 右 (10,8)
assert.deepEqual(placed(h), [[11, 6], [11, 7], [10, 8]], `${mod ?? 'none'}: 上 at the battle start, then 右`);
assert.equal(u.mem.wang.followers.length, 3);
done(h);
}
{ // an enemy tile first; undeployable ground before deployable ground
@@ -156,23 +166,36 @@ test('铸子: a spent 棋子 comes back on its tile 2 s later for its cost (TRP-
h.step();
assert.ok(!b.alive);
h.b.kill(h.b.enemies.find((x) => x.alive), null);
// the battle start: (11,9) deploys first (a column top to bottom) — its 右 (11,10) is undeployable floor, before its
// road 上; then (10,9) takes its floor 右 (10,10)
assert.deepEqual(placed(h), [[11, 10], [10, 10]], '不可部署地块 > 可部署地面地块 (before 上)');
const fl = h.spawn('enemy_fly', { pos: [9, 9] }); // 下 of (10,9) holds an enemy (a flyer: no block on the trap rule)
assert.ok(h.runUntil(() => b.alive, 3), 'back (a flyer does not stop it)');
assert.deepEqual(placed(h), [[9, 9]], 'the enemy tile first (before the undeployable 右 (10,10))');
assert.deepEqual(placed(h)[2], [9, 9], 'the return: the enemy tile first');
h.step();
assert.ok(h.hooksOf('damaged').some((c) => c.target === fl && c.source === u && c.dmg.tags.includes('wang:stone')), 'the 跟子 sets off at once — its damage is 望\'s');
assert.equal(u.mem.wang.followers.length, 0);
assert.equal(u.mem.wang.followers.length, 2, 'the two on the floor stay; the one on the flyer is spent');
done(h);
}
{ // no enemy around: undeployable ground (10,10) before the road
{ // a return with no enemy around: undeployable ground (10,10) before the road
const { h, u, st } = field({ tier: 6, elite: true, skill: 1, stones: [[10, 9], [11, 9]], dp: 50 });
const b = at(st, 10, 9);
const e = h.spawn('enemy_dummy', { pos: [10, 9] });
h.step();
h.b.kill(e, null);
u.mem.wang.followers.length = 0; // (the battle start's 跟子 gone: the floor beside it is free again)
const n0 = placed(h).length;
assert.ok(h.runUntil(() => b.alive, 3));
assert.deepEqual(placed(h), [[10, 10]], '不可部署地块 > 可部署地面地块');
void u;
assert.deepEqual(placed(h).slice(n0), [[10, 10]], '不可部署地块 > 可部署地面地块');
done(h);
}
{ // a 跟子 on the tile of a piece still to come at the battle start goes when that piece deploys there ("跟子所在格进行部署…
// 时,该跟子会随之消失"): (10,3)'s 上 holds an operator, so its 跟子 takes 右 (10,4) — the next 棋子's tile —, which
// deploys there and drops its own 上 (11,4)
const { h, u } = field({ tier: 6, elite: true, skill: 1, stones: [[10, 3], [10, 4]], others: [{ uid: 10, chessId: 'chess_char_1_01_a', row: 11, col: 3 }] });
assert.deepEqual(placed(h), [[10, 4], [11, 4]]);
assert.deepEqual(u.mem.wang.followers.map((f) => [f.r, f.c]), [[11, 4]], 'the one on (10,4) is gone');
assert.ok(h.eventsOf('fx').some((e) => e[1] === 'disappear' && e[2] === 4 && e[3] === 10), 'it vanishes');
done(h);
}
{ // 望 away ⇒ her 棋子 leave and wait for her; her 跟子 are gone
@@ -306,13 +329,14 @@ test('S2 连星 (AUTO, SP_FULL): a triggered piece hits every enemy on its conne
const { h, u } = field({ tier, elite, skill: 1, stones: [[10, 6], [10, 7]] });
const onLine = h.spawn('enemy_fly', { pos: [10, 9] }); // 3 tiles right of (10,6)…(10,7)+2: on the horizontal line
const beyond = h.spawn('enemy_dummy', { pos: [10, 11] }); // 4 tiles past (10,7)
const off = h.spawn('enemy_dummy', { pos: [11, 7] }); // off the line (vertical never activated)
const vertical = h.spawn('enemy_dummy', { pos: [12, 7] }); // its battle-start 跟子 (11,7) is 上 of it: the vertical line is on
const off = h.spawn('enemy_dummy', { pos: [9, 4] }); // on neither line
const trig = h.spawn('enemy_runner', { pos: [10, 7] });
h.b.applyStatus(trig, 'stun', { duration: 99, force: true }); // it stays on the tile (the slow is read off its speed stat)
h.step();
const hit = stoneHits(h);
const victims = new Set(hit.map((c) => c.target));
assert.ok(victims.has(trig) && victims.has(onLine), `T${tier}: the line, air included`);
assert.ok(victims.has(trig) && victims.has(onLine) && victims.has(vertical), `T${tier}: the lines, air included`);
assert.ok(!victims.has(beyond) && !victims.has(off), `T${tier}: not beyond ± 3 / off the line`);
for (const c of hit.filter((x) => x.target === trig)) approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'] * 1.24, `T${tier}: 2 stacks`, 1e-4);
const slow = trig.findBuff('wang:slow');
@@ -359,8 +383,9 @@ test('S3 天下劫 (MANUAL, ACTIVE_RANGE 4-12, 20 bullets): passive — the piec
assert.equal(u.skill.attackOverride()?.noAttack, true, `T${tier}: 停止攻击`);
assert.equal(u.mem.wang.stock, 8, `T${tier}: +8, capped`);
const fl = placed(h).map(([r, c]) => `${r},${c}`);
assert.equal(fl.length, 5, `T${tier}: (7 − 2 placed) + 8 − 8 over the cap`);
assert.deepEqual(fl.slice(0, 2).sort(), ['10,7', '9,5'], `T${tier}: the enemies' tiles first (${fl})`);
assert.deepEqual(fl.slice(0, 2), ['12,6', '12,7'], `T${tier}: the battle start's 跟子, 上 of each (${fl})`);
assert.equal(fl.length, 7, `T${tier}: then (7 − 2 placed) + 8 − 8 over the cap`);
assert.deepEqual(fl.slice(2, 4).sort(), ['10,7', '9,5'], `T${tier}: the overflow on the enemies' tiles first (${fl})`);
assert.ok(h.hooksOf('damaged').some((c) => c.target === a && c.source === u && c.dmg.tags.includes('wang:stone')), `T${tier}: they set off`);
// inside her range a 棋子 comes back onto an enemy ("可部署至敌人所在位置") — and sets off again
h.b.kill(a, null); h.b.kill(b, null);
@@ -390,6 +415,18 @@ test('S3 天下劫 (MANUAL, ACTIVE_RANGE 4-12, 20 bullets): passive — the piec
}
});
test('S3 天下劫: the overflow 跟子 take her range by the 铸子 order (S3 备注 "采用相同的位置选择优先顺序") — enemy tiles first, an enemy on undeployable floor before one on the road her target order puts first, then the floor', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 2, row: 10, col: 6, stones: [[11, 4], [11, 5]], dp: 300 });
assert.deepEqual(placed(h), [[12, 4], [12, 5]], 'the battle start');
u.skill.gainSp(999);
h.spawn('enemy_dummy', { pos: [10, 10] }); // on tile_floor
h.spawn('enemy_dummy', { pos: [10, 8] }); // on the road, nearer the goal
assert.ok(h.runUntil(() => u.skill.active, 2));
const fl = placed(h).slice(2).map(([r, c]) => `${r},${c}`);
assert.deepEqual(fl, ['10,10', '10,8', '9,10', '11,10', '12,10'], `(7 − 2) + 8 − 8 = 5: the enemies' tiles by class, then the undeployable tiles (${fl})`);
done(h);
});
test('the hand: deploying her in prep brings 7 棋子 (TRP-X "可同时部署的陷阱数量提升": 8) — the match\'s data view reads the module variant\'s deploy limit', () => {
const gd = new GameData(getData({ log: { warn() {}, error() {}, info() {} } }), 'mode_multi_hard');
const data = { chess: CHESS, backups: BACKUPS };