feedback5 WM: merge feedback4 (896dd5d, the coming 0.1.4) — PRs #150 #66 #146 #74 #72 #103 #149 #109 #160 ported into the split layout

Ported by hand:
- #109 kit hunks → kits/ops (the tier files stay re-export shims): 宴 chess_char_1_18-utage.js, 野鬃
  chess_char_1_19-wildmn.js, 砾 chess_char_2_12-gravel.js, 斯卡蒂 chess_char_3_05-skadi.js, 伊内丝
  chess_char_4_04-ines.js, 缄默德克萨斯 chess_char_4_16-texas2.js, 耀骑士临光 chess_char_6_17-nearl2.js. The +/- lines
  are identical to #109's tier1/2/3/4/6 diffs.
- #109 generic.js against W5D's own-duration passives (一击即退): the deployment window is computed where #109 computes
  it, with W5D's case included. Both forms run #109's duration lifecycle (activateOnDeploy, spType 'none', the
  skill's own mods), and W5D's 冲锋号令 DP start effect is kept.
- #103 room.js through i18n phase 1: t('{name}邀请你加入卫戍协议:盟约【{difficulty}】') with the translated difficulty
  name (the Chinese text is the PR's), plus its English line in public/i18n/en.json.
- data/assets.json: feedback5's manifest plus #72's audio.bgm.combatAlts (after boss, the plan order). files +3 and
  bytes +5 037 595 as measured on feedback4; the hash is recomputed with the generator's contentHash. No other data/
  file changed.
- docs: DESIGN §5.6 (#109's timed deployment paragraph before feedback5's trigger rules; "Permanent passive skills are
  always on"), SIM §7.4 (both generic passive cases).
- Auto-merged and checked: #149 bond-bar fold (still in screens/game.js), #160 skills.js / spine.js, #109 skills.js /
  snapshot.js, #66 addon/battle.js (restTile / isDown live in sim/battle/tiles.js), #150 units.js, #72 audio.js /
  plan.mjs, #74 plan.mjs / docs.

Where the PRs met feedback5 work:
- 突袭 (W5L, GitHub #49): raidPoll counts a skill that needs no SP while it is on. That is a passive, as before, or a
  #109 deploy-timed skill while its window runs. Once the window has ended only the idle trigger is left. Without this
  change 缄默德克萨斯 / 宴 lost #49. research 02 §3.18 says so.
- Tests changed to #109's representation: bonds_addon #49 test (kind 'duration', activateOnDeploy) plus a new test
  (window over → idle trigger only), and standin.test.js's 预备干员 PASSIVE branch (the skill's mods instead of the
  generic:passive buff).

Golden regenerated on the merged code. Attribution: each PR's gameplay change was reverted alone in a scratch copy, and
all three together. With all three reverted the digests equal feedback5's exactly. Every moved scenario was replayed
and fields the unit that explains it.
- #160 only (0.5 s skill events of deploy passives; events.skill only): roster-001/006/025/049, bond-arcaneShip-low,
  bond-emptyShip-high, bond-investShip-high, bond-kjeragShip-low, bond-siracusaShip-low/high, bond-victoriaShip-low,
  bond-yanShip-high, boss-boss_2/4/6-solo, hidden-boss_8/10-solo, standin-03/05/08/10, diy-005/006 (普罗旺斯 S1,
  琳琅诗怀雅 S1/S2, 星熊 S2, 凯瑟琳 S1, Misery S3 as stand-in / 自选 prototype).
- #109 only (deploy-timed skill state and lifecycle; 突袭 技能就绪 limited to the window; generic 一击即退):
  roster-003/004/005/021/023/024/039/041/042/046/047, bond-egirShip-high, bond-indomShip-high,
  bond-kazimierzShip-low/high, hidden-boss_8-pair, standin-01, coop4-FUNNY-6, coop2-NORMAL-8-serverrun.
- #66 only (灵巧's aura stays only for a killed member, from its body tile): roster-028, coop2-NORMAL-3,
  coop3-ABYSS-5, coop4-ABYSS-7, solo-HARD-9-boosted.
- #109 + #160: roster-002, roster-029, bond-investShip-low, bond-raidShip-low/high, bond-suntShip-low,
  bond-visiShip-high.
- #66 + #160: roster-008, roster-027.
- #66 + #109: roster-022, roster-040, bond-indomShip-low, boss-boss_2/4/6-pair, hidden-boss_10-pair, standin-06,
  coop2-HARD-4, coop2-NORMAL-14-standins.
- all three: roster-010, roster-012, roster-031.
69 scenarios. Every other digest is unchanged.
This commit is contained in:
sganggs committed 2026-10-05 23:59:05 +08:00
commit a0a7660287
55 files changed
+2140 -736

No files matched your search

+87 -9
View File
@@ -265,6 +265,13 @@ test('伊内丝 S3 独影归途: first deployment leaves a sentry and retreats;
const sk = D(id, 2).skill, bb = sk.bb;
const h = battle([U(id, 10, 5, 2)], { flags: { dpInit: 99, dpPerSec: 0 } });
const u = h.unit(id);
h.b.start();
assert.equal(u.skill.kind, 'duration');
assert.equal(u.skill.active, false, 'first deployment does not activate the skill');
assert.equal(u.skill.activations, 0);
assert.equal(h.hooksOf('skillStart').filter((c) => c.unit === u).length, 0, 'placing a sentry emits no skillStart');
assert.equal(u.skill.timeLeft, 0, 'first deployment places the sentry without a full effect window');
assert.equal(u.findBuff('ines:s3'), null);
const near = [];
for (let i = 0; i < 3; i++) near.push(h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_dummy', { pos: [10, 4] }));
const far = h.spawn('enemy_dummy', { pos: [10, 8] });
@@ -273,9 +280,14 @@ test('伊内丝 S3 独影归途: first deployment leaves a sentry and retreats;
assert.ok(left && left.reason === 'retreat', `${id}: leaves right after the first deployment`);
assert.ok(h.runUntil(() => u.alive && u.deployed, 2), 'redeploy timer refreshed: back at once');
const buff = u.findBuff('ines:s3');
assert.ok(buff, 'passive buff on the redeploy');
assert.ok(buff, 'ATK effect on the redeploy');
approx(buff.mods.atkPct, bb.atk, 1e-9);
approx(buff.timeLeft, sk.duration, 0.1);
approx(u.skill.timeLeft, sk.duration, 0.1);
assert.equal(u.skill.active, true);
assert.equal(u.skill.activations, 1);
assert.equal(h.hooksOf('skillStart').filter((c) => c.unit === u).length, 1, 'the redeployment starts the skill once');
assert.equal(u.skill.ready, false);
assert.equal(h.snapshot().units.find((t) => t[0] === u.id)[6], sk.duration);
const recall = dmgBy(h, u, tagged('sentryRecall'));
assert.equal(recall.length, bb.max_target, `${id}: recall hits ≤ ${bb.max_target} of 6`);
assert.ok(recall.every((c) => near.includes(c.target)) && !recall.some((c) => c.target === far));
@@ -287,6 +299,15 @@ test('伊内丝 S3 独影归途: first deployment leaves a sentry and retreats;
assert.ok(hits > 0);
assert.equal(h.b.getPlayer('p1').dp, dpAt + hits * bb.cost, 'one DP per damage while the passive lasts');
assert.equal(h.hooksOf('death').filter((c) => c.unit === u).length, 1, 'only the first deployment leaves');
h.run(sk.duration);
assert.equal(u.skill.active, false);
assert.equal(u.skill.ready, false);
assert.equal(u.findBuff('ines:s3'), null);
const dpEnd = h.b.getPlayer('p1').dp;
h.run(2);
assert.equal(h.b.getPlayer('p1').dp, dpEnd, 'no skill DP after the actual window ends');
assert.equal(h.hooksOf('death').filter((c) => c.unit === u).length, 1, 'stays deployed after the window');
assert.deepEqual(h.hooksOf('skillEnd').filter((c) => c.unit === u).map((c) => c.reason), ['duration']);
checkInvariants(h.b);
}
});
@@ -611,10 +632,11 @@ test('缄默德克萨斯 S1 细雨无声: passive ATK up for its duration; hits
const h = battle([U(id, 10, 4, 0)]);
h.step();
const u = h.unit(id);
const buff = u.findBuff('texas2:drizzle');
assert.ok(buff, `${id}: passive active on deployment`);
approx(buff.mods.atkPct, bb.atk, 1e-9);
assert.ok(u.findBuff('texas2:rainAtk'), 'talent 德克萨斯传统 ATK during the passive');
assert.equal(u.skill.kind, 'duration');
assert.equal(u.skill.active, true);
assert.equal(u.skill.charges, 0);
approx(u.skill.spec.mods.atkPct, bb.atk + D(id, 0).talents[0].bb.atk, 1e-9, 'skill and 德克萨斯传统 ATK');
assert.equal(h.snapshot().units.find((t) => t[0] === u.id)[6], sk.duration);
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
h.run(3);
assert.ok(e.s.flags.silence, 'hit target silenced (失去特殊能力)');
@@ -623,7 +645,10 @@ test('缄默德克萨斯 S1 细雨无声: passive ATK up for its duration; hits
assert.ok(dot.length >= 2, `a DoT tick every second although she re-hits faster (${dot.length})`);
for (const c of dot) approx(c.amount, bb['attack@texas2_s_1[dot].dot_damage'], 1e-9, 'fixed arts per second (RES 0)');
h.run(sk.duration);
assert.ok(!u.findBuff('texas2:drizzle'), 'ends with the skill duration');
assert.equal(u.skill.active, false, 'ends with the skill duration');
assert.equal(u.skill.ready, false);
assert.equal(h.hooksOf('skillEnd').filter((c) => c.unit === u && c.reason === 'duration').length, 1);
assert.ok(e.s.flags.silence && e.findBuff(`texas2:drizzleDot:${u.id}`), 'applied enemy effects keep their own duration');
h.run(bb['attack@silence'] + 1);
assert.ok(!e.s.flags.silence && !e.findBuff(`texas2:drizzleDot:${u.id}`), 'no new silence / DoT after the passive');
}
@@ -657,8 +682,8 @@ test('缄默德克萨斯 S2 阵雨连绵: deploy burst atk_scale arts + RES down
approx(burst[0].amount, u.s.atk * bb.atk_scale * 0.5, 1e-6, 'arts burst vs RES 50');
approx(e.s.res, 50 * (1 + bb.magic_resistance), 1e-6, 'RES down');
assert.equal(far.s.res, 50);
approx(u.findBuff('texas2:shower').mods.atkPct, bb.atk, 1e-9);
approx(u.findBuff('texas2:shower').timeLeft, sk.duration, 1e-6);
approx(u.skill.spec.mods.atkPct, bb.atk + D(id, 1).talents[0].bb.atk, 1e-9);
approx(u.skill.timeLeft, sk.duration, 1e-6);
h.run(2);
const hits = dmgBy(h, u, (c) => c.dmg.isAttack);
assert.ok(hits.length >= 2 && hits.every((c) => c.type === 'arts'), 'arts attacks');
@@ -666,6 +691,8 @@ test('缄默德克萨斯 S2 阵雨连绵: deploy burst atk_scale arts + RES down
for (const c of hits) per.set(c.dmg.attackId, (per.get(c.dmg.attackId) ?? 0) + 1);
assert.ok([...per.values()].every((v) => v === 2), 'double hits');
h.run(sk.duration);
assert.equal(u.skill.active, false);
assert.equal(u.skill.ready, false);
const n0 = dmgBy(h, u, (c) => c.dmg.isAttack).length;
h.run(3);
const late = dmgBy(h, u, (c) => c.dmg.isAttack).slice(n0);
@@ -673,6 +700,57 @@ test('缄默德克萨斯 S2 阵雨连绵: deploy burst atk_scale arts + RES down
}
});
test('缄默德克萨斯 S1/S2/S3: first kill reopens a full window, including a kill inside the deployment burst', () => {
for (const id of pair('chess_char_4_16_a')) for (const index of [0, 1, 2]) {
const life = [];
const h = battle([U(id, 10, 4, index)], { extra: { setup(b) {
for (const name of ['skillStart', 'skillEnd']) b.on(name, (c) => life.push([name, c.reason]), { priority: 1000 });
} } });
h.b.start();
const u = h.unit(id), sk = u.skill;
h.run(2);
u.hp = u.s.maxHp / 2;
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
h.b.kill(e, u);
assert.equal(u.mem.texasKilled, true);
approx(u.hpRatio, 1);
assert.equal(u.findBuff('texas2:swordplay'), null);
assert.equal(sk.activations, 2);
approx(sk.timeLeft, sk.duration);
assert.equal(sk.charges, 0);
assert.equal(sk.ready, false);
assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['recast']);
h.b.kill(h.spawn('enemy_dummy', { pos: [10, 5] }), u);
assert.equal(sk.activations, 2, 'only first kill recasts');
h.run(sk.duration + 0.1);
assert.equal(sk.active, false);
assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['recast', 'duration']);
assert.deepEqual(h.hooksOf('skillStart').map((c) => c.reason), ['deploy', 'kill']);
h.b.retreat(u);
const lifeAt = life.length;
const weak = h.spawn('enemy_dummy', { pos: [10, 5] });
weak.hp = 1;
assert.ok(h.b.redeploy(u, { free: true }));
if (index === 0) h.b.kill(weak, u); // S1 has no deployment burst
assert.equal(u.mem.texasKilled, true);
assert.equal(sk.activations, 4, 'new deployment and first-kill recast, including synchronous burst reentry');
assert.deepEqual(life.slice(lifeAt), [['skillStart', 'deploy'], ['skillEnd', 'recast'], ['skillStart', 'kill']]);
assert.equal(u.findBuff('texas2:swordplay'), null);
approx(sk.timeLeft, sk.duration);
if (index === 2) {
const target = h.spawn('enemy_dummy', { pos: [10, 5] });
h.run(sk.duration + 0.1);
const waves = new Set(dmgBy(h, u, tagged('swordRain')).filter((c) => c.target === target).map((c) => c.t));
assert.equal(waves.size, sk.duration, 'reentrant deployment keeps a full set of rain waves');
const n = dmgBy(h, u, tagged('swordRain')).length;
h.run(2);
assert.equal(dmgBy(h, u, tagged('swordRain')).length, n, 'rain stops at skill end');
assert.ok(target.alive);
}
checkInvariants(h.b);
}
});
test('缄默德克萨斯 module none: no ATK +10 % when alone (EXE-Y default has it)', () => {
const has = (m) => { const h = battle([U('chess_char_4_16_b', 10, 4, null, m)]); h.run(0.5); return !!h.unit('chess_char_4_16_b').findBuff('texas2:module'); };
assert.equal(has(null), true);