From 0e3429aa1f22b8bd27bf22aa0109c495a89d76a1 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sun, 4 Oct 2026 19:31:15 +0800 Subject: [PATCH] =?UTF-8?q?feedback3=20WF:=20=E8=81=94=E9=98=B2=20fights?= =?UTF-8?q?=20with=20the=20bond=20layers=20the=20helpers'=20own=20combat?= =?UTF-8?q?=20reached=20(the=20strip's=20count,=20capped=20like=20the=20se?= =?UTF-8?q?ttlement),=20no=20longer=20the=20round-start=20layers;=20the=20?= =?UTF-8?q?settlement=20still=20adds=20those=20gains=20once=20(PRTS=20?= =?UTF-8?q?=E8=81=94=E9=98=B2=E9=98=B6=E6=AE=B5=20=E4=BB=A5=E5=85=B6?= =?UTF-8?q?=E9=98=B5=E5=9C=B0=E5=BD=93=E5=89=8D=E7=9A=84=E7=8A=B6=E6=80=81?= =?UTF-8?q?,=20community=20report=20#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESIGN.md | 8 +++++--- docs/META.md | 7 ++++--- docs/PLAYING.md | 2 +- docs/research/01-core-rules.md | 1 + server/match/PlayerState.js | 12 +++++++++--- server/match/unite.js | 9 ++++++--- test/match/watch-bonds.test.js | 31 +++++++++++++++++++++++++++++++ 7 files changed, 57 insertions(+), 13 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 3ff2df63..1806277c 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -188,7 +188,8 @@ b.on(event, fn, { priority=0, owner } ) / b.off(...) // hook bus (§5.4) items: [itemId...], // equipped items (golden ids when merged) carryState?: { hpPct, sp } | { sp } | { down: true } } ], // unite: an operator keeps its end-of-combat HP ratio + SP, a summon its SP (no running skill); // { down: true } = knocked out at its end: deployed, then forced out (§19.3) - bonds: { [bondId]: { count, active, tier, layers } }, // snapshot from bondsMeta at combat start + bonds: { [bondId]: { count, active, tier, layers } }, // snapshot from bondsMeta at combat start (联防: with the layers the + // helper's own combat reached, PlayerState.bondsView — §20.15) bandId, playerEffects: [ EffectRef ], // active band/choice/team effects with battle parts lpForBoss?: number } ``` @@ -325,7 +326,8 @@ LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed COMBAT (one Battle per alive player, all in parallel, 2×; watchers get snapshots; solo: pausable, §14 Solo pause) UNITE (co-op only, if ≥1 leaked & ≥1 perfect: ≤2 perfect helpers in `unite.js helperOrder` order (research 08 §5 / PRTS 帮助: most units on the field > an active bond > most standing units > seat; the pair ordered by units > active bond > - Σ active layers > standing > seat, the first one on the right-hand field) fight the union of leaks; no layer gains; + Σ active layers > standing > seat, the first one on the right-hand field) fight the union of leaks; no layer gains — with + the layers their own combat reached (the round's pending gains, PRTS "以其阵地当前的状态" [ASSUMED]); a helper's operator knocked out at the end of its own combat enters down — carryState { down: true }, PRTS "上一阶段为退场状态的干员强制退场", its full redeploy timer — confirmed by the user (§19.3)) SETTLE (LP −min(leaks,10) per player (unite survivors to their source); eliminations; layer gains applied; coins; broadcast) @@ -1106,7 +1108,7 @@ In SETTLE it is the field the last battle left on screen. In prep without watchi - The client lays them over the view's counts with `withLiveLayers`: the higher value wins (a count only grows) and never shows above 999. They apply in the battle phases and SETTLE only. - 联防 and boss battles disable gains, so they report none. - **After COMBAT** (`_finishCombat`): until SETTLE, both `m.private bonds` and `m.public players[].bonds` add the finished normal battle's IN_BATTLE gains (`PlayerState.pendingLayerGains` = the result's `layerGains`; `bondsMeta.bondsWithGains`: floored, at most up to the cap, like `settle()`). The strip of a player, and of a teammate watching him in the 联防, therefore keeps the layers his battle reached. -- This is views only: `ps.bonds` / `ps.layers` are untouched. SETTLE clears the pending gains as it adds them (once), and the next round start clears them too. +- `ps.bonds` / `ps.layers` are untouched. SETTLE clears the pending gains as it adds them (once), and the next round start clears them too. Since 0.1.3 the 联防 field fights with the same counts: the helpers' battle input takes its bonds from `bondsView()` (`battleInput({ reached: true })`, PRTS §联防阶段 "将以其阵地当前的状态" [ASSUMED] — it used to carry the round-start layers while the strip showed the reached ones); no other rule reads them. **Data:** - `m.public players[].bonds = ps.alive ? bondList(gd, ps.bondsView()) : []`: every bond with members, layers or an active tier. It is the same list and order as the player's own `m.private bonds`, minus `thresholds` / `countsHand` (the client reads those from bonds.json). diff --git a/docs/META.md b/docs/META.md index 3aa85aac..1960484d 100644 --- a/docs/META.md +++ b/docs/META.md @@ -702,9 +702,10 @@ spawn tile `start`, where the boss-field prep shows it —, + its bounties). Bond layers in the views (DESIGN §20.15): from the end of COMBAT (`_finishCombat`, every normal result in) until SETTLE, `m.private bonds` and `m.public players[].bonds` add the finished battle's IN_BATTLE gains (`PlayerState.pendingLayerGains` = the result's `layerGains`; `bondsMeta.bondsWithGains`: floored, at most up to `BOND_LAYER_CAP`, like the settlement), so -the strip keeps the layers the battle reached through the COMBAT_END pause and the 联防. Views only: `ps.bonds` / -`ps.layers` (rules, the 联防 spec, `activatedLayers`) are untouched; SETTLE clears the pending gains as it adds them to -`ps.layers` (once); the next round start clears them too. +the strip keeps the layers the battle reached through the COMBAT_END pause and the 联防. The 联防 field fights with the +same counts (its input's `bonds` come from `bondsView()`, PlayerState.battleInput `reached`; since 0.1.3). `ps.bonds` / +`ps.layers` (rules, `activatedLayers`) are untouched; SETTLE clears the pending gains as it adds them to `ps.layers` +(once); the next round start clears them too. `m.field` = `{ fieldId, kind, rect, stageId, units, live }`; during prep `g.watch 'n:'` returns a one-shot board view with `prep: true` (scouting a teammate). diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 1bb11995..3f7851d4 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -80,7 +80,7 @@ ## 5. 联防(同盟) -某回合**有人漏怪、又有人完美作战**(全歼且无漏怪)时触发:最多 2 名完美作战的队友带着自己的阵容(与官方相同,只保留作战结束时的生命比例和技力:作战结束时还开着的技能在联防开场是关着的,技力是开技能后剩下的值,通常为 0;召唤物只保留技力;作战结束时已被击倒的干员在原位倒地(阿戈尔的开场吞噬仍把它当作站着的干员计算标记与基础攻击力、阻挡数,之后它照常退场),按官方的「强制退场」以完整的再部署时间重新倒计时,时间到且部署费用足够后照常再部署——联防较短时可能来不及再部署;机变「征召」这类开战时生效的再部署时间减免同样计入),迎击其他玩家漏掉的全部敌人。 +某回合**有人漏怪、又有人完美作战**(全歼且无漏怪)时触发:最多 2 名完美作战的队友带着自己的阵容(与官方相同,只保留作战结束时的生命比例和技力:作战结束时还开着的技能在联防开场是关着的,技力是开技能后剩下的值,通常为 0;召唤物只保留技力;作战结束时已被击倒的干员在原位倒地(阿戈尔的开场吞噬仍把它当作站着的干员计算标记与基础攻击力、阻挡数,之后它照常退场),按官方的「强制退场」以完整的再部署时间重新倒计时,时间到且部署费用足够后照常再部署——联防较短时可能来不及再部署;机变「征召」这类开战时生效的再部署时间减免同样计入),迎击其他玩家漏掉的全部敌人。盟约层数按本回合自己作战结束时达到的层数(即盟约栏显示的层数)计算,联防中不再叠加层数;这些层数在结算时只计入一次。 - 参加者按官方规则挑选:场上单位最多 > 有已激活的盟约 > 未倒下的单位最多 > 座位靠前(与 LP 无关);两人参加时,排在前面的一位(再比较激活盟约的层数总和)在右半场「率先迎敌」。 diff --git a/docs/research/01-core-rules.md b/docs/research/01-core-rules.md index 616aaf10..341571d0 100644 --- a/docs/research/01-core-rules.md +++ b/docs/research/01-core-rules.md @@ -227,6 +227,7 @@ Each player fights their own copy of the round level independently. There is no - **Setup:** 1–2 players who *did* clear perfectly field their board as-is. They face the **union of all enemies the other players leaked**, using the escaped-battle template `level_act1autochess_escaped_multi` (char limit 20). - **Rules:** - Bond stacking is disabled ("该阶段不能叠加层数"). + - The helpers fight "以其阵地当前的状态" (PRTS 帮助): the remake reads that as including the layers their own combat reached that round (the count the strip shows) [ASSUMED]; the settlement adds those gains once. - Bounty kills here pay the killer. - Only perfect-clear teammates can defend (tip). - Community: in 下半 the result shown is the *defended* player's view. diff --git a/server/match/PlayerState.js b/server/match/PlayerState.js index afcdd4ac..7f6a8513 100644 --- a/server/match/PlayerState.js +++ b/server/match/PlayerState.js @@ -1524,14 +1524,20 @@ export class PlayerState { /** * The bond states the views show (m.private bonds, m.public players[].bonds): the computed states plus the pending - * in-battle gains of this round's finished normal battle (bondsMeta.bondsWithGains). Never used by rules. + * in-battle gains of this round's finished normal battle (bondsMeta.bondsWithGains). The 联防 field fights with them too + * (battleInput `reached`); no other rule reads them. */ bondsView() { return bondsWithGains(this.bonds, this.pendingLayerGains); } // ================================================================================================= // battle input - battleInput({ side = 'L', colOffset = 0, carry = null } = {}) { + /** + * `reached`: the bonds carry the layers this round's own combat reached (bondsView: the pending in-battle gains, capped + * like settle()) — the 联防 field (unite.js; PRTS 卫戍协议/帮助 §联防阶段 "将以其阵地当前的状态", [ASSUMED] the current + * state includes those layers, as the strip shows them). The gains stay pending: settle() adds them once. + */ + battleInput({ side = 'L', colOffset = 0, carry = null, reached = false } = {}) { // a terrain change not yet followed by a recompute (a content hook at the prep end) never fields an illegal board this.deployMap(); if (this._legalityStale) this.recompute(); @@ -1557,7 +1563,7 @@ export class PlayerState { side, colOffset, units, - bonds: bondSnapshot(this.bonds), + bonds: bondSnapshot(reached ? this.bondsView() : this.bonds), bandId: this.bandId, playerEffects: this.effects.filter((e) => e.battle !== false).map((e) => ({ id: e.id, key: e.key ?? null, source: e.iconKind ?? null, params: e.params ?? null, counter: e.counter ?? null, data: e.data ?? null, diff --git a/server/match/unite.js b/server/match/unite.js index afe9bcd7..7de3253c 100644 --- a/server/match/unite.js +++ b/server/match/unite.js @@ -18,8 +18,10 @@ // them beside the operators; one off the field at the end enters fresh [ASSUMED]). Enemies = the union of every leaker's // counted leaks (same stats: the SpawnSpec mods travel with the leak), routed on the escaped template (`escaped_single` // for 1 helper, `escaped_multi` for 2): walkers on its `lrsldr` action, flyers on `yokai`, tokens on `gopro_2` / -// `lazerd` (waves.js buildUniteWave); kill bounties keep paying the killer (a helper). No IN_BATTLE layer gains. Time -// limit = the round's combat limit. +// `lazerd` (waves.js buildUniteWave); kill bounties keep paying the killer (a helper). No IN_BATTLE layer gains ("该阶段 +// 不能叠加层数"); the helpers' bonds carry the layers their own combat reached (PlayerState.battleInput `reached`: the +// round's pending gains, capped like settle() — the strip's count; "以其阵地当前的状态" [ASSUMED] includes them; until 0.1.3 +// the round-start layers), and settle() still adds those gains once. Time limit = the round's combat limit. // LP: an enemy still alive at the end (leaked in the unite battle, or never spawned before the limit) costs its // SOURCE player 1 LP; each player's round loss = min(lpCap, survivors attributed to them + leaks that could not // re-enter) — the same 10 cap as a normal round. @@ -122,7 +124,8 @@ export function uniteBattleOpts(m, plan, timeLimit) { } // 2 helpers: the first one meets the enemies first on the right-hand field (escaped_multi enters at col 18) const colOffset = plan.helpers.length > 1 && i === 0 ? 8 : 0; - const input = ps.battleInput({ side: 'L', colOffset, carry }); + // the layers its own combat reached (bondsView, the round's pending gains; PRTS "以其阵地当前的状态") — see header + const input = ps.battleInput({ side: 'L', colOffset, carry, reached: true }); const ev = { input, kind: 'unite', round: m.round, spawns: wave.spawns }; m.dispatch(ps, 'onBattleStart', ev); return ev.input && typeof ev.input === 'object' ? ev.input : input; diff --git a/test/match/watch-bonds.test.js b/test/match/watch-bonds.test.js index 570f9bd7..f867f115 100644 --- a/test/match/watch-bonds.test.js +++ b/test/match/watch-bonds.test.js @@ -21,6 +21,7 @@ import { createStore, initialState } from '../../public/js/store.js'; import * as specMod from '../../server/sim/spec.js'; import { DataSource } from '../../server/sim/simdata.js'; import { DATA, makeMatch, checkInvariants } from './harness.js'; +import { FakeBattle } from './fakeBattle.js'; const row = (h, pid) => h.m.publicView().players.find((p) => p.playerId === pid); const entry = (list, id) => (list || []).find((b) => b.bondId === id) || null; @@ -122,6 +123,36 @@ test('the 联防 phase keeps showing the gains (a teammate watching the helper s m.dispose(); }); +test('the 联防 field fights with the layers the helper\'s own combat reached (PRTS 联防阶段 "以其阵地当前的状态"), capped like the settlement, which still adds them once', () => { + // p_0 perfect with gains, p_1 leaks: 联防 with p_0 as the helper. Until 0.1.3 the 联防 input carried the round-start + // layers while the strip showed the reached ones (community report #24: 不屈 at 200+ layers "sometimes not triggering") + const h = makeMatch({ + mode: 'coop', humans: 2, seed: 95, fake: true, + script: (b) => (b.round === 1 && b.kind === 'normal' ? { layerGains: { p_0: { indomShip: 20, yanShip: 9 } }, leaks: { p_1: 2 } } : {}), + }).start(); + const m = h.m; + h.toPrep(1); + const a = h.ps('p_0'); + a.layers.indomShip = 230; + a.layers.yanShip = 995; + a.recompute(); + const first = FakeBattle.instances.length; + const input = (kind, round) => FakeBattle.instances.slice(first).find((x) => x.kind === kind && x.round === round)?.opts.players.find((p) => p.playerId === 'p_0'); + h.drive(() => m.phase === PHASE.UNITE || m.phase === PHASE.SETTLE); + assert.equal(m.phase, PHASE.UNITE, 'p_1 leaked, p_0 was perfect: 联防'); + assert.deepEqual([input('normal', 1).bonds.indomShip.layers, input('normal', 1).bonds.yanShip.layers], [230, 995], 'the own combat: the round-start layers'); + const u = input('unite', 1); + assert.equal(u.bonds.indomShip.layers, 250, '联防: 230 + the 20 reached in the own combat'); + assert.equal(u.bonds.yanShip.layers, BOND_LAYER_CAP, '995 + 9 is capped at 999, as the settlement caps it'); + assert.equal(u.bonds.indomShip.layers, entry(a.privateView().bonds, 'indomShip').layers, 'the count the strip shows'); + assert.deepEqual([a.layers.indomShip, a.bonds.indomShip.layers], [230, 230], 'nothing persistent yet'); + h.drive(() => m.phase === PHASE.COMBAT && m.round === 2); + assert.deepEqual([a.layers.indomShip, a.layers.yanShip], [250, BOND_LAYER_CAP], 'settled once (not 270)'); + assert.equal(input('normal', 2).bonds.indomShip.layers, 250, 'the next combat starts from 250'); + checkInvariants(m); + m.dispose(); +}); + // ---- the browser runner's live layers --------------------------------------------------------------------------------- const DS = new DataSource(DATA, null);