feedback5: merge W5AA — community reports of 2026-10-06: the 孤岛风云 prisoners leave the gate confined (grey) and change model as their prefabs do, the 4th attack hits freed; a bounty enemy that dies with no operator behind it pays in 联防 too (the helper whose half it fell on); 忍冬's trigger and 枯朽之种's bounty were already fixed (pinned by tests); DESIGN §25.14 after round 18's §25.13; data/enemies.json regenerated offline on the merged code (round 18's model quirks + the prisoners' clips); golden: vs feedback5 exactly W5AA's moves (26 battles, solo-ABYSS-2, coop2-NORMAL-3, coop3-ABYSS-5, solo-HARD-9-boosted), vs W5AA exactly round 18's boss-pool moves

This commit is contained in:
sganggs committed 2026-10-06 22:35:10 +08:00
commit 714af994fc
26 files changed
+1081 -366

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
// test/sim/feedback5-bounty-payee.test.js — community report of 2026-10-06 「被源石地板烫死的悬赏没给赏金」. The bounty pays
// whenever the body dies (the owner's decision, relayed 2026-10-06; 0.1.1 "a bounty coin is paid once, at the final
// death"; 0.1.3 §23.25 "Killing the body pays once"); PRTS 卫戍协议:盟约 决策: "该敌人于对应玩家所属区域倒下时,使相应玩家获得
// 额外资金". Battle._bountyPayee: an operator of a player in the battle that dealt the blow pays its player; any other
// death pays the card's owner when that player fights there — on its own field a 活性源石 death always paid (pinned) —
// and in 联防, where the owner is the leaker (unite.js planUnite) and fights elsewhere, the helper whose half it fell on.
// Until 0.2.0 that last case fell back to the leaker, who has no per-player entry in the 联防 battle: the coins were
// dropped (until 0.1.3 the 联防 field was the round's map — 战场#04's 活性源石 burned leaked bounty enemies for nothing).
// Run: node --test test/sim/feedback5-bounty-payee.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
const coins = (h) => {
const out = {};
for (const e of h.eventsOf('bounty')) out[e[1]] = (out[e[1]] || 0) + e[2];
return out;
};
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
const helper = (playerId, seat, side, colOffset, units = []) => ({ playerId, seat, side, colOffset, units, bonds: {}, layers: {} });
// a 联防 field: helpers A (left half) and B (right half), a bounty enemy the leaker L sent in (planUnite's shape)
function unite(players, pos) {
return makeBattle({
kind: 'unite', content: 'none', autoFinish: false, timeLimit: 60, players,
defs: { enemies: { enemy_b: enemyRec({ key: 'enemy_b', hp: 100, speed: 0 }), enemy_x: enemyRec({ key: 'enemy_x', hp: 100, speed: 0 }) } },
enemies: [{ key: 'enemy_b', pos, sourcePlayerId: 'L', mods: { bountyId: 'card1' }, bounty: { coins: 2, ownerPlayerId: 'L' }, tag: 'bounty' }],
});
}
const target = (h) => h.b.enemies.find((e) => e.defId === 'enemy_b');
test('own field (战场#04): a bounty enemy the 活性源石 alone burns to death pays its owner — as before', () => {
const h = makeBattle({
stageId: 'act1autochess_m04', autoFinish: false, timeLimit: 60,
defs: { enemies: { enemy_b: enemyRec({ key: 'enemy_b', hp: 100, speed: 0.5 }) } },
enemies: [{ key: 'enemy_b', route: { motion: 'WALK', start: [10, 7], end: [9, 2], checkpoints: [] }, mods: { bountyId: 'card1' }, bounty: { coins: 2, ownerPlayerId: 'p1' }, tag: 'bounty', ownerPlayerId: 'p1' }],
});
assert.ok(h.runUntil(() => !h.b.enemies.some((e) => e.alive) && h.b.time > 1, 30));
assert.deepEqual(h.eventsOf('die').map((d) => d[2]), ['killed'], 'it died, no leak');
assert.deepEqual(coins(h), { p1: 2 });
assert.equal(h.result().perPlayer.p1.coins, 2);
done(h);
});
test('联防: a leaked bounty enemy that dies with no operator behind it pays the helper of the half it fell on', () => {
const ways = [
['killed by nobody', (h, e) => h.b.kill(e, null)],
['无来源 terrain-tagged damage', (h, e) => h.b.dealDamage(null, e, { amount: 500, type: 'true', canDodge: false, tags: ['terrain'] })],
['another enemy\'s hit', (h, e) => h.b.dealDamage(h.spawn('enemy_x', { pos: [10, 3] }), e, { amount: 500, type: 'true' })],
['its own HP loss', (h, e) => h.b.loseHp(e, 500, { source: e })],
];
for (const [label, how] of ways) {
// one helper: the whole field is hers
const h1 = unite([helper('A', 0, 'L', 0)], [10, 6]);
h1.step();
how(h1, target(h1));
assert.ok(!target(h1).alive, label);
assert.deepEqual(coins(h1), { A: 2 }, `one helper, ${label}`);
assert.equal(h1.result().perPlayer.A.coins, 2);
done(h1);
// two helpers: by the half it fell on (the right-hand helper's half from column 11)
for (const [col, who] of [[5, 'A'], [15, 'B']]) {
const h2 = unite([helper('A', 0, 'L', 0), helper('B', 1, 'R', 8)], [10, col]);
h2.step();
how(h2, target(h2));
assert.deepEqual(coins(h2), { [who]: 2 }, `two helpers, column ${col}, ${label}`);
done(h2);
}
}
});
test('联防: a helper\'s operator that knocks it out still takes the coins, whatever half it fell on', () => {
const GUN = chessRec({ id: 't_gun', profession: 'SNIPER', projectile: 'none', stats: { atk: 50, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: [[0, 0]], skill: null });
const h = makeBattle({
kind: 'unite', content: 'none', autoFinish: false, timeLimit: 60,
players: [helper('A', 0, 'L', 0, [{ uid: 1, kind: 'chess', chessId: 't_gun', row: 10, col: 4 }]), helper('B', 1, 'R', 8)],
defs: { chess: { t_gun: GUN }, enemies: { enemy_b: enemyRec({ key: 'enemy_b', hp: 100, speed: 0 }) } },
enemies: [{ key: 'enemy_b', pos: [10, 15], sourcePlayerId: 'L', mods: { bountyId: 'card1' }, bounty: { coins: 2, ownerPlayerId: 'L' }, tag: 'bounty' }],
});
h.step();
const gun = h.b.allies().find((u) => u.ownerId === 'A');
assert.ok(gun, 'A\'s operator');
h.b.dealDamage(gun, target(h), { amount: 500, type: 'phys' });
assert.deepEqual(coins(h), { A: 2 }, 'the killer\'s player, on B\'s half');
done(h);
});
test('Final Assault pair field: the card\'s owner fights there, so a death nobody caused pays the owner on either half', () => {
const h = makeBattle({
kind: 'boss', content: 'none', autoFinish: false, timeLimit: 60,
players: [helper('A', 0, 'L', 0), helper('B', 1, 'R', 8)],
defs: { enemies: { enemy_b: enemyRec({ key: 'enemy_b', hp: 100, speed: 0 }) } },
enemies: [{ key: 'enemy_b', pos: [3, 15], ownerPlayerId: 'A', mods: { bountyId: 'card1' }, bounty: { coins: 2, ownerPlayerId: 'A' }, tag: 'bounty' }],
});
h.step();
h.b.kill(target(h), null);
assert.deepEqual(coins(h), { A: 2 });
done(h);
});
+75
View File
@@ -0,0 +1,75 @@
// test/sim/feedback5-community-pins.test.js — two community reports of 2026-10-06 that this tree already answers, pinned
// (DESIGN §25.14; they pass on the parent commit — nothing changed for them):
// 「敌人在忍冬技能范围内没有开技能,在攻击范围才开」 — 0.1.4 cast her default S3 隐狐之艺 on its official DEFAULT row, i.e.
// only with an enemy in her 1-tile attack range; 0.2.0's ACTIVE_RANGE (the owner's decision of 2026-10-05, §25.5)
// casts it with an enemy inside its running range 2-2. S2 坠刃拷问 casts on anything in its 技能范围 3-12
// (SKILL_RANGE), S1 小施惩戒 (AUTO, no larger range) on her attack.
// 「悬赏敌人枯朽萃聚使徒召唤的枯朽之种被击杀也会获得赏金」 — the seeds come from spawnChildren, which drops the card's
// bountyId / bountyCoins (0.1.3 §23.25, GitHub #67 / #89-2); killing them never paid on the own field, and since 0.1.3
// a leaked seed no longer re-enters 联防 carrying the card (≤ 0.1.2 every one paid it again there).
// Run: node --test test/sim/feedback5-community-pins.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';
import { planUnite } from '../../server/match/unite.js';
const ds = getDefaultSource();
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
const dummy = (key) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0 });
test('忍冬: S3 casts with an enemy in its 2-2 running range (not only in her attack range), S2 on its 3-12, S1 on her attack', () => {
const casts = (id, skillId, pos) => {
const index = ds.rawChess(id).skills.find((s) => s.skillId === skillId).index;
const h = makeBattle({ seed: 3, autoFinish: false, timeLimit: 60, defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, skillIndex: index }] });
h.step();
const u = h.unit(id);
u.skill.gainSp(999, 'test');
h.spawn('e', { pos });
h.run(3);
done(h);
return u.skill.activations;
};
for (const id of ['chess_char_3_18_a', 'chess_char_3_18_b']) {
assert.deepEqual(ds.rawChess(id).rangeGrid, [[0, 0], [0, 1]], `${id}: her own range is 1 tile ahead`);
assert.ok(casts(id, 'skchr_vulpis_3', [10, 6]) >= 1, `${id} S3: an enemy 2 tiles ahead`);
assert.equal(casts(id, 'skchr_vulpis_3', [10, 7]), 0, `${id} S3: not 3 tiles ahead`);
for (const pos of [[10, 6], [10, 7], [9, 5]]) assert.equal(casts(id, 'skchr_vulpis_2', pos), 1, `${id} S2 (3-12): ${pos}`);
assert.equal(casts(id, 'skchr_vulpis_1', [10, 6]), 0, `${id} S1: not out of her attack range`);
assert.ok(casts(id, 'skchr_vulpis_1', [10, 5]) >= 1, `${id} S1: on her attack`);
}
});
test('枯朽萃聚使徒 as a bounty: its 枯朽之种 carry no bounty and pay nothing; the body pays once; in 联防 only its own leak carries the card', () => {
const card = { id: 'enemyeffect_10_8', card: { payout: 'kill', coin: 2, enemyKey: 'enemy_1321_wdarft' } };
const h = makeBattle({
autoFinish: false, timeLimit: 200,
enemies: [{ key: 'enemy_1321_wdarft', route: 2, mods: { hpMul: 1, bountyId: card.id }, bounty: { coins: 2, ownerPlayerId: 'p1' }, tag: 'bounty', ownerPlayerId: 'p1' }],
});
assert.ok(h.runUntil(() => h.b.enemies.some((e) => e.alive && e.defId === 'enemy_1269_nhfly'), 30), 'BornBugs cast');
const seeds = h.b.enemies.filter((e) => e.alive && e.defId === 'enemy_1269_nhfly');
for (const s of seeds) {
assert.equal(s.bounty, null);
assert.ok(!s.mods || (s.mods.bountyId == null && s.mods.bountyCoins == null));
h.b.kill(s, null);
}
assert.deepEqual(h.eventsOf('bounty'), [], 'the seeds pay nothing');
h.b.kill(h.enemy('enemy_1321_wdarft'), null);
assert.deepEqual(h.eventsOf('bounty'), [['bounty', 'p1', 2]], 'the body pays once');
done(h);
// 联防: the 使徒 and its seeds leak (no operator); only the 使徒 re-enters with the card's bounty
const h2 = makeBattle({
autoFinish: true, timeLimit: 200,
enemies: [{ key: 'enemy_1321_wdarft', route: 2, mods: { hpMul: 1, bountyId: card.id }, bounty: { coins: 2, ownerPlayerId: 'p1' }, tag: 'bounty', ownerPlayerId: 'p1' }],
});
h2.runToEnd(200);
const r = h2.result().perPlayer.p1;
assert.ok(r.leaked.filter((l) => l.enemyKey === 'enemy_1269_nhfly').length >= 3, 'seeds leaked');
const seat = (id, s, o = {}) => ({ playerId: id, seat: s, deployCount: o.deployCount ?? 1, bonds: {}, layers: {}, board: new Map(), bounties: o.bounties ?? [] });
const leaker = seat('p1', 1, { bounties: [card] });
const helperSeat = seat('H', 0, { deployCount: 3 });
const m = { isSolo: false, alivePlayers: () => [leaker, helperSeat], gd: { unite: { maxHelpers: 2 }, enemy: (k) => ({ key: k }) } };
const plan = planUnite(m, new Map([['p1', r], ['H', { perfect: true, leaked: [], unitsEnd: [] }]]));
assert.deepEqual(plan.leaked.filter((l) => l.bounty).map((l) => [l.enemyKey, l.bounty.coins]), [['enemy_1321_wdarft', 2]]);
});