feedback5: merge W5C — 琳琅诗怀雅 S2 bomb on x-6 (#116), 新硎 / 沉沙 one 断刃 layer (#107), the owner's ACTIVE_RANGE trigger rule, AUTO-skill audit (伺夜 S2, 浊心斯卡蒂 S2)

# Conflicts:
#	data/backups.json
#	tools/build-data.mjs
This commit is contained in:
sganggs committed 2026-10-05 17:51:44 +08:00
commit 65ac4afbd8
32 files changed
+2432 -1959

No files matched your search

+24 -9
View File
@@ -290,22 +290,37 @@ for (const [key, n] of Object.entries(DEATH_SPAWN)) {
}
for (const key of ['enemy_1207_sfji', 'enemy_1207_sfji_2']) {
test(`${nm(key)}: each attack spends a blade (+ATK); unspent blades become 矛头 on death (at least 1)`, () => {
// PRTS 天赋 "攻击力+X%,持有4个【断刃】 / 每次成功攻击后消耗1个【断刃】,攻击结束时若已耗尽【断刃】,则立刻切换为无断刃模式并失去攻击力
// 加成" (popup: "清空当次攻击间隔" — it attacks again at once); GitHub #107: until 0.2.0 each attack stacked another layer
test(`${nm(key)}: one ATK layer while it holds a blade, gone with the 4th attack (which is followed at once by the next); unspent blades become 矛头 on death (at least 1)`, () => {
const per = tb(key, 'Atkup.atk') ?? tb(key, 'AtkUp.atk'), cnt = tb(key, 'DeadSpawn.cnt');
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
h.step();
const e = put(h, key, [9, 5]);
const base = e.s.atk;
const base = e.base.atk;
approx(e.s.atk, base * (1 + per), 1e-6, 'holding 4 blades: one layer from the spawn');
h.runUntil(() => e.stats.attacks >= 2, 20);
const per = tb(key, 'Atkup.atk') ?? tb(key, 'AtkUp.atk');
approx(e.s.atk, base * (1 + 2 * per));
killed(h, e, null);
approx(e.s.atk, base * (1 + per), 1e-6, 'two blades spent: still one layer (never stacked)');
h.runUntil(() => e.stats.attacks >= cnt, 20);
approx(e.s.atk, base, 1e-6, 'the last blade spent: 无断刃模式, no bonus');
const tLast = h.b.time;
assert.ok(h.runUntil(() => e.stats.attacks >= cnt + 1, 0.2), 'the mode switch clears the attack interval: the next attack at once');
assert.ok(h.b.time - tLast < e.s.interval / 2, `${h.b.time - tLast} s after the 4th attack`);
const child = E[key].talents.bbStr['DeadSpawn.enemy_key'];
assert.equal(alive(h, child).length, tb(key, 'DeadSpawn.cnt') + tb(key, 'DeadSpawn.cnt_add') * 2);
const h2 = arena();
killed(h, e, null);
assert.equal(alive(h, child).length, 1, 'no blade left: still one 矛头');
// killed after two attacks: the two unspent blades; untouched: all four
const h2 = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
h2.step();
const e2 = put(h2, key, [10, 7]);
const e2 = put(h2, key, [9, 5]);
h2.runUntil(() => e2.stats.attacks >= 2, 20);
killed(h2, e2, null);
assert.equal(alive(h2, child).length, tb(key, 'DeadSpawn.cnt'));
assert.equal(alive(h2, child).length, cnt + tb(key, 'DeadSpawn.cnt_add') * 2);
const h3 = arena();
h3.step();
const e3 = put(h3, key, [10, 7]);
killed(h3, e3, null);
assert.equal(alive(h3, child).length, cnt);
});
}
+5 -3
View File
@@ -352,16 +352,18 @@ test('2_12 砾 S1 影袭: at deployment DEF +def decaying to 0 over `duration` s
}
});
test('2_13 蒂比 S1 专业喷绘技巧: DEFAULT trigger, takes off (skill range, ATK +atk, blocks flyers), single shots; hits never set it off', () => {
test('2_13 蒂比 S1 专业喷绘技巧: ACTIVE_RANGE trigger (an enemy in its 2-3 only), takes off (skill range, ATK +atk, blocks flyers), single shots; hits never set it off', () => {
for (const id of both('chess_char_2_13')) {
const bb = bbAlt(id);
const h = run({ defs: { enemies: { e: dummy('e'), f: dummy('f', { motion: 'FLY' }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [9, 6] }] });
// (10,5) = [1,0]: inside S1's 2-3, outside her own 2-2 — the owner's rule of 2026-10-05 (data trigger ACTIVE_RANGE)
const h = run({ defs: { enemies: { e: dummy('e'), f: dummy('f', { motion: 'FLY' }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }] });
const u = h.unit(id);
usesAlt(u, id);
h.step();
const range0 = u.baseRangeKeys.length;
assert.ok(!u.baseRangeKeys.includes(h.b.grid.key(10, 5)), 'the enemy is outside her own range');
h.runUntil(() => u.skill.active, 3);
assert.equal(started(h, u)[0].reason, 'DEFAULT');
assert.equal(started(h, u)[0].reason, 'ACTIVE_RANGE');
assert.equal(u.s.flags.liftoff, true, 'airborne (起飞)');
assert.equal(u.ground, true, 'still a ground unit on her low tile');
approx(u.s.atk, u.base.atk * (1 + bb.atk));
+26
View File
@@ -829,6 +829,32 @@ test('3_19 伺夜 S2 领袖的馈赠: +DP, the pack recovers HP, its next attack
}
});
test('3_19 伺夜 S2 领袖的馈赠 (自动触发): no enemy needed — cast at full SP while the pack stands and holds no unused gift (PRTS 备注), never without the pack', () => {
for (const id of BOTH('chess_char_3_19_a')) {
const b = SB(id, 'skchr_vigil_2');
const h = makeBattle({ defs: { chess: noGarrison(id) }, timeLimit: 60, flags: { dpPerSec: 0 }, units: [U(id, 'skchr_vigil_2', 10, 3)] });
const u = h.unit(id), p = h.b.getPlayer('p1');
h.run(1);
const w = u.trait.reinforcement;
assert.ok(w && w.alive, 'the pack stands');
const dp0 = p.dp;
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), 'cast with nobody on the field');
approx(p.dp, dp0 + b.cost, 1e-6, '+cost DP at once');
assert.ok(w.mem.vigilGift, 'the pack holds the gift for its next attack');
fill(u);
h.run(3);
assert.equal(u.skill.activations, 1, '"狼群未获得此技能的充能时可触发": no second cast while the gift is unused');
// the gift spent (as by a bite), the pack gone: still no cast until a pack stands again
w.mem.vigilGift = null;
for (let i = 0; i < 10 && w.alive; i++) h.b.dealDamage(null, w, { amount: 1e9, type: 'true' }); // a wolf is lost per KO
assert.ok(!u.trait.reinforcement?.alive, 'no pack on the field');
h.step(2);
assert.equal(u.skill.activations, 1, '"仅场上存在狼群…时可触发": no pack, no cast');
done(h);
}
});
test('3_19 伺夜 精锐 module TAC-Y: ×165 % on pack-blocked enemies; "援军阻挡的敌人更容易受到我方的攻击" = those enemies taunt +1 (not the pack); TAC-X guard not applied', () => {
const id = 'chess_char_3_19_b', mod = 'uniequip_003_vigil';
const tb = ds.getChess(id, { moduleId: mod }).traitBb;
+17
View File
@@ -366,6 +366,23 @@ test('6_04 浊心斯卡蒂 S2 同葬无光之愿: toggle; 鼓舞 ATK and DEF (at
}
});
test('6_04 浊心斯卡蒂 S2 同葬无光之愿 (自动触发, effects on her allies only): on as soon as its SP is full — no enemy needed', () => {
for (const id of both('chess_char_6_04')) {
const sid = 'skchr_skadi2_2';
const h = run({
defs: { chess: { ally_a: plain('ally_a', { stats: { maxHp: 1e5, atk: 400, def: 100 } }) } },
units: [U(id, sid, 10, 4, { carryState: READY }), { chessId: 'ally_a', row: 10, col: 5 }],
});
const u = h.unit(id), a = h.unit('ally_a');
usesSkill(u, sid);
assert.equal(u.skill.rule, 'SP_FULL');
assert.ok(h.runUntil(() => u.skill.active, 2), 'on with nobody on the field (the data DEFAULT waited for an enemy in her range)');
h.run(1.1);
assert.ok(a.findBuff('inspire') && a.findBuff('inspire:def'), '鼓舞 on the ally beside her');
done(h);
}
});
// =================================================================================================================
// 6_05 异客
+68 -22
View File
@@ -6,6 +6,7 @@ import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.
import { getDefaultSource } from '../../server/sim/simdata.js';
import { effectiveProfile } from '../../server/sim/ai.js';
import KITS from '../../server/sim/content/kits/tier3.js';
import { COLS } from '../../server/sim/constants.js';
const ds = getDefaultSource();
const D = (id) => ds.getChess(id);
@@ -110,23 +111,38 @@ test('3_03 诗怀雅: 近距离作战指导 melee ATK aura, ×talent_scale durin
done(h);
});
test('3_04 琳琅诗怀雅: coins → champagne bomb (ATK% + 停顿), 大买家 coin/ATK per DP payment, 破财消灾 DP revive', () => {
/** 琳琅诗怀雅 S2's placement range x-6 (range_table; PRTS 备注 "香槟炸弹放置范围:x-6") — symmetric, so facing does not matter. */
const SWIRE_X6 = [[2, 0], [1, 0], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, 0], [-2, 0]];
const TRAP = 'token_10031_swire2_gdtrap';
const trapsOf = (h) => h.b.allyUnits.filter((t) => t.alive && t.defId === TRAP);
const x6Keys = (r, c) => new Set(SWIRE_X6.map(([dr, dc]) => (r + dr) * COLS + c + dc));
/** Flat stage where the only free placeable tile of x-6 around (9,5) is (9,6): the others are walkable 'R' road. */
const ONE_TILE = { rows: { 9: '##ERRrrRrrSrrrrrrrS##', 10: '##hrrRrrrrfrrrrrrrf##', 11: '##hrrRrrrrfrrrrrrrf##' } };
test('3_04 琳琅诗怀雅: coins → champagne bombs on range x-6 with no enemy needed (ATK% + 停顿), 大买家 coin/ATK per DP payment, 破财消灾 DP revive', () => {
const id = 'chess_char_3_04_a', bb = BB(id), t0 = TB(id, 0), t1 = TB(id, 1);
const booms = [];
const h = makeBattle({
defs: { enemies: { enemy_w: enemyRec({ key: 'enemy_w', hp: 1e6, speed: 1, def: 0, atk: 0 }) } }, timeLimit: 60, hooks: ['damaged', 'deploy'], captureNoisy: true,
units: [{ chessId: id, row: 9, col: 5 }], enemies: [{ key: 'enemy_w', route: 0 }],
units: [{ chessId: id, row: 9, col: 5 }], enemies: [{ key: 'enemy_w', route: 0, time: 8 }],
setup: (b) => b.on('damaged', (c) => { if (c.dmg?.tags?.includes('trap')) booms.push({ amount: c.amount, atk: c.source.s.atk, t: b.time }); }),
});
const u = h.unit(id);
const x6 = x6Keys(9, 5);
h.step();
assert.equal(u.mem.coins, t0.sp, '开启技能 coin');
// the 开启技能 coin (大买家) is thrown at once — her attack's turn, nobody on the field (client charpack: the S2 attack is
// [SpawnToken if a free x-6 tile and a coin, else Attack]; PRTS 备注 "否则随机放置香槟至可部署的地块")
assert.equal(trapsOf(h).length, t0.sp, 'the deployment coin became a bomb with no enemy around');
assert.equal(u.mem.coins, 0);
h.run(3.1);
assert.equal(u.mem.coins, t0.sp + t0.trait_sp, 'coin from the first trait payment');
assert.equal(u.findBuff('talent:swire2_buyer')?.stacks, 1);
assert.equal(u.findBuff('talent:swire2_buyer')?.stacks, 1, 'first trait payment: an ATK stack');
approx(u.s.atk, u.base.atk * (1 + t0.atk));
assert.ok(h.runUntil(() => booms.length > 0, 20), 'bomb exploded');
assert.ok(h.hooksOf('deploy').some((c) => c.unit.defId === 'token_10031_swire2_gdtrap'), 'bomb token placed');
assert.equal(trapsOf(h).length, t0.sp + t0.trait_sp, '… and its coin another bomb');
for (const t of trapsOf(h)) {
assert.ok(x6.has(t.tileR * COLS + t.tileC), `bomb (${t.tileR},${t.tileC}) on range x-6`);
assert.ok(h.b.grid.groundPassable(t.tileR, t.tileC) && h.b.grid.canStand(t.tileR, t.tileC), 'on walkable ground a melee unit may stand on');
}
assert.ok(h.runUntil(() => booms.length > 0, 30), 'bomb exploded');
approx(booms[0].amount, booms[0].atk * bb.atk_scale, 1e-6, 'bomb damage');
const e = h.enemy('enemy_w');
assert.ok(e.findBuff('sluggish'), '停顿');
@@ -846,33 +862,37 @@ test('3_02 断崖: 浮游刃 blades hit enemies blocked by the allies around her
done(h);
});
test('3_04 琳琅诗怀雅 精锐: MER-X drain, coin cap, armed bomb hits twice; bombs never land on a dead operator’s tile', () => {
test('3_04 琳琅诗怀雅 精锐: MER-X drain; no enemy ⇒ coins fill every free tile of x-6, then pile up to the cap; armed bomb hits twice; never on a dead operator’s tile', () => {
const gid = 'chess_char_3_04_b', bb = BB(gid), t0 = TB(gid, 0), tb = TR(gid);
// drain + coin cap (no enemy: no attack spends a coin)
const q = makeBattle({ units: [{ chessId: gid, row: 9, col: 5 }], timeLimit: 60, flags: { dpPerSec: 0 } });
// drain; no enemy: every coin becomes a bomb on a free tile of x-6 until none is left, then the coins pile up to the cap
const q = makeBattle({ units: [{ chessId: gid, row: 9, col: 5 }], timeLimit: 90, flags: { dpPerSec: 0 } });
const s = q.unit(gid);
q.step();
q.b.getPlayer('p1').dp = 50;
q.b.getPlayer('p1').dp = 90;
q.run(3.1);
approx(q.b.getPlayer('p1').dp, 50 - Math.abs(tb.cost), 1e-6, 'module: 2 DP per payment');
q.run(tb.interval * (bb.sp + 1));
approx(q.b.getPlayer('p1').dp, 90 - Math.abs(tb.cost), 1e-6, 'module: 2 DP per payment');
// the flat field around (9,5): x-6 has 6 free placeable tiles (row 9 cols 3/4/6/7, (10,5), (11,5); rows 7/8 are no field)
const free = [...x6Keys(9, 5)].filter((k) => { const r = (k / COLS) | 0, c = k % COLS; return !(r === 9 && c === 5) && q.b.grid.groundPassable(r, c) && q.b.grid.canStand(r, c); });
assert.equal(free.length, 6);
q.run(tb.interval * (free.length + bb.sp + 1));
assert.deepEqual(trapsOf(q).map((t) => t.tileR * COLS + t.tileC).sort((a, b) => a - b), free.sort((a, b) => a - b), 'a bomb on every free tile of x-6');
const paid = Math.floor((q.b.time + 1e-9) / tb.interval);
assert.equal(s.mem.coins, bb.sp, 'coin cap = bb.sp');
assert.equal(s.mem.coins, bb.sp, 'x-6 full: coin cap = bb.sp');
assert.equal(s.findBuff('talent:swire2_buyer').stacks, Math.min(t0.max_stack_cnt, paid), 'ATK stack per payment (coins capped, stacks not)');
done(q);
// a bomb that stayed duration_switch s on the field hits twice
// a bomb that stayed duration_switch s on the field hits twice (only (9,6) is free: the walker comes to it after 3 s)
const booms = [];
const h = makeBattle({
flat: ONE_TILE,
defs: { enemies: { enemy_d: dummy('enemy_d'), enemy_w: enemyRec({ key: 'enemy_w', hp: 1e6, speed: 1, def: 0, atk: 0 }) } }, timeLimit: 60,
units: [{ chessId: gid, row: 9, col: 5 }], enemies: [{ key: 'enemy_d', pos: [9, 5] }, { key: 'enemy_w', route: 0, time: 1 }],
setup: (b) => b.on('damaged', (c) => { if (c.dmg?.tags?.includes('trap')) booms.push({ target: c.target.defId, amount: c.amount, atk: c.source.s.atk, t: b.time }); }),
});
const u = h.unit(gid);
const switchT = ds.getToken('token_10031_swire2_gdtrap', gid).skill.bb.duration_switch;
assert.ok(h.runUntil(() => h.b.allyUnits.some((t) => t.alive && t.defId === 'token_10031_swire2_gdtrap'), 5), 'bomb placed in front of her');
const bomb = h.b.allyUnits.find((t) => t.alive && t.defId === 'token_10031_swire2_gdtrap');
assert.deepEqual([bomb.tileR, bomb.tileC], [9, 6]);
const switchT = ds.getToken(TRAP, gid).skill.bb.duration_switch;
assert.ok(h.runUntil(() => trapsOf(h).length > 0, 5), 'bomb placed');
const bomb = trapsOf(h)[0];
assert.deepEqual([bomb.tileR, bomb.tileC], [9, 6], 'the one free tile of x-6');
assert.ok(h.runUntil(() => booms.length > 0, 30), 'the walker triggered it');
assert.ok(booms[0].t - bomb.deployedAt >= switchT);
assert.equal(booms.length, 2, 'armed bomb: one extra hit');
@@ -880,8 +900,9 @@ test('3_04 琳琅诗怀雅 精锐: MER-X drain, coin cap, armed bomb hits twice;
assert.ok(h.enemy('enemy_w').findBuff('sluggish'));
done(h);
// the tile in front belongs to a dead operator waiting to redeploy: no bomb there (no coin spent)
// that one tile belongs to a dead operator waiting to redeploy: no bomb anywhere, no coin spent, and she attacks on
const k = makeBattle({
flat: ONE_TILE,
defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 30,
units: [{ chessId: gid, row: 9, col: 5 }, { chessId: 'chess_char_3_16_a', row: 9, col: 6 }], enemies: [{ key: 'enemy_d', pos: [9, 5], time: 0.5 }],
});
@@ -892,11 +913,36 @@ test('3_04 琳琅诗怀雅 精锐: MER-X drain, coin cap, armed bomb hits twice;
const coins = v.mem.coins;
k.run(5);
assert.ok(v.stats.attacks >= 2, 'she kept attacking');
assert.equal(k.b.allyUnits.filter((t) => t.defId === 'token_10031_swire2_gdtrap').length, 0, 'no bomb on the dead operator’s tile');
assert.equal(k.b.allyUnits.filter((t) => t.defId === TRAP).length, 0, 'no bomb on the dead operator’s tile');
assert.ok(v.mem.coins >= coins, 'no coin spent');
done(k);
});
test('3_04 琳琅诗怀雅 S2 见面礼: an enemy on a free tile of x-6 gets the bomb on its tile — ground only, in her attack target order; outside her 1-1 attack range too', () => {
const id = 'chess_char_3_04_a';
const bombAt = (enemies) => {
const h = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d'), enemy_f: dummy('enemy_f', { motion: 'FLY' }) } }, timeLimit: 10, autoFinish: false,
units: [{ chessId: id, row: 9, col: 5 }], enemies,
});
const u = h.unit(id);
h.step();
for (const t of trapsOf(h)) h.b.retreat(t, { reason: 'expired', permanent: true }); // the deployment coin's bomb
h.step(2);
u.mem.coins = 1;
assert.ok(h.runUntil(() => trapsOf(h).length > 0, 2), 'a bomb');
const t = trapsOf(h)[0];
done(h);
return [t.tileR, t.tileC];
};
// (9,7): two tiles ahead — x-6 but outside her attack range 1-1
assert.deepEqual(bombAt([{ key: 'enemy_d', pos: [9, 7] }]), [9, 7]);
// two ground enemies: the one nearer the goal (9,2) first (her attack target order: blocked → … → remaining distance)
assert.deepEqual(bombAt([{ key: 'enemy_d', pos: [9, 7] }, { key: 'enemy_d', pos: [9, 4] }]), [9, 4]);
// a flyer is no target of the selector (its `_targetMotion` WALK): the ground enemy farther from the goal gets it
assert.deepEqual(bombAt([{ key: 'enemy_d', pos: [9, 7] }, { key: 'enemy_f', pos: [9, 4], route: 2 }]), [9, 7]);
});
test('3_07 见行者: 惊爆射击 fires on an enemy inside the skill range only; 精锐 refund only on a ranged tile', () => {
const id = 'chess_char_3_07_a', bb = BB(id);
const h = makeBattle({
+3
View File
@@ -801,10 +801,13 @@ test('百炼嘉维尔 S2: drags unblocked enemies in front of her; T1 ATK/DEF +1
u.hp = u.s.maxHp * 0.3;
approx(h.b.heal(null, u, 100), 100 * t1.heal_scale_2);
for (const e of h.b.enemies) h.b.dealDamage(null, e, { amount: 1e9, type: 'true' });
h.run(0.3); // 战地巨斧 refreshes every 0.2 s: back to no extra blocked enemy before the cast
const near = h.spawn('enemy_dummy', { pos: [9, 5] });
const far = h.spawn('enemy_dummy', { pos: [9, 6] });
u.skill.gainSp(1000);
// S2's 2-5 strictly contains her 1-1: ACTIVE_RANGE (the owner's rule, 2026-10-05) casts it at once, no attack needed
assert.ok(h.runUntil(() => u.skill.active, 3));
assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u)?.reason, 'ACTIVE_RANGE');
approx(u.s.atk, u.base.atk * (1 + t0.atk + bb.atk));
h.b.dealDamage(null, near, { amount: 1e9, type: 'true' });
h.run(1.5);
@@ -125,8 +125,10 @@ test('琳琅诗怀雅: a 香槟炸弹 arms after its owner\'s module variant of
chessId: SWIRE, lo, tokens, row: 9, col: 5, enemies: { enemy_d: dummy('enemy_d') },
spawns: [{ key: 'enemy_d', pos: [9, 5] }, { key: 'enemy_d', pos: [9, 13] }],
});
for (const u of ops) { u.mem.coins = 5; u.player.dp = 99; }
// the moment a bomb stands, an enemy steps on it (the bomb is < 1 tick old)
// each threw its deployment coin's bomb in duel's first step (S2 needs no enemy); no other coin before the 3 s payment,
// so every trap hit below is that bomb's
for (const u of ops) { u.mem.coins = 0; u.player.dp = 99; }
// the moment a bomb stands, an enemy steps on it (the bomb is a tick old)
const bombs = [null, null];
for (let i = 0; i < 30 * 10 && bombs.some((x) => !x); i++) {
h.step();