feedback1 WE: 鸭爵 swaps stay on the player's half of a pair leader field

- A pair Final Assault / Hidden Core field has one spawn list that both
  players' handlers edit; each player's 0-2 swaps now come only from the
  enemies whose route ends on that player's half (the half rule of the
  field's bounties). Match passes the wave's routes and the player's side
  ('L' / 'R', null on a one-player field) in the boss / hidden
  onBattleStart event; docs/META.md documents the two fields.
- The comment justifying elite eligibility was wrong (the leader rounds'
  escorts are a mix, about two thirds elites, not all elites); reworded
  with the measured numbers and kept [ASSUMED].
- The pair-field test now drives the real match path (Final Assault, then
  the Hidden Core it unlocks) instead of dispatching by hand; a unit check
  covers the half filter.
This commit is contained in:
sganggs committed 2026-10-03 02:25:47 +08:00
1 parent f1e7f7da1a
commit e96d2bae17
6 files changed
+83 -40

No files matched your search

+10
View File
@@ -376,6 +376,16 @@ test('鸭爵 “神秘顾客”: from R5, 0–2 ground enemies in the last 40 %
const out = duckReplace(ctx, spawns, { ...p.bb, ...p.bbStr }, 'p_0');
assert.equal(out.length, 2);
assert.equal(spawns.reduce((n, sp) => n + (sp.count || 1), 0), 10);
// a pair boss field (`side` + `routes`): only the enemies heading for the player's half (route end left / right of
// the middle column), the 60–99 % share taken over that half — here the left half's enemies all come first
const routes = [{ motion: 'WALK', end: [1, 3] }, { motion: 'WALK', end: [1, 17] }];
for (const [side, route, times] of [['L', 0, [6, 7]], ['R', 1, [16, 17]], [null, 1, [12, 13]]]) {
const field = Array.from({ length: 20 }, (_, i) => ({ time: i, enemyKey: normalKey, routeIndex: i < 10 ? 0 : 1, count: 1 }));
const got = duckReplace(ctx, field, { ...p.bb, ...p.bbStr }, 'p_0', { routes, side });
assert.deepEqual(got.map((d) => d.time), times, `side ${side}`);
for (const d of got) assert.equal(d.routeIndex, route);
assert.equal(field.length, 20);
}
cover('band_ducklord');
});
+36 -25
View File
@@ -11,7 +11,6 @@ import { PHASE } from '../../shared/constants.js';
import { DATA, makeMatch, give, legalTileFor } from './harness.js';
import { FakeBattle } from './fakeBattle.js';
import { Battle } from '../../server/sim/Battle.js';
import { buildBossWave } from '../../server/match/waves.js';
const BAND = 'band_ducklord';
const PARAMS = DATA.bands[BAND].buffs.find((b) => b.key === 'round_start_all_player_change_enemy_2');
@@ -149,31 +148,43 @@ test('#7 the swap still happens in the Final Assault (escorts, never the leader
assert.ok(lpChecked);
});
test('#7 the Hidden Core and pair boss fields: each player of the field gets 0–2 swaps among the escorts', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, seed: 7201, fake: true }).start();
const m = h.m;
h.toPrep(1);
h.ps('p_0').bandId = BAND;
h.ps('p_1').bandId = 'band_bldsk';
const seen = { boss: 0, hidden: 0 };
for (const [kind, round] of [['boss', m.gd.bossRound], ['hidden', m.gd.hiddenRound]]) {
for (let k = 0; k < 40; k++) {
m.round = round;
const wave = buildBossWave(m.gd, m.rngWaves, m.factions, round, { bossId: kind === 'hidden' ? m.hiddenBossId : m.bossId, solo: false });
const spawns = wave.spawns.map((s) => ({ ...s, mods: s.mods ? { ...s.mods } : undefined }));
const before = spawns.reduce((n, s) => n + (s.count || 1), 0);
for (const [j, ps] of [h.ps('p_0'), h.ps('p_1')].entries()) {
const ev = { input: ps.battleInput({ side: j ? 'R' : 'L', colOffset: j ? 8 : 0 }), kind, round, spawns };
m.dispatch(ps, 'onBattleStart', ev);
test('#7 pair fields of the Final Assault and the Hidden Core (real match path): each player\'s 0–2 swaps land on their own half', () => {
const seen = { boss: { L: 0, R: 0 }, hidden: { L: 0, R: 0 } };
const done = () => Object.values(seen).every((k) => k.L > 0 && k.R > 0);
for (let seed = 1; seed <= 8 && !done(); seed++) {
// 2 humans + 2 bots → two pair fields; the bosses fall at once; Σ layers > 1200 opens the Hidden Core
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, bots: 2, seed: 7200 + seed, fake: true, script: (b) => (b.kind === 'boss' || b.kind === 'hidden' ? { bossDps: 1e9 } : {}) }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { band: BAND });
assert.equal(m.round, m.gd.bossRound);
assert.ok([...m.players.values()].some((p) => p.bandId === BAND), 'someone holds 鸭爵');
for (const p of m.players.values()) { p.bondCountBonus.yanShip = 3; p.layers.yanShip = 601; p.recompute(); }
for (const [kind, phase] of [['boss', PHASE.FINAL_ASSAULT], ['hidden', PHASE.HIDDEN_CORE]]) {
h.drive(() => m.phase === phase, { band: BAND });
assert.equal(m.phase, phase, `${kind} reached`);
const fields = FakeBattle.instances.filter((b) => b.kind === kind);
assert.equal(fields.length, 2, `${kind}: two pair fields`);
for (const f of fields) {
assert.equal(f.players.length, 2);
const wave = m.bossWaves.find((w) => w.players.join() === f.players.join()).wave;
const sum = (list) => list.reduce((n, sp) => n + (sp.count || 1), 0);
assert.equal(sum(f.opts.spawns.filter((sp) => sp.tag !== 'bounty')), sum(wave.spawns), `${kind}: same enemy count`);
assert.ok(f.opts.spawns.some((sp) => sp.tag === 'boss'), 'the leader stays');
const ducks = f.opts.spawns.filter((sp) => DUCKS.includes(sp.enemyKey));
for (const d of ducks) {
assert.equal(d.tag, 'duck');
const j = f.players.indexOf(d.bounty.ownerPlayerId);
assert.ok(j >= 0, 'owned by a player of the field');
assert.equal(d.bounty.coins, 1);
// its route heads for the owner's protection point (goals at cols 2–3 left / 17 right)
const end = f.opts.routes[d.routeIndex].end[1];
assert.ok(j === 0 ? end < 10 : end > 10, `${kind}: ${d.bounty.ownerPlayerId}'s swap on route ${d.routeIndex} ends at col ${end}`);
seen[kind][j === 0 ? 'L' : 'R']++;
}
for (const pid of f.players) assert.ok(ducks.filter((d) => d.bounty.ownerPlayerId === pid).length <= PARAMS.bb.max, `${kind}: ≤ 2 for ${pid}`);
}
const ducks = spawns.filter((s) => DUCKS.includes(s.enemyKey));
assert.ok(ducks.length <= 2 * PARAMS.bb.max, `${kind}: ${ducks.length}`);
assert.equal(spawns.reduce((n, s) => n + (s.count || 1), 0), before, 'same enemy count');
assert.ok(spawns.filter((s) => s.tag === 'boss').length >= 1);
for (const d of ducks) assert.ok(['p_0', 'p_1'].includes(d.bounty.ownerPlayerId));
seen[kind] += ducks.length;
}
m.dispose();
}
assert.ok(seen.boss > 0 && seen.hidden > 0, JSON.stringify(seen));
m.dispose();
assert.ok(done(), `swaps on both halves of both leader rounds: ${JSON.stringify(seen)}`);
});