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

+157 -5
View File
@@ -71,6 +71,28 @@ test('迅捷: member skill end +12 SP (p=1 at high L), ≥40 layers every operat
assert.equal(off.s_x, 0);
});
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 } }) } },
units: [{ chessId: 't_deploy', row: 10, col: 4 }],
bonds: { swiftShip: bond(1, 1000), raidShip: bond(1) }, captureNoisy: true,
kits: { t_deploy: () => ({ skill: {
kind: 'duration', activateOnDeploy: true, duration: 2, spCost: 0, spType: 'none', trigger: 'NEVER',
} }) },
});
h.b.start();
const u = h.unit('t_deploy'), seq = u.deploySeq;
h.run(5);
assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['duration']);
assert.ok(h.hooksOf('spGain').some((c) => c.unit === u), 'swift end handler attempted SP gain');
assert.equal(u.skill.sp, 0);
assert.equal(u.skill.charges, 0);
assert.equal(u.skill.ready, false);
assert.equal(u.skill.activations, 1);
assert.equal(u.deploySeq, seq, 'skill end alone does not meet raid readiness');
checkInvariants(h.b);
});
test('迅捷 / 不屈 proc chances: p = min(1, base + per·L) at each layer count', () => {
const sw = bondBb('swiftShip'), ind = bondBb('indomShip');
close(procChance(sw, 0), 0.20);
@@ -83,7 +105,7 @@ test('迅捷 / 不屈 proc chances: p = min(1, base + per·L) at each layer coun
close(procChance(ind, 1000), 1, 'capped');
});
test('灵巧: aura on members + 4 neighbours (once), 8 tiles at ≥40 layers, follows death and relocation', () => {
test('灵巧: aura on members + 4 neighbours (once), 8 tiles at ≥40 layers, survives death and follows relocation', () => {
const defs = { chess: { k_1: op('k_1', ['skillfulShip']), k_2: op('k_2', ['skillfulShip']), k_n: op('k_n', []), k_d: op('k_d', []), k_f: op('k_f', []) } };
const h = makeBattle({
defs, bonds: { skillfulShip: bond(1, 5) },
@@ -107,7 +129,117 @@ test('灵巧: aura on members + 4 neighbours (once), 8 tiles at ≥40 layers, fo
assert.equal(a('k_n'), 100, 'relocated out of the aura');
h.b.dealDamage(null, h.unit('k_1'), { amount: 1e9, type: 'true' });
h.step(2);
assert.equal(a('k_d'), 100, 'aura source died');
assert.equal(a('k_d'), 150, 'downed aura source still buffs neighbours');
checkInvariants(h.b);
});
for (const layers of [5, 40]) {
test(`灵巧: downed sources keep one aura at ${layers} layers, update live layers and restore it on redeploy`, () => {
const defs = { chess: {
k_1: op('k_1', ['skillfulShip']), k_2: op('k_2', ['skillfulShip']),
k_n: op('k_n', []), k_d: op('k_d', []),
} };
const h = makeBattle({
defs, bonds: { skillfulShip: bond(1, layers) },
units: [
{ chessId: 'k_1', row: 10, col: 4 }, { chessId: 'k_2', row: 10, col: 6 },
{ chessId: 'k_n', row: 10, col: 5 }, { chessId: 'k_d', row: 11, col: 5 },
],
});
h.step();
const first = h.unit('k_1'), second = h.unit('k_2'), neighbour = h.unit('k_n'), diagonal = h.unit('k_d');
assert.equal(neighbour.s.aspd, 110 + layers, 'overlapping sources buff once');
h.b.kill(first);
assert.ok(h.b.isDown(first));
assert.equal(neighbour.s.aspd, 110 + layers, 'no attack speed drop on death');
h.b.kill(second);
assert.ok(h.b.isDown(second));
h.run(1);
assert.equal(neighbour.s.aspd, 110 + layers, 'all sources down: polling keeps the aura');
assert.equal(diagonal.s.aspd, layers >= 40 ? 110 + layers : 100, '4 / 8 tile range still applies');
assert.equal(neighbour.buffs.filter((b) => b.key === 'bond:skillfulShip').length, 1);
gainLayers(h.b, { playerId: 'p1', bonds: 'skillfulShip', n: 40 - layers + 2 });
h.step(2);
assert.equal(neighbour.s.aspd, 152, 'downed sources use live layer values');
assert.equal(diagonal.s.aspd, 152, 'downed sources widen their aura at 40 layers');
h.b.kill(neighbour);
assert.ok(h.b.redeploy(neighbour, { free: true }));
assert.equal(neighbour.s.aspd, 152, 'recipient redeployed next to a downed source');
assert.ok(h.b.redeploy(first, { free: true }));
assert.equal(first.s.aspd, 152, 'source regains its own bonus on redeploy');
assert.equal(neighbour.s.aspd, 152, 'redeploy does not stack the aura');
h.b.retreat(first);
assert.equal(neighbour.s.aspd, 152, 'remaining downed source still covers the recipient');
assert.ok(h.b.redeploy(second, { free: true }));
h.b.retreat(second);
assert.equal(neighbour.s.aspd, 100, 'withdrawn sources no longer provide an aura');
checkInvariants(h.b);
});
}
test('灵巧: a downed source provides its aura around the body tile when it returns home after death', () => {
const defs = { chess: {
k_1: op('k_1', ['skillfulShip']), k_2: op('k_2', ['skillfulShip']),
k_home: op('k_home', []), k_away: op('k_away', []), k_tile: op('k_tile', []),
} };
const h = makeBattle({
defs, bonds: { skillfulShip: bond(1, 5) },
units: [
{ chessId: 'k_1', row: 10, col: 4 }, { chessId: 'k_2', row: 12, col: 3 },
{ chessId: 'k_home', row: 10, col: 5 }, { chessId: 'k_away', row: 10, col: 8 },
{ chessId: 'k_tile', row: 10, col: 7 },
],
});
h.step();
h.b.retreat(h.unit('k_tile'), { permanent: true });
assert.ok(h.b.relocate(h.unit('k_1'), 10, 7));
h.run(0.4);
assert.equal(h.unit('k_home').s.aspd, 100);
assert.equal(h.unit('k_away').s.aspd, 115);
h.b.kill(h.unit('k_1'));
assert.deepEqual(h.unit('k_1').body, [10, 4]);
assert.equal(h.unit('k_home').s.aspd, 115, 'aura follows the downed body back home');
assert.equal(h.unit('k_away').s.aspd, 100, 'last living tile no longer provides an aura');
checkInvariants(h.b);
});
for (const reason of ['retreat', 'forcedExit', 'merchant']) {
test(`灵巧: ${reason} leaves a down model without preserving the killed-source aura`, () => {
const h = makeBattle({
defs: { chess: { k_1: op('k_1', ['skillfulShip']), k_n: op('k_n', []) } },
bonds: { skillfulShip: bond(1, 5) },
units: [{ chessId: 'k_1', row: 10, col: 4 }, { chessId: 'k_n', row: 10, col: 5 }],
});
h.step();
const source = h.unit('k_1'), recipient = h.unit('k_n');
assert.equal(recipient.s.aspd, 115);
h.b.retreat(source, { reason });
assert.ok(h.b.isDown(source), 'a down model is not proof of being killed');
assert.equal(source.removeReason, reason);
assert.equal(recipient.s.aspd, 100, 'aura stops immediately on retreat');
assert.ok(h.b.redeploy(source, { free: true }));
assert.equal(recipient.s.aspd, 115, 'the living source restores its aura');
h.b.kill(source);
assert.equal(recipient.s.aspd, 115, 'a subsequent real kill keeps the aura');
checkInvariants(h.b);
});
}
test('灵巧: unite carry-down forcedExit starts without an aura and restores it on redeploy', () => {
const h = makeBattle({
kind: 'unite',
defs: { chess: { k_1: op('k_1', ['skillfulShip']), k_n: op('k_n', []) } },
bonds: { skillfulShip: bond(1, 40) },
units: [{ chessId: 'k_1', row: 10, col: 4, carryState: { down: true } }, { chessId: 'k_n', row: 11, col: 5 }],
});
h.step();
const source = h.unit('k_1'), recipient = h.unit('k_n');
assert.equal(source.removeReason, 'forcedExit');
assert.ok(h.b.isDown(source));
assert.equal(recipient.s.aspd, 100, 'forcedExit is not a kill in this battle');
assert.ok(h.b.redeploy(source, { free: true }));
assert.equal(recipient.s.aspd, 150, 'the redeployed source covers all eight neighbours');
checkInvariants(h.b);
});
@@ -414,8 +546,9 @@ test('突袭 #51: the most advanced enemy out of reach → the jump goes to the
// GitHub #49: skills.js `ready` is false for every passive skill, so a passive 突袭 member (缄默德克萨斯, 宴 …) only jumped on
// the 10 s idle trigger; the reporter's footage of the official game shows 缄默德克萨斯 jumping within her passive's 10 s.
// raidPoll (only there) counts a passive skill that is on as 技能就绪; the landing rule (#51) still keeps it from hopping.
test('突袭 #49: a passive skill that is on counts as 技能就绪 — 缄默德克萨斯 jumps within a few seconds, then stays while her enemy is in range', () => {
// raidPoll (only there) counts a passive skill that is on as 技能就绪, and — since #109 made her skills deploy-timed
// duration skills — a deploy-timed skill while its window runs; the landing rule (#51) still keeps it from hopping.
test('突袭 #49: a passive skill that is on, or a deploy-timed skill while it runs (#109), counts as 技能就绪 — 缄默德克萨斯 jumps within a few seconds, then stays while her enemy is in range', () => {
const tex = 'chess_char_4_16_a';
const h = makeBattle({
defs: { enemies: DUMMY }, bonds: { raidShip: bond(1, 10) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 8] }],
@@ -423,7 +556,7 @@ test('突袭 #49: a passive skill that is on counts as 技能就绪 — 缄默
});
h.step();
const u = h.unit(tex), e = h.enemy('enemy_addon_dummy');
assert.deepEqual([u.skill.kind, u.skill.ready, u.skill.active], ['passive', false, true], 'her passive: on, and the global `ready` unchanged');
assert.deepEqual([u.skill.kind, !!u.skill.spec.activateOnDeploy, u.skill.ready, u.skill.active], ['duration', true, false, true], 'her deploy-timed skill (#109): on, and the global `ready` false');
assert.ok(!inRange(u, e), 'nothing in her range');
assert.ok(h.runUntil(() => raidJumps(h).length > 0, 3), 'jumped within 3 s (no 10 s idle wait)');
assert.ok(h.b.time < bondBb('raidShip').no_attack_duration - 5, `at ${h.b.time.toFixed(2)} s`);
@@ -433,6 +566,25 @@ test('突袭 #49: a passive skill that is on counts as 技能就绪 — 缄默
checkInvariants(h.b);
});
test('突袭 #49 with #109: a deploy-timed skill counts only while its window runs — once it has ended, only the idle trigger is left', () => {
const tex = 'chess_char_4_16_a';
const idle = bondBb('raidShip').no_attack_duration;
const h = makeBattle({
defs: { enemies: DUMMY }, bonds: { raidShip: bond(1, 10) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 8] }],
units: [{ chessId: tex, row: 12, col: 3 }], hooks: ['deploy', 'death'], autoFinish: false, timeLimit: 60,
});
h.step();
const u = h.unit(tex);
u.skill.end('duration'); // her window over before the first poll, as after its duration
assert.deepEqual([u.skill.active, u.skill.ready], [false, false], 'ended: no charge until the next deployment');
h.run(3);
assert.equal(raidJumps(h).length, 0, 'no 技能就绪 jump once the window has ended');
assert.ok(h.runUntil(() => raidJumps(h).length > 0, idle + 1), 'the idle trigger');
assert.ok(h.b.time >= idle - 1e-6, `at ${h.b.time.toFixed(2)} s`);
assert.ok(u.skill.active, 'the landing is a new deployment: a new window');
checkInvariants(h.b);
});
test('突袭 #49: non-passive members unchanged — a charged skill jumps at once, an uncharged one waits for the idle time; a passive one jumps at once', () => {
const idle = bondBb('raidShip').no_attack_duration;
const defs = {