feedback7: merge fb7-gh-sim — PR #348 (take-the-highest chains), PR #293 (REA-Y in the engine), #329 ports (异客 13 tiles, 歌蕾蒂娅 net radius 1, 搅拌机 latest first, 大帝 uncapped), PR #365 (骨刺 forms + PRTS targeting), #298 (instant non-attack skills recast at most once per attack interval, attacks untouched), #364 (先兆者 shell 3×3), #220 (速射手 air first), 余 skips noStack bonds (owner)

- Conflict: docs/PLAYING.md 技能自动释放 bullet — both insertions kept (the 铁钳号 rule from fb7-owner-sim, the #298
  pacing sentence).
- Grok review: MERGE AFTER FIXES — the HIGH #298 finding (the cast replaced the due attack) fixed on the branch
  (db29acf9: per-skill nextCastAt; golden of #298 alone shrank from 84 scenarios to 2).
- Golden union: 4 matches differ from HEAD though the branch alone left them (coop2-NORMAL-3, coop4-ABYSS-7,
  coop2-NORMAL-8-serverrun, solo-HARD-9-boosted): HEAD's side had moved all four (#333's shop RNG …) and on those new
  bot paths the merged rules apply — the diffs are in-battle layer gains / damage (卡西米尔 50 → 45, 精准 80 → 76,
  叙拉古 176 → 168, kills ±2, solo-HARD-9 LP 397 → 399), no outcome change.
- Broad node --test 5571 (5570 pass, 0 fail, 1 skipped); lint 0 errors; typecheck.
This commit is contained in:
sganggs committed 2026-10-08 21:27:04 +08:00
commit 52f56319ac
41 files changed
+2065 -648

No files matched your search

+28
View File
@@ -302,6 +302,22 @@ test('余 文火慢炖: R8 round start — exactly 1 active bond +36 layers, oth
cover('band_yu');
});
test('余 文火慢炖 adds no layers to a bond without layers (noStack: 独行, 绝技 …) — the owner\'s decision of 2026-10-08', () => {
assert.deepEqual(Object.values(DATA.bonds).filter((b) => b.noStack).map((b) => b.name).sort(), ['协防干员', '独行', '绝技', '调和'].sort());
for (const [bonus, want] of [
[{ soloShip: 1 }, {}], // the one active bond is 独行: nothing at all
[{ suntShip: 5 }, {}], // … or 绝技
[{ soloShip: 1, yanShip: 20 }, { yanShip: 12 }], // two active (the sentence's count): the layered one +12, 独行 none
]) {
const s = setup({ band: 'band_yu' });
Object.assign(s.ps.bondCountBonus, bonus); s.ps.recompute();
for (const id of Object.keys(bonus)) assert.ok(s.ps.bonds[id]?.active, `${id} active`);
s.roundStart(8);
const got = Object.fromEntries(Object.entries(s.ps.layers).filter(([, v]) => v > 0));
assert.deepEqual(got, want, JSON.stringify(bonus));
}
});
test('凯瑟琳 定向投放: every shop upgrade offers 3 different shop items (any tier — community report of 2026-10-06), 1 free pick', () => {
const { m, ps } = setup({ band: 'band_cathy', seed: 9 });
for (const lvl of [2, 3]) {
@@ -587,6 +603,18 @@ test('大帝 加急调派: every deployment halves the operator\'s next redeploy
cover('band_emperor');
});
test('大帝 加急调派 stacks without a cap: the 21st and 22nd deployments still halve the next redeploy (PRTS "※该策略效果可无限叠加"; GitHub #328, PR #329)', () => {
const h = fight({ band: 'band_emperor', units: [{ chessId: 't_op', row: 10, col: 4 }] });
h.step(1);
const u = h.unit('t_op');
for (let n = 1; n <= 22; n++) {
h.b.retreat(u, { reason: 'raid' });
close(u.respawnAt - u.deathAt, 20 / 2 ** n, `after deployment ${n}`);
if (n < 22) assert.ok(h.b._deploy(u), `deployment ${n + 1}`);
}
assert.equal(u.findBuff('band:band_emperor').stacks, 22);
});
test('桑葚 药枚实验: the units on the right-most column get a 25 % chance per attack of 1 shield layer (max 1)', () => {
const ops = { t_op: op('t_op'), t_b: op('t_b') };
for (const [v, want] of [[0.24, 1], [0.26, 0]]) {
+31
View File
@@ -71,6 +71,37 @@ test('迅捷: member skill end +12 SP (p=1 at high L), ≥40 layers every operat
assert.equal(off.s_x, 0);
});
test('迅捷 refills 引星棘刺 S1 at once: it is cast at most once per attack interval, and her attacks go on untouched (GitHub #298)', () => {
// S1 度算浪波: AUTO, no duration, no attack of its own, SP_FULL, cost 6; 迅捷 refunds 12 + 15 SP at ≥ 40 layers
const run = ({ aspd = 0, enemy = true, silence = false } = {}) => {
const h = makeBattle({
seed: 1, bonds: { swiftShip: bond(2, 229, 3) }, autoFinish: false, timeLimit: 30,
units: [{ chessId: 'chess_char_5_15_b', skillIndex: 0, row: 10, col: 4 }],
enemies: enemy ? [{ key: 'enemy_still', route: { motion: 'WALK', start: [10, 6], end: [10, 6], checkpoints: [] } }] : [],
defs: { enemies: { enemy_still: enemyRec({ key: 'enemy_still', hp: 1e9, atk: 0, speed: 0, blockCnt: 0 }) } },
});
h.step();
const u = h.unit('chess_char_5_15_b');
if (aspd) h.b.addBuff(u, { key: 'test:aspd', mods: { aspd } });
if (silence) h.b.addBuff(u, { key: 'test:mute', flags: { silence: true } });
if (enemy) assert.ok(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).length, 'an enemy stays in range');
u.skill.gainSp(u.skill.spCost, 'test');
const n0 = u.skill.activations, a0 = u.stats.attacks;
h.run(6);
checkInvariants(h.b);
return { n: u.skill.activations - n0, attacks: u.stats.attacks - a0, interval: u.s.interval };
};
const base = run(), muted = run({ silence: true });
// the first at once, then one per interval (1.36 s): 5 in 6 s — every tick before (180)
assert.ok(base.n >= 4 && base.n <= 5, `one cast per attack interval (${base.interval.toFixed(2)} s): ${base.n} in 6 s`);
assert.ok(base.attacks > 0 && base.attacks === muted.attacks, `the casts take no attack (${base.attacks} vs ${muted.attacks} silenced)`);
const fast = run({ aspd: 500 }), fastMuted = run({ aspd: 500, silence: true });
assert.ok(fast.n >= 23 && fast.n <= 25, `ASPD +500 (interval ${fast.interval.toFixed(2)} s): ${fast.n} casts`);
assert.equal(fast.attacks, fastMuted.attacks, 'still no attack taken');
const alone = run({ enemy: false });
assert.ok(alone.n >= 4 && alone.n <= 5 && alone.attacks === 0, `no enemy: still fires at full SP (#124), one per interval — ${alone.n}`);
});
test('迅捷 / 突袭: SP gifts after end do not recharge a zero-SP deployment skill or trigger a ready raid', () => {
const h = makeBattle({
defs: { chess: { t_deploy: chessRec({ id: 't_deploy', bonds: ['swiftShip', 'raidShip'], skill: { spCost: 0 } }) } },
+83
View File
@@ -186,6 +186,67 @@ test(`${nm('enemy_9008_acbunn')}: attacks several targets at once while stealthe
assert.ok(atk.targets.length >= 2, `targets ${atk.targets.length}`);
});
// 骨刺 — PRTS 天赋 (级别0) 「自身普通攻击索敌不受阻挡影响 / 不会攻击飞行单位,隐匿(解除阻挡0秒后恢复) / 隐匿期间可同时攻击3个目标」
// and the level description 「隐匿;隐匿状态下同时攻击3个目标。」 (PR #365, the targeting part checked against PRTS).
function boneArena(o = {}) {
const h = arena({
units: ['t_wall', 't_wall2', 't_wall3', 't_wall4'].map((chessId, i) => ({ chessId, row: 9 + (i % 2), col: 6 + Math.floor(i / 2) })),
captureNoisy: true, hooks: ['attack'], ...o,
});
h.step();
for (const u of h.allies()) h.b.addBuff(u, { key: 'test:noBlock', persist: true, flags: { noBlock: true } });
const e = put(h, 'enemy_9008_acbunn', [9, 6]);
return { h, e };
}
/** The targets of `e`'s next attack. */
function boneNext(h, e) {
const n = h.hooksOf('attack').filter((c) => c.attacker === e).length;
assert.ok(h.runUntil(() => h.hooksOf('attack').filter((c) => c.attacker === e).length > n, 8), 'a new attack');
return h.hooksOf('attack').filter((c) => c.attacker === e)[n].targets;
}
test(`${nm('enemy_9008_acbunn')}: 3 targets while hidden, 1 once blocked or revealed — by priority, not its blocker first; 3 again when hidden`, () => {
for (const cause of ['block', 'reveal']) {
const { h, e } = boneArena();
const wall = h.unit('t_wall'), taunter = h.unit('t_wall4');
assert.equal(new Set(boneNext(h, e)).size, 3, `${cause}: hidden — three distinct targets of the four in reach`);
h.b.addBuff(taunter, { key: 'test:taunt', mods: { taunt: 5 } });
if (cause === 'block') { h.b.removeBuff(wall, 'test:noBlock'); h.step(2); assert.equal(e.blockedBy, wall); }
else h.b.addBuff(e, { key: 'test:reveal', persist: true, flags: { reveal: true } });
assert.deepEqual(boneNext(h, e), [taunter], `${cause}: one target, the taunt-5 one (索敌不受阻挡影响)`);
if (cause === 'block') { h.b.addBuff(wall, { key: 'test:noBlock', persist: true, flags: { noBlock: true } }); h.b.releaseBlocked(wall); }
else h.b.removeBuff(e, 'test:reveal');
assert.equal(boneNext(h, e).length, 3, `${cause}: hidden again (0 s after the block) — three`);
checkInvariants(h.b);
}
});
test(`${nm('enemy_9008_acbunn')}: never targets a flying unit (炎佑, taunt 5), hidden or revealed`, () => {
for (const revealed of [false, true]) {
const { h, e } = boneArena({ content: 'full', extraContent: [] });
const [air] = spawnYanyou(h.b, 'p1', { count: 1, hp: 50000 });
assert.ok(air?.isFlying);
air.x = 6.5; air.y = 9;
h.b.applyStatus(air, 'stun', { duration: 30, force: true });
h.b.addBuff(air, { key: 'test:taunt', mods: { taunt: 5 } });
if (revealed) h.b.addBuff(e, { key: 'test:reveal', persist: true, flags: { reveal: true } });
const t = boneNext(h, e);
assert.equal(t.length, revealed ? 1 : 3);
assert.ok(!t.includes(air), `${revealed ? 'revealed' : 'hidden'}: the flyer is no target`);
}
});
test(`${nm('enemy_9008_acbunn')}: its 3 hidden targets come from its normal attack's reach (2 + the 0.25 ally collider)`, () => {
const { h, e } = boneArena();
const edge = h.unit('t_wall3'), outside = h.unit('t_wall4');
edge.x = e.x + 2.1; edge.y = e.y;
outside.x = e.x + 2.3; outside.y = e.y;
h.b.addBuff(edge, { key: 'test:taunt', mods: { taunt: 5 } });
const t = boneNext(h, e);
assert.ok(t.includes(edge), 'the ally 2.1 away is one of the three');
assert.ok(!t.includes(outside), 'the one 2.3 away is out of reach');
});
test(`${nm('enemy_1175_dushdo_2')}: stealth; next to 深池伙友卫队精英 its attack interval drops by traitAbility.base_attack_time`, () => {
const h = arena();
h.step();
@@ -1037,6 +1098,28 @@ test(`${nm('enemy_1112_emppnt')} / 中枢: every attack is a shell that lands 3
}
});
test(`${nm('enemy_1112_emppnt_2')} / 先兆者: the shell's 1.2 circle takes every ally whose collider touches it — the impact tile and the 8 around it, not the cross (GitHub #364)`, () => {
for (const key of ['enemy_1112_emppnt_2', 'enemy_1112_emppnt']) {
// the enemy on (10, 8) reaches only the wall on (10, 6): the impact; the others stand around it, out of its reach
const h = arena({ units: [
{ chessId: 't_wall', row: 10, col: 6 }, { chessId: 't_wall2', row: 10, col: 5 },
{ chessId: 't_wall3', row: 9, col: 5 }, { chessId: 't_wall4', row: 10, col: 4 },
], captureNoisy: true });
h.step();
const e = put(h, key, [10, 8], { route: 2 });
assert.ok(h.runUntil(() => e.stats.attacks >= 1, 10), 'attacks');
const shell = h.eventsOf('fx').find((ev) => ev[1] === 'bombardShell');
assert.ok(Math.abs(shell[4].r - 1.45) < 1e-9, 'drawn at the reach: 1.2 + the 0.25 collider');
h.run(3.1);
const taken = (id) => h.unit(id).stats.taken;
for (const [id, what] of [['t_wall', 'the impact'], ['t_wall2', 'its orthogonal neighbour (1 away)'], ['t_wall3', 'a diagonal neighbour (√2 away)']]) {
assert.ok(Math.abs(taken(id) - e.s.atk) < 1e-6, `${key}: ${what} takes the shell`);
}
assert.equal(taken('t_wall4'), 0, `${key}: two tiles away stays out`);
checkInvariants(h.b);
}
});
test(`${nm('enemy_10084_hlegle')}: never attacks a flying ally (不会攻击飞行单位) — a ground one in range instead`, () => {
// a flying ally (the 炎佑 is one) deployed after the wall, so the engine's order (latest deployed) would pick it
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }, { chessId: 't_wall2', row: 10, col: 8 }] });
+24
View File
@@ -113,6 +113,30 @@ test('信仰搅拌机 S1 铳骑主考官 (自动触发 ⇒ DEFAULT: hurt SP, fir
}
});
test('信仰搅拌机 S1 reloads the latest-deployed 拉特兰 ammo user around him, not the nearest (PRTS 备注; GitHub #325, PR #329)', () => {
for (const redeploy of [false, true]) {
// 隐现 ×2: the orthogonal neighbour deployed first, the diagonal one after it (both on his 8 surrounding tiles)
const h = battle([U('chess_char_4_01_a', 10, 5, 0), U('chess_char_1_01_a', 10, 4, null, null, { uid: 2 }), U('chess_char_1_01_a', 9, 4, null, null, { uid: 3 })]);
h.step();
const u = h.unit('chess_char_4_01_a'), near = h.unit(2), far = h.unit(3);
if (redeploy) { // a redeploy is the latest deployment
h.b.retreat(near, { reason: 'raid' });
assert.ok(h.b._deploy(near));
}
const latest = redeploy ? near : far, other = redeploy ? far : near;
assert.ok(latest.deploySeq > other.deploySeq);
for (const a of [near, far]) { assert.ok(a.skill.activate('test', { free: true })); a.skill.ammoLeft = 5; }
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(u.skill.activate('test', { free: true }));
h.b.forceAttack(u, [e]);
h.run(0.5);
const reloads = h.eventsOf('fx').filter((x) => x[1] === 'reload');
assert.ok(reloads.length > 0, 'a reload happened');
assert.ok(reloads.every((x) => x[4].id === latest.id), `${redeploy ? 'after a redeploy, the redeployed one' : 'the later-deployed diagonal one'} is reloaded`);
checkInvariants(h.b);
}
});
test('信仰搅拌机 S2 八臂电锯侠: ammo skill, ATK/DEF up; a lethal hit is negated for ammo_cost bullets; with fewer it is still negated, every bullet goes and the skill ends (PRTS 备注)', () => {
for (const id of pair('chess_char_4_01_a')) {
const bb = D(id, 1).skill.bb;
+21
View File
@@ -454,6 +454,27 @@ test('歌蕾蒂娅 S3: binds the farthest target, tornado pulses 85 % ATK arts e
checkInvariants(h.b);
});
test('歌蕾蒂娅 S3: the skill-end 捕网 has radius 1, the tornado 1.5 (PRTS 备注; GitHub #324, PR #329)', () => {
const id = 'chess_char_4_12_a';
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3 }], timeLimit: 200, autoFinish: false });
h.step();
const u = h.unit(id);
h.spawn('enemy_dummy', { pos: [10, 6] }); // the farthest enemy: bound, the tornado sits on it
h.step();
assert.ok(u.skill.activate('test', { free: true }));
assert.deepEqual([u.mem.tornado.x, u.mem.tornado.y], [6, 10]);
const at = (x, y) => { const e = h.spawn('enemy_dummy', { pos: [10, 6] }); e.x = x; e.y = y; return e; };
const inner = at(6, 9.1), outer = at(6, 11.25); // 0.9 and 1.25 from the centre
h.step();
for (const e of [inner, outer]) assert.ok(e.findBuff(`glady:slow:${u.id}`), 'both inside the tornado (slowed)');
const p0 = [inner.x, inner.y, outer.x, outer.y];
u.skill.end('test');
h.run(0.6);
assert.ok(Math.hypot(inner.x - p0[0], inner.y - p0[1]) > 0.3, 'the net pulls the enemy 0.9 from the centre');
assert.deepEqual([outer.x, outer.y], [p0[2], p0[3]], 'the one 1.25 from the centre stays: outside the net');
checkInvariants(h.b);
});
test('歌蕾蒂娅 阿戈尔的波涛: 深海猎人 regen 2.5 %/s and −25 % damage from 海怪; elite drag damage', () => {
const id = 'chess_char_4_12_a', t0 = D(id).talents[0].bb;
const sea = withTags(dummy({ key: 'enemy_sea', atk: 0 }), ['seamonster']);
+26
View File
@@ -1097,6 +1097,32 @@ test('异客: the S3 storm stops when she leaves the field', () => {
assert.equal(h.eventsOf('fx').filter((e) => e[1] === 'lightning').length, n, 'no strike after her retreat');
});
test('异客: the S3 storm is range x-1 — the 13-tile diamond — around the tile of the target\'s centre (PRTS 备注; GitHub #322, PR #329)', () => {
// the storm's target stands off its tile centre at (5.4, 10.4) — tile (10, 5) — and is removed right after the cast, so
// every strike starts on the probe (no chain bounce from the target) when, and only when, the probe is in the zone
const strikes = ([dx, dy]) => {
const h = battle({ units: [{ chessId: 'chess_char_6_05_a', row: 10, col: 3 }], autoFinish: false });
h.step();
const u = h.unit('chess_char_6_05_a');
const tgt = h.spawn('enemy_dummy2', { pos: [10, 5], mods: { hpMul: 2 } });
tgt.x = 5.4; tgt.y = 10.4;
h.step();
assert.ok(u.skill.activate('test', { free: true }));
const storm = h.eventsOf('fx').find((e) => e[1] === 'storm');
assert.deepEqual([storm[2], storm[3]], [5, 10], 'the storm sits on the centre of the target\'s tile');
h.b.dealDamage(null, tgt, { amount: 1e12, type: 'true' });
const p = h.spawn('enemy_dummy', { pos: [10, 5] });
p.x = 5 + dx; p.y = 10 + dy;
h.run(4.2);
checkInvariants(h.b);
return h.dmg.filter((d) => d.src === u.id && d.tgt === p.id && d.tags.includes('storm')).length;
};
// in the diamond (|Δrow| + |Δcol| ≤ 2 by tile): (2, 0) / (0, −2) / (1.95, 0.4) lay outside the old 1.5 circle
for (const off of [[0, 0], [2, 0], [0, -2], [-2, 0], [1.45, 1.45], [1.95, 0.4]]) assert.equal(strikes(off), 8, `(${off}) struck by all 8 strikes`);
// outside it: tile (+2, +1), and tile (+3, 0) — (1.55, 1.05) was inside the old circle around the target's position
for (const off of [[2, 1], [1.55, 1.05], [0, 2.6]]) assert.equal(strikes(off), 0, `(${off}) never struck`);
});
test('蕾缪安: 通缉 needs a continuous 8 s stay in a 拉特兰 range (leaving restarts the count)', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_01_a', row: 12, col: 2 }, { chessId: 'test_lat_a', row: 10, col: 2 }],
+2
View File
@@ -292,6 +292,8 @@ test('her stock (the owner\'s decision of 2026-10-06): the placed 棋子 occupy
assert.deepEqual([u.skill.activations, w.stock], [1, 7], `${S}: 立即获得两枚棋子`);
h.step();
assert.ok(!u.s.flags.noSp, `${S}: below the cap, no 阻回`);
assert.ok(u.skill.nextCastAt > h.b.time, `${S}: the next cast waits one attack interval (GitHub #298)`);
h.run(u.skill.nextCastAt - h.b.time); // wait it out
u.skill.gainSp(999);
h.step();
assert.deepEqual([u.skill.activations, w.stock], [2, 8], `${S}: 最多拥有8枚`);
@@ -0,0 +1,56 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec } from '../helpers/battleHarness.js';
test('Dorothy, Pozëmka, and Schwarz DEF reductions fall back to the strongest unexpired application', () => {
const h = makeBattle({
autoFinish: false,
timeLimit: 10,
flags: { dpInit: 0, dpPerSec: 0 },
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, def: 1000, speed: 0 }) } },
units: [
{ uid: 1, diy: { slot: 'chess_char_6_diy1_a', charId: 'char_4048_doroth', skillIndex: 0, uniEquipId: null }, elite: true, row: 10, col: 4 },
{ uid: 2, kind: 'token', tokenId: 'token_10025_doroth_recttp', ownerUid: 1, row: 9, col: 6 },
{ uid: 3, diy: { slot: 'chess_char_6_diy2_a', charId: 'char_4055_bgsnow', skillIndex: 0, uniEquipId: null }, elite: true, row: 10, col: 5 },
{ uid: 4, kind: 'token', tokenId: 'token_10026_bgsnow_subbow', ownerUid: 3, row: 9, col: 5 },
{ uid: 5, diy: { slot: 'chess_char_5_diy1_a', charId: 'char_340_shwaz', skillIndex: 2, uniEquipId: null }, elite: true, row: 9, col: 4 },
],
});
h.step();
// Keep the real kits and projectile travel; prevent extra attacks refreshing their reductions.
for (const unit of h.allies()) unit.atkCd = 100;
const trap = h.unit(2), typewriter = h.unit(4), schwarz = h.unit(5);
const enemy = h.spawn('enemy_dummy', { pos: [9, 6] });
h.step();
assert.equal(enemy.s.def, 700, 'Dorothy S1 applies the initial 30% reduction');
h.run(0.9 - h.b.time);
assert.equal(h.b.forceAttack(typewriter, [enemy]), true);
h.run(1.8 - h.b.time);
schwarz.skill.gainSp(999);
schwarz.atkCd = 0;
h.step();
assert.equal(schwarz.skill.active, true, 'Schwarz S3 guarantees her armor-piercing talent');
schwarz.atkCd = 100;
h.run(2.1 - h.b.time);
const reductions = h.hooksOf('statusApplied').filter((event) => event.target === enemy && event.status === 'defDown');
assert.deepEqual(reductions.map(({ source, value, duration }) => [source.id, value, duration]), [
[trap.id, 0.3, 5],
[typewriter.id, 0.25, 5],
[schwarz.id, 0.2, 5],
]);
for (const [index, expectedTime] of [[0, 0], [1, 1], [2, 2]]) {
assert.ok(Math.abs(reductions[index].t - expectedTime) < 0.1, `reduction ${index + 1} lands near ${expectedTime}s`);
}
assert.equal(enemy.s.def, 700, 'weaker applications do not reduce the active strength');
h.run(5.1 - h.b.time);
assert.equal(enemy.s.def, 750, 'the typewriter reduction survives Dorothy S1 expiring');
h.run(6.1 - h.b.time);
assert.equal(enemy.s.def, 800, 'Schwarz reduction survives the typewriter reduction expiring');
h.run(7.1 - h.b.time);
assert.equal(enemy.s.def, 1000, 'DEF returns to its base value after the final application expires');
assert.deepEqual(h.b.errors, []);
h.invariants();
});