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:
1 parent
f1e7f7da1a
commit
e96d2bae17
6 files changed
+83
-40
No files matched your search
+1
-1
@@ -246,7 +246,7 @@ Every handler method is `(ctx, ev)`; `ev` is shared by all handlers of one dispa
|
||||
| `onMerge` | chess or item merge | `{ kind, piece, baseId|itemId, consumed:[uid], area? }` — a chess merge's `area` is where its elite went: 'board' (a consumed copy's tile) \| 'hand' \| 'temp' |
|
||||
| `onLevelUp` | shop level up | `{ level, price }` |
|
||||
| `onSpend` | a payment's action is complete (buy / refresh / levelUp / reward / effect) | `{ amount, reason, total }` (`total` = funds spent this match) |
|
||||
| `onBattleStart` | a battle input is built (normal / unite / boss / hidden); also for the stats preview (`g.unitStats`, DESIGN §18.5) | `{ input: PlayerBattleInput, kind, round, spawns?, preview? }` — mutate/replace `ev.input`. With `ev.preview` true (no `ev.spawns`) a handler must NOT change match state or draw match rng: the preview only reads the input |
|
||||
| `onBattleStart` | a battle input is built (normal / unite / boss / hidden); also for the stats preview (`g.unitStats`, DESIGN §18.5) | `{ input: PlayerBattleInput, kind, round, spawns?, routes?, side?, preview? }` — mutate/replace `ev.input`; `ev.spawns` (the field's spawn list, shared by a boss field's players) may be edited in place. Boss / hidden fields also carry the wave's `routes` and the player's `side` ('L' / 'R' on a pair field — its half is the routes ending left / right of the middle column — null on a one-player field), so a handler can edit only the player's own enemies (鸭爵's swap). With `ev.preview` true (no `ev.spawns`) a handler must NOT change match state or draw match rng: the preview only reads the input |
|
||||
| `onBattleResult` | SETTLE (normal rounds) and after boss fields | `{ result, lpLoss, perfect, unite?|boss? }` |
|
||||
| `onChoicePick` | a 机变 card was applied (the card's own `choice:` handler runs first, then every source observes) | `{ card, family, picker, forTeammate }` |
|
||||
| `onEquip` | `g.equip` (item handler only) | `{ item, target, golden, consumed, keep, error }` — see §2.5 |
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@
|
||||
- **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。
|
||||
- **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。
|
||||
- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。
|
||||
- **策略「鸭爵」(“神秘顾客”)**:从第 5 回合起,你和队友的每场作战(**最终攻势和隐秘核心也算**,联防不另外替换)有 0–2 名地面敌人(不含领袖及其部件、悬赏敌人)被替换为特殊版本的鸭爵、高普尼克、流泪小子或圆仔,出现在被替换敌人的时间和路线上;击倒者获得 1 资金(联防中的队友也算),漏进保护目标点照常扣 1 点目标生命值。鸭爵和圆仔**不攻击、无法被阻挡**:鸭爵受到伤害后移动速度 +300%;圆仔始终面向我方干员较多的一侧,来自正面的物理 / 法术伤害 −80%。流泪小子隐匿(被阻挡时才能被选中),攻击使目标晕眩 3.5 秒;高普尼克免疫晕眩、冻结和浮空。
|
||||
- **策略「鸭爵」(“神秘顾客”)**:从第 5 回合起,你和队友的每场作战(**最终攻势和隐秘核心也算**,联防不另外替换)有 0–2 名地面敌人(不含领袖及其部件、悬赏敌人)被替换为特殊版本的鸭爵、高普尼克、流泪小子或圆仔,出现在被替换敌人的时间和路线上(两人同场的领袖战里,各自只替换走向自己保护目标点的敌人);击倒者获得 1 资金(联防中的队友也算),漏进保护目标点照常扣 1 点目标生命值。鸭爵和圆仔**不攻击、无法被阻挡**:鸭爵受到伤害后移动速度 +300%;圆仔始终面向我方干员较多的一侧,来自正面的物理 / 法术伤害 −80%。流泪小子隐匿(被阻挡时才能被选中),攻击使目标晕眩 3.5 秒;高普尼克免疫晕眩、冻结和浮空。
|
||||
- 右上角 🔍▶▶ 可以查看本回合即将来袭的敌人(预览区),左上角 🔍 查看本局信息(禁用盟约、策略)。
|
||||
|
||||
## 5. 联防(同盟)
|
||||
|
||||
@@ -2825,7 +2825,8 @@ export class Match {
|
||||
const inputs = g.map((ps, j) => {
|
||||
const input = ps.battleInput({ side: j === 0 ? 'L' : 'R', colOffset: j === 0 ? 0 : 8 });
|
||||
input.lpForBoss = this.teamLp;
|
||||
const ev = { input, kind: hidden ? 'hidden' : 'boss', round: this.round, spawns };
|
||||
// `side` + `routes`: the player's half of a pair field (spawn-list edits for one player, e.g. 鸭爵's swap)
|
||||
const ev = { input, kind: hidden ? 'hidden' : 'boss', round: this.round, spawns, routes: wave.routes, side: g.length > 1 ? (j === 0 ? 'L' : 'R') : null };
|
||||
this.dispatch(ps, 'onBattleStart', ev);
|
||||
return ev.input && typeof ev.input === 'object' ? ev.input : input;
|
||||
});
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
// up_shop_add_special_goods {count,choice,pool} 凯瑟琳 every level-up: pick 1 of `count` items of pool (free)
|
||||
// coin_carry_over {capital,interest,max} 坎诺特 leftover ≥ capital at round start → +min(max, ⌊left/capital⌋×interest) income
|
||||
// round_start_all_player_change_enemy_2 鸭爵 global: every alive player's battle from `round` (own + teammates;
|
||||
// normal, Final Assault, Hidden Core): 0–2 enemies → enemylist
|
||||
// normal, Final Assault, Hidden Core — on the player's half of a
|
||||
// pair field): 0–2 ground enemies → enemylist
|
||||
// round_start_activate_char_chess_effect_in_board 铃兰 round start: 获得时 traits of the right-most (then bottom-most) board op with one
|
||||
// first_sell_char_chess_exchange_char_chess_in_shop 巫恋 first sale of a normal op per round: swapped with a random shop chess (no funds)
|
||||
// round_start_gain_char_chess_in_shop_every_n_round 松桐 round % n = 0: a random shop chess for free (its slot empties)
|
||||
@@ -29,6 +30,7 @@
|
||||
// preparation_start_add_special_goods_every_n_round 娜仁图亚 round % n = 0: pick choice_cnt of refresh_cnt items of pool (free)
|
||||
// "进入休整期时" / "回合开始时" = onRoundStart (research 01 §5 step 6).
|
||||
|
||||
import { GEO } from '../../../../shared/constants.js';
|
||||
import { buffsOf, num, bandRecord, gameData } from '../support/index.js';
|
||||
import { metaBonds } from '../support/meta.js';
|
||||
import * as garrisons from '../garrisons.js';
|
||||
@@ -354,37 +356,56 @@ K.preparation_start_add_special_goods_every_n_round = (ps) => ({
|
||||
// player or a teammate: from round `round`, in the normal battle, the Final Assault and the Hidden Core (PRTS 卫戍协议:盟约
|
||||
// 下半/PRTS盟约记录 鸭爵 备注 "每回合将有0~2名敌人被替换为上述敌人之一…且在最终回合和隐秘核心回合中仍然生效"; the 联防 phase
|
||||
// replaces nothing: its enemies are leaks, which keep their bounty). min..max (0–2, uniform [ASSUMED]) of the player's
|
||||
// ground enemies from the `minweight`–`maxweight` share of the wave (time order) [ASSUMED reading of the two weights]
|
||||
// — any wave enemy but the leader, its parts, bounties, earlier swaps and uncounted units: normal or elite, as the
|
||||
// Final Assault's escorts are all elites; flyers stay [ASSUMED: the four are ground units and a FLY action's route is
|
||||
// a FLY route] — become a random enemylist enemy (the act2 `_2` versions) at the replaced unit's time and route, worth
|
||||
// DUCK_COINS to its killer (bounty, also in 联防). A boss field's two players edit the field's one list in turn, so each
|
||||
// gets its own 0–2. The originals never exist, so none of their death / kill / leak effects happen. Reaching the
|
||||
// protection point costs 1 LP ("但进入保护目标点将减少1点目标生命值": the enemies' data lpr, tools/build-data.mjs).
|
||||
// ground enemies from the `minweight`–`maxweight` share of the player's enemies (time order) [ASSUMED reading of the two
|
||||
// weights] — any wave enemy but the leader, its parts, bounties, earlier swaps and uncounted units. Elites are eligible
|
||||
// too [ASSUMED: the text names no rank; one rule for every battle; about two thirds of the leader rounds' escorts in that
|
||||
// share are elites, so a NORMAL-only rule would leave most Final Assault halves without a candidate — ≈ 80 % instead of
|
||||
// ≈ 36 % of the R14 pair-field halves over 40 seeds]. Flyers are never swapped [ASSUMED: the four are ground units and a
|
||||
// FLY action's route is a FLY route]. A swapped unit becomes a random enemylist enemy (the act2 `_2` versions) at the
|
||||
// replaced unit's time and route, worth DUCK_COINS to its killer (bounty, also in 联防). A pair boss field has one spawn
|
||||
// list that its two players' handlers edit in turn; each player's 0–2 come from the enemies whose route ends on that
|
||||
// player's half (`ev.side` + `ev.routes`, the half rule of the field's bounties, server/match/waves.js routeByMotion), the
|
||||
// share taken over that half [ASSUMED: "你和队友遭遇的敌人" — the enemies heading for the player's own protection point;
|
||||
// each official client simulates its own battle, docs/research/08 appendix A]. The originals never exist, so none of
|
||||
// their death / kill / leak effects happen. Reaching the protection point costs 1 LP ("但进入保护目标点将减少1点目标生命值":
|
||||
// the enemies' data lpr, tools/build-data.mjs).
|
||||
|
||||
export const DUCK_BAND = 'band_ducklord';
|
||||
/** "击倒这些敌人者获得1资金奖励" (the blackboard's `count` is 1 too). */
|
||||
export const DUCK_COINS = 1;
|
||||
/** Battle kinds the swap applies to (PRTS: the Final Assault and the Hidden Core too). */
|
||||
const DUCK_KINDS = new Set(['normal', 'boss', 'hidden']);
|
||||
/** Boss-field column between the left ('L') and the right ('R') player's half (the goals are at cols 2–3 / 17). */
|
||||
const BOSS_MID_COL = (GEO.BOSS_RECT.c0 + GEO.BOSS_RECT.c1) / 2;
|
||||
|
||||
function duckParams() {
|
||||
for (const b of buffsOf(bandRecord(DUCK_BAND))) if (b.key === 'round_start_all_player_change_enemy_2') return b.p;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Rewrite `spawns` in place (exported for tests). Returns the replaced spawn specs. */
|
||||
export function duckReplace(ctx, spawns, p, ownerPlayerId) {
|
||||
/**
|
||||
* Rewrite `spawns` in place (exported for tests). Returns the replaced spawn specs. `side` ('L' | 'R', a pair boss
|
||||
* field) limits the swap to the spawns whose route (`routes[routeIndex]`) ends on that half of the field.
|
||||
*/
|
||||
export function duckReplace(ctx, spawns, p, ownerPlayerId, { routes = null, side = null } = {}) {
|
||||
const gd = ctx.gd;
|
||||
const ducks = list(p.enemylist).filter((k) => gd.enemy(k));
|
||||
if (!ducks.length || !Array.isArray(spawns)) return [];
|
||||
const lo = Math.max(0, int(p.min, 0)), hi = Math.max(lo, int(p.max, lo));
|
||||
const want = lo + ctx.rng.int(hi - lo + 1);
|
||||
if (want <= 0) return [];
|
||||
// one entry per enemy (split multi-count specs), in spawn-time order
|
||||
const half = side === 'L' || side === 'R';
|
||||
const onHalf = (s) => {
|
||||
if (!half) return true;
|
||||
const r = Array.isArray(routes) ? routes[s.routeIndex] : null;
|
||||
const c = r && Array.isArray(r.end) ? Number(r.end[1]) : NaN;
|
||||
return side === 'R' ? c > BOSS_MID_COL : c < BOSS_MID_COL;
|
||||
};
|
||||
// one entry per enemy of the player (split multi-count specs), in spawn-time order
|
||||
const flat = [];
|
||||
for (let i = 0; i < spawns.length; i++) {
|
||||
const s = spawns[i];
|
||||
if (!s || !onHalf(s)) continue;
|
||||
const cnt = Math.max(1, int(s.count, 1));
|
||||
for (let k = 0; k < cnt; k++) flat.push({ i, k, t: num(s.time, 0) + k * num(s.interval, 0) });
|
||||
}
|
||||
@@ -439,7 +460,7 @@ const duckGlobal = {
|
||||
if (!p || ctx.round < int(p.round, 1)) return;
|
||||
const held = ctx.bandId() === DUCK_BAND || ctx.teammates().some((t) => t.bandId() === DUCK_BAND);
|
||||
if (!held) return;
|
||||
duckReplace(ctx, ev.spawns, p, ctx.playerId);
|
||||
duckReplace(ctx, ev.spawns, p, ctx.playerId, { routes: ev.routes, side: ev.side });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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)}`);
|
||||
});
|
||||
Reference in new issue
Block a user