diff --git a/docs/META.md b/docs/META.md index 62ae4972..5fcf4c91 100644 --- a/docs/META.md +++ b/docs/META.md @@ -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 | diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 6b105b86..f57acbbd 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -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. 联防(同盟) diff --git a/server/match/Match.js b/server/match/Match.js index 1181bf42..ac4ff3e4 100644 --- a/server/match/Match.js +++ b/server/match/Match.js @@ -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; }); diff --git a/server/sim/content/bands/meta.js b/server/sim/content/bands/meta.js index 69676f5a..bb09fcb9 100644 --- a/server/sim/content/bands/meta.js +++ b/server/sim/content/bands/meta.js @@ -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 }); }, }; diff --git a/test/content/bands.test.js b/test/content/bands.test.js index 6e4b822b..7fb879d7 100644 --- a/test/content/bands.test.js +++ b/test/content/bands.test.js @@ -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'); }); diff --git a/test/match/feedback1-ducklord.test.js b/test/match/feedback1-ducklord.test.js index 92c0ed1e..4c4c3dac 100644 --- a/test/match/feedback1-ducklord.test.js +++ b/test/match/feedback1-ducklord.test.js @@ -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)}`); });