feedback5: merge CB2 — community reports (healers / summons): 刺玫's S2 heals the most injured ally again (the counter's heal alone goes to her protégé), 琳琅诗怀雅 heals herself and her 破财消灾 cost restarts at 5 DP with every deployment, 赫默's S2 casts at full SP so the drone takes the field at once, the countdown summons (医疗探机, 诅咒娃娃, 海嗣 and four 自选 ones) take no damage, are never healed and show the life left on their bar, stacked ground areas stay readable; §25.16 after CB5's §25.19 (renumbered with the later merges); golden: vs feedback5 CB2's own rules — roster-001/002/003/005/008/016/018/027/035/037/045/046/049, bond-deputShip-low/high, bond-suntShip-high, bond-victoriaShip-low/high, bond-visiShip-low/high, boss-boss_1/3/5/7-pair, hidden-boss_9-pair, unite-1, matches solo-FUNNY-2, coop2-HARD-4, coop3-ABYSS-5, coop4-ABYSS-7, coop2-NORMAL-8-serverrun, coop2-ABYSS-10-boosted, coop2-NORMAL-14-standins; vs CB2 exactly CB5's 18 bot matches
Merge fixes (the three tests the trial merge failed): - test/content/summon_loadout_conflicts.test.js (赫默's drone lifetime per module variant): it asserted "no cast yet" after the drone's redeploy time and then cast S2 by hand — with nobody hurt the old DEFAULT rule never cast. S2 now casts by itself the tick its SP is full (22 s), which is the official rule (client skill skchr_silent_2: no `_trigger`, `_allowNoTarget` 1, `_checkHasTargetBeforeDoCast` 0; CB264f8bf9), so the test waits for that cast and checks both drones' lifetimes from it. Failed on fb5-cb2 itself (the stream did not run the file). - test/sim/feedback1f-downed-tile.test.js (F5 rule 3): its "killed" case killed 浊心斯卡蒂's 海嗣 with 1e9 true damage; the 海嗣 is a countdown summon now — 无敌 + 禁疗, the official state (PRTS 海嗣 备注 「持有禁疗、无敌」) — so it only expires. The killed case takes 缪尔赛思's 流形 (a placed board summon that is attacked and killed and also comes back on its tile 25 s later); both cases now also check the summon's return. Failed on fb5-cb2 itself. - test/golden-standins.test.js (the 补位 match fields at least 3 different stand-ins): already failing on feedback537558f0— CB5's 调度中心 upgrade card changed the human seat's shop in coop2-NORMAL-14-standins, which then fielded only 2 of its 7 not-owned chess (忍冬 → Sharp, 安洁莉娜 → Raidian). tools/golden.mjs MATCH_NOT_OWNED adds 缄默德克萨斯 and 铃兰 (chess its AI fields now): 4 stand-ins (Sharp from round 4, Misery from 7, 预备干员-辅助 from 9, Raidian in 12); the test is unchanged, test/golden/README.md says 9 chess. This moves coop2-NORMAL-14-standins only. - docs/history/0.2.0.md: the §25 intro's two stream lines joined into one sentence (the merge had cut it). Golden moves outside CB2's commit list are CB2's rules on the paths CB5 gave the bots: coop2-HARD-4 fields 琳琅诗怀雅 from round 6, coop2-NORMAL-8-serverrun 刺玫 from round 7, coop2-ABYSS-10-boosted 刺玫 from round 2, coop2-NORMAL-14-standins 刺玫 from round 3 and 琳琅诗怀雅 in rounds 6-7 (instrumented); coop2-NORMAL-3 and coop4-FUNNY-6 (in CB2's list) still field 刺玫 and her S2 heals now reach other allies (36 / 205 heals), but no kill, leak, LP or end stat changes, so their digests stay. Gate: non-browser suites 4949 / 4949, npm run golden ok (283 scenarios), test/golden.test.js 9 / 9, lint 0 errors, check:imports ok, typecheck ok.
This commit is contained in:
commit
47079456be
34 files changed
+964
-368
No files matched your search
@@ -0,0 +1,52 @@
|
||||
// test/content/feedback5-silent-drone.test.js — 赫默 S2 医疗无人机 casts as soon as its SP is full (community report of
|
||||
// 2026-10-06 「赫默的无人机在2技能好了之后应该秒放」): the AUTO skill has no cast condition — client skill skchr_silent_2: no
|
||||
// `_trigger`, `_allowNoTarget` 1, `_checkHasTargetBeforeDoCast` 0, a RechargeToken action —, so the placed 医疗探机 takes the
|
||||
// field the moment S2 is ready, injured ally or not. While the stock is full her SP stops (阻回) until it is spent
|
||||
// (`_stopSpWhenTokenIsFull` 1; PRTS 赫默 S2 备注). Until 0.2.0 the data's DEFAULT rule (+ `heal`) waited for an injured ally.
|
||||
// Run: node --test test/content/feedback5-silent-drone.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
const SILENT = 'chess_char_2_02_a', DRONE = 'token_10000_silent_healrb';
|
||||
|
||||
function field() {
|
||||
const h = makeBattle({
|
||||
units: [{ chessId: SILENT, row: 10, col: 3 }, { kind: 'token', tokenId: DRONE, row: 11, col: 3, ownerUid: 1 }, { chessId: 'chess_char_1_02_a', row: 10, col: 4 }],
|
||||
autoFinish: false, timeLimit: 120, seed: 3,
|
||||
});
|
||||
const drones = [], casts = [];
|
||||
h.b.on('deploy', (c) => { if (c.unit.defId === DRONE) drones.push(h.b.time); });
|
||||
h.b.on('skillStart', (c) => { if (c.unit.defId === SILENT) casts.push(h.b.time); });
|
||||
return { h, s: h.unit(SILENT), drones, casts };
|
||||
}
|
||||
|
||||
test('S2 casts the tick its SP is full with nobody injured, and the drone takes the field at once', () => {
|
||||
const { h, s, drones, casts } = field();
|
||||
h.run(0.1);
|
||||
assert.deepEqual(drones.length, 1, 'the placed drone deploys with the board (SKILL_SUMMON_START_DEPLOY)');
|
||||
// (the cast comes in the very tick the SP fills: from 0 at the deployment, spCost / spRecovery seconds later)
|
||||
const fullAt = s.skill.spCost / s.s.spRecovery;
|
||||
h.run(40);
|
||||
assert.ok(h.b.allyUnits.every((a) => !a.alive || a.hp >= a.s.maxHp), 'nobody was injured');
|
||||
assert.ok(casts.length >= 1, 'it casts with no injured ally anywhere');
|
||||
assert.ok(Math.abs(casts[0] - fullAt) < 0.1, `at full SP (${fullAt.toFixed(2)} s; cast ${casts[0].toFixed(2)} s)`);
|
||||
assert.ok(drones.some((t) => Math.abs(t - casts[0]) < 0.1), 'the drone deploys at the cast');
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0);
|
||||
});
|
||||
|
||||
test('while the stock is full (the previous drone still up) her SP stops; it runs again once the drone deployed', () => {
|
||||
const { h, s, drones, casts } = field();
|
||||
h.run(5);
|
||||
s.skill.sp = s.skill.spCost;
|
||||
h.step(2);
|
||||
assert.equal(casts.length, 1, 'cast at once');
|
||||
assert.equal(s.mem.summonStock[DRONE], 1, 'the drone is still up (10 s): one in stock');
|
||||
h.run(6);
|
||||
assert.equal(s.skill.sp, 0, '阻回: no SP while the stock is full');
|
||||
assert.ok(h.runUntil(() => drones.length >= 2, 10), 'the stocked drone deploys once the first is gone and ready again');
|
||||
h.run(3);
|
||||
assert.ok(s.skill.sp > 2, `SP runs again (${s.skill.sp.toFixed(2)})`);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// test/content/feedback5-swire2-revive-cost.test.js — 琳琅诗怀雅's 破财消灾 (「受到致命伤害时,若费用足够则消耗5点部署费用使生命恢复到
|
||||
// 70%,每次触发该天赋时消耗的费用翻倍」): the doubled cost restarts at 5 with every deployment — PRTS 备注 「再部署时重置本天赋费用
|
||||
// 消耗」 (community report of 2026-10-06 「进入联防阶段琳琅诗怀雅所需的复活费用应该重置,正常对局中死亡之后再部署复活费用也应该
|
||||
// 重置」). A 联防 field is a battle of its own, so it always started at 5; a redeploy used to keep the doubled cost.
|
||||
// Run: node --test test/content/feedback5-swire2-revive-cost.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
const ID = 'chess_char_3_04_a';
|
||||
|
||||
function field(kind = 'normal') {
|
||||
const h = makeBattle({ kind, units: [{ chessId: ID, row: 10, col: 5 }], timeLimit: 300, autoFinish: false, flags: { dpInit: 60, dpMax: 99 } });
|
||||
h.step();
|
||||
return { h, u: h.unit(ID), ps: h.b.getPlayer('p1') };
|
||||
}
|
||||
|
||||
/** A lethal hit with `dp` DP in the pool: returns the DP the talent took (0 when she fell). */
|
||||
function lethal(h, u, ps, dp = 90) {
|
||||
ps.dp = dp;
|
||||
h.b.dealDamage(null, u, { amount: u.s.maxHp * 5, type: 'true', canDodge: false });
|
||||
return u.alive ? dp - ps.dp : 0;
|
||||
}
|
||||
|
||||
test('破财消灾 doubles within one deployment and restarts at 5 DP after a redeploy', () => {
|
||||
const { h, u, ps } = field();
|
||||
assert.equal(lethal(h, u, ps), 5, 'first save: 5 DP');
|
||||
assert.ok(u.alive && Math.abs(u.hp - u.s.maxHp * 0.7) < 1e-6, 'back to 70 % HP');
|
||||
assert.equal(lethal(h, u, ps), 10, 'second save of the same deployment: doubled');
|
||||
h.b.retreat(u);
|
||||
assert.ok(!u.alive);
|
||||
ps.dp = 90;
|
||||
assert.ok(h.runUntil(() => u.alive && u.deployed, 120), 'redeployed');
|
||||
assert.equal(lethal(h, u, ps), 5, 'after the redeploy: 5 DP again (it used to cost 20)');
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0);
|
||||
});
|
||||
|
||||
test('a 联防 field starts at 5 DP: a battle of its own', () => {
|
||||
const { h, u, ps } = field('unite');
|
||||
assert.equal(lethal(h, u, ps), 5);
|
||||
assert.equal(lethal(h, u, ps), 10);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// test/content/feedback5-swire2-self-heal.test.js — 琳琅诗怀雅 S1 仗义疏财 heals herself too (community report of 2026-10-06
|
||||
// 「干员琳琅诗怀雅不会治疗自己」): 「下一次攻击会为周围八格内血量不足70%的一名友方单位恢复…」 — the client charpack
|
||||
// (char_1033_swire2, mode S1, ability HealAlly) selects with range x-4 (the 3×3 with her own tile), ally side,
|
||||
// `_excludeOwner` 0, max HP ratio 0.7, one target. kits/ops/chess_char_3_04-swire2.js used to skip her own tile.
|
||||
// Run: node --test test/content/feedback5-swire2-self-heal.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
|
||||
const ds = getDefaultSource();
|
||||
const ID = 'chess_char_3_04_a', S1 = 'skchr_swire2_1';
|
||||
const S1_INDEX = ds.rawChess(ID).skills.find((s) => s.skillId === S1).index;
|
||||
const NEIGHBOUR = 'chess_char_3_16_a';
|
||||
|
||||
/** 琳琅诗怀雅 (S1) on (9, 5), an ally on (10, 5); HP ratios set after the deployment; coins to spare. */
|
||||
function field(herRatio, allyRatio) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_d: enemyRec({ key: 'enemy_d', hp: 1e7, speed: 0 }) } },
|
||||
units: [{ chessId: ID, row: 9, col: 5, skillIndex: S1_INDEX }, { chessId: NEIGHBOUR, row: 10, col: 5 }],
|
||||
timeLimit: 60, autoFinish: false, hooks: ['heal'], captureNoisy: true, flags: { dpInit: 60, dpMax: 99 },
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit(ID), ally = h.unit(NEIGHBOUR);
|
||||
u.mem.coins = 2;
|
||||
u.hp = u.s.maxHp * herRatio;
|
||||
ally.hp = ally.s.maxHp * allyRatio;
|
||||
h.run(1);
|
||||
return { h, u, ally, heals: h.hooksOf('heal').filter((c) => c.source === u) };
|
||||
}
|
||||
|
||||
test('alone below 70 %: a coin heals herself (her own tile is in the x-4 selector)', () => {
|
||||
const { h, u, heals } = field(0.5, 1);
|
||||
assert.equal(heals.length, 1, 'one heal');
|
||||
assert.equal(heals[0].target, u, 'on herself');
|
||||
assert.equal(u.mem.coins, 1, 'one coin spent');
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0);
|
||||
});
|
||||
|
||||
test('the lowest HP ratio of the 3×3 wins: herself below the neighbour, the neighbour below her; 70 % or more is never healed', () => {
|
||||
assert.equal(field(0.4, 0.6).heals[0].target.defId, ID, 'she is lower: herself');
|
||||
assert.equal(field(0.6, 0.4).heals[0].target.defId, NEIGHBOUR, 'the neighbour is lower: the neighbour');
|
||||
const none = field(0.75, 1);
|
||||
assert.equal(none.heals.length, 0, 'not below 70 %: no heal, the coin is kept');
|
||||
assert.equal(none.u.mem.coins, 2);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// test/content/feedback5-vendla-heal.test.js — 刺玫 S2 荆藤庇荫 and her 咒愈师 trait (community report of 2026-10-06
|
||||
// 「干员刺玫开技能(装备源石药剂)之后不能治疗」): the protégé takes only the trait heal of the S2 counter ("该角色受到攻击时刺玫
|
||||
// 对目标造成攻击力20%的法术伤害并仅对该角色触发刺玫特性"; en "…and activates her Trait on the ally"; client buff vendla_tr: the
|
||||
// S2TraitHealRange selector only for a damage whose modifier carries vendla_s_2). Her own attacks keep healing the lowest-HP
|
||||
// ally in range — herself too (TraitHealRange: _excludeOwner 0). Until 0.2.0 every damage she dealt during the skill healed
|
||||
// the protégé, so with 源石溶剂 draining her she healed nobody while it ran.
|
||||
// Run: node --test test/content/feedback5-vendla-heal.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
const VENDLA = 'chess_char_1_06_a', SOLVENT = 'chess_item_1_05_e_a';
|
||||
const tank = chessRec({ id: 't_tank', stats: { maxHp: 9000, atk: 0, def: 0 }, skill: null });
|
||||
const still = (r, c) => ({ motion: 'WALK', start: [r, c], end: [r, c], checkpoints: [] });
|
||||
|
||||
/** 刺玫 (+ 源石溶剂) on (10, 4), a high-max-HP ally on (11, 4) — her protégé —, one dummy enemy on (10, 6) hitting `hits`. */
|
||||
function field({ enemyAtk = 0, hits = null } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_tank: tank }, enemies: { e_dummy: enemyRec({ key: 'e_dummy', hp: 1e8, atk: enemyAtk, speed: 0, range: enemyAtk ? 5 : 0, bat: 1 }) } },
|
||||
units: [{ chessId: VENDLA, row: 10, col: 4, items: [SOLVENT] }, { chessId: 't_tank', row: 11, col: 4 }],
|
||||
enemies: [{ key: 'e_dummy', time: 0, pos: [10, 6], route: still(10, 6) }],
|
||||
autoFinish: false, timeLimit: 60, seed: 3, captureNoisy: true, hooks: ['heal', 'damaged', 'skillStart'],
|
||||
setup: hits ? (b) => b.on('beforeAttack', (ctx) => { if (ctx.attacker.side === 'enemy') ctx.targets = [b.allyUnits.find((u) => u.defId === hits)]; }, { priority: 50 }) : null,
|
||||
});
|
||||
const v = h.unit(VENDLA), tk = h.unit('t_tank');
|
||||
h.run(8); // the 源石溶剂 drain has her below full HP
|
||||
const sk = v.skill;
|
||||
sk.sp = sk.spCost; sk.charges = Math.max(1, sk.charges);
|
||||
assert.ok(sk.activate('manual'), 'S2 starts');
|
||||
assert.equal(v.mem.protege, tk, 'the protégé is the highest-max-HP ally in range');
|
||||
return { h, v, tk, t0: h.b.time };
|
||||
}
|
||||
|
||||
test('during 荆藤庇荫 her attacks heal the most injured ally in range (herself, drained by 源石溶剂), not the full-HP protégé', () => {
|
||||
const { h, v, tk, t0 } = field();
|
||||
h.run(10);
|
||||
assert.ok(v.skill.active, 'still running');
|
||||
const heals = h.hooksOf('heal').filter((c) => c.source === v && c.t >= t0 && c.amount > 0);
|
||||
assert.ok(heals.length >= 4, `she keeps healing: ${heals.length} heals in 10 s`);
|
||||
assert.ok(heals.every((c) => c.target === v), 'every attack heal goes to her — the only injured ally — never to the protégé');
|
||||
assert.equal(tk.hp, tk.s.maxHp);
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0);
|
||||
});
|
||||
|
||||
test('the S2 counter (a hit on the protégé) heals the protégé; her attacks still heal the lowest-HP ally', () => {
|
||||
const { h, v, tk, t0 } = field({ enemyAtk: 300, hits: 't_tank' });
|
||||
h.run(8);
|
||||
const counters = h.hooksOf('damaged').filter((c) => c.source === v && c.t >= t0 && (c.dmg?.tags || []).includes('counter'));
|
||||
assert.ok(counters.length >= 3, 'the enemy hitting the protégé draws counters');
|
||||
const heals = h.hooksOf('heal').filter((c) => c.source === v && c.t >= t0);
|
||||
const toTank = heals.filter((c) => c.target === tk), toHer = heals.filter((c) => c.target === v);
|
||||
assert.ok(toTank.length >= counters.length, `each counter heals the protégé (${toTank.length} heals for ${counters.length} counters)`);
|
||||
assert.ok(toHer.length > 0, 'her attacks heal the most injured ally — herself when she is lower than the protégé');
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0);
|
||||
});
|
||||
@@ -219,7 +219,9 @@ test('1_06 刺玫: 荆藤庇荫 taunts the highest-HP ally in range and counters
|
||||
const counters = dealt(h, u, tagged('counter'));
|
||||
assert.ok(counters.length >= 2);
|
||||
for (const c of counters) approx(c.amount, u.s.atk * bb.atk_scale, 'counter = 20 % ATK arts');
|
||||
assert.ok(heals(h, u).every((c) => c.target === yak), 'trait heal only on the protected ally during the skill');
|
||||
// the counters' trait heal goes to the protected ally; her attacks heal the most injured ally in range — the protected
|
||||
// one here, the only ally hit (test/content/feedback5-vendla-heal.test.js: herself when she is the most injured)
|
||||
assert.ok(heals(h, u).every((c) => c.target === yak), 'trait heals on the protected ally (counters; the most injured)');
|
||||
// talent: the highest max-HP ally in range receives more healing
|
||||
yak.hp = yak.s.maxHp * 0.3;
|
||||
approx(h.b.heal(null, yak, 100), 100 * t.heal_scale, 'heal ×1.08');
|
||||
|
||||
@@ -86,9 +86,13 @@ test('赫默: the medical drone lives its owner\'s withdraw duration (module var
|
||||
const start = ops.map((u) => summons(h, u, DRONE)[0]);
|
||||
assert.ok(start.every((d) => d && d.alive), 'both start deploys');
|
||||
assertLifetimes(h, start, lo.map(life));
|
||||
h.run(6); // past the token's redeploy time
|
||||
assert.ok(ops.every((u) => u.skill.activations === 0), 'no cast yet');
|
||||
const drones = castAndCollect(h, ops, DRONE);
|
||||
// S2 casts by itself the tick its SP is full (22 s), injured ally or not — client skill skchr_silent_2: no `_trigger`,
|
||||
// `_allowNoTarget` 1 (community report of 2026-10-06, DESIGN §25.16; until then it waited for an injured ally and this
|
||||
// test cast it by hand): past the token's redeploy time, so each drone comes back with its owner's cast
|
||||
assert.ok(ops.every((u) => u.skill.activations === 0), 'no cast before the SP is full');
|
||||
assert.ok(h.runUntil(() => ops.every((u) => u.skill.activations === 1), 10), 'both cast at full SP');
|
||||
const drones = ops.map((u) => summons(h, u, DRONE)[0]);
|
||||
assert.ok(drones.every((d) => d.alive), 'both drones back on the field');
|
||||
assertLifetimes(h, drones, lo.map(life));
|
||||
done(h);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user