From f14f03a59f92ccdc55647ec3b4f0c9d923b61169 Mon Sep 17 00:00:00 2001 From: sganggs Date: Wed, 7 Oct 2026 02:11:25 +0800 Subject: [PATCH] =?UTF-8?q?feedback5=20CB5:=20in=20a=20boss=20round's=20pr?= =?UTF-8?q?ep=20a=20watcher=20sees=20the=20boss=20field=20=E2=80=94=20the?= =?UTF-8?q?=20scouted=20player's=20half=20(mirrored=20on=20the=20right)=20?= =?UTF-8?q?with=20the=20leader=20standing=20at=20its=20spawn=20=E2=80=94?= =?UTF-8?q?=20instead=20of=20the=20normal=20board=20without=20a=20boss=20(?= =?UTF-8?q?community=20report=20of=202026-10-06,=20item=2055)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduced in the browser (notes i55-repro, an eliminated guest at the boss round): in the Final Assault fight the watched boss field shows the leader (enemy_9013_acstmk at 10,3), but in the boss round's prep the eliminated player saw its own empty normal board (camera 'prep', no leader), and a scout of a teammate was the normal board too, while the players prepare on their half of the boss field with the leader standing there. Match.prepFieldMeta: in a boss round's prep the scout is built on the boss field (kind 'boss', BOSS_RECT, units through finalAssault.js bossFieldPlacement: rows − 7, the right half mirrored with RIGHT ↔ LEFT, the player's side; nextEnemies keeps the leader's spawn tile); game.js frames it with the boss-field prep camera of that side, which shows the leader (render/app.js leaderShown). With item 56 an eliminated viewer gets it at the round start without a tap. After the fix the same run: guest field kind 'boss', camera bossPrep, leader shown. Golden unchanged. Tests: test/match/feedback5-boss-prep-scout.test.js (new, 2). Docs: design/network.md, design/client.md (§15). --- docs/design/client.md | 2 +- docs/design/network.md | 2 +- public/js/screens/game.js | 5 +- server/match/match/views.js | 24 ++++++- test/match/feedback5-boss-prep-scout.test.js | 76 ++++++++++++++++++++ 5 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 test/match/feedback5-boss-prep-scout.test.js diff --git a/docs/design/client.md b/docs/design/client.md index e4587ed4..bad024d8 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -72,6 +72,6 @@ Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN) - One camera model drives both layers: `public/js/render/projection.js` keeps its public API (`worldToScreen`, `screenToWorld`/tile picking, presets, transitions) but derives it from a three.js `PerspectiveCamera` initialised from the official configBlackBoard framing, so Spine units, FX, drag/drop and hit-testing stay aligned with the 3D board. - Must degrade gracefully: when local-client art or WebGL2 features are missing, fall back to the current 2D atlas board; keep 60 fps (static geometry merged into few draw calls; textures mipmapped; DPR-aware). - Implemented (`public/js/render/board3d/*`, `render/app.js`): three.js is downloaded only when the local-art manifest lists the board atlas; the built 3D area, the drawn 2D rows and the lit rect follow `viewKind(kind, {rect})` (the field the camera actually shows: a 'prep' camera on rows ≤ 6 = `bossPrep`); the background plane is extended with mirrored copies and the clear colour matches the fog (no visible edge at any aspect); battle crates live in their own group (survive scene rebuilds) and follow the board mode (`switchableBox`: 3D mesh ⇄ 2D box on a mode switch or a lost WebGL context); quality 'low' drops shadows; `?board=2d|3d` overrides. The direction wheel's range stripes sit between the board canvas and the unit canvas. -- Done: Final Assault / Hidden Core prep takes place on your own half of the boss field (research 09 §1.2): pieces shown at boss-field positions (row − 7; right-hand player mirrored col → 20 − col, RIGHT ↔ LEFT), drop targets and wheel directions mapped back to board coordinates (`prepCamera` → `bossPrep` in public/js/ui/gameLogic.js; covered by the playtest2 browser tests). +- Done: Final Assault / Hidden Core prep takes place on your own half of the boss field (research 09 §1.2): pieces shown at boss-field positions (row − 7; right-hand player mirrored col → 20 − col, RIGHT ↔ LEFT), drop targets and wheel directions mapped back to board coordinates (`prepCamera` → `bossPrep` in public/js/ui/gameLogic.js; covered by the playtest2 browser tests). A scout of such a board (`m.field` prep of a boss round) comes in boss-field coordinates with the player's `side` and is framed the same way, the leader standing at its spawn (0.2.0, community report item 55). --- diff --git a/docs/design/network.md b/docs/design/network.md index 23af0a6c..37fad38e 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -89,7 +89,7 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ { **Solo pause (official `PauseUp` / `ResumeUp`; `Match.setPause`).** C→S `g.pause { on }`: solo matches only (co-op → `WRONG_PHASE 'co-op battles never pause'`), and `on: true` only while a battle runs (COMBAT / FINAL_ASSAULT / HIDDEN_CORE with a live field, not once the Final Assault is ending — else `WRONG_PHASE 'no battle running'`); `on: false` is always accepted. While `m.public.paused` is true everything of the battle is frozen: the field clock (`b.start elapsed`, the boss plausibility budgets and the overtime drain), the authority deadline and the server-run release timers, the boss clock, the HUD `deadline` / `overtimeAt` (shifted by the paused time on resume) and the server-run pacers (FieldRunner / HeadlessPacer skip their intervals); the browser's runner stops its local clock. In solo battles the top bar shows a 暂停 / 继续作战 button (`ui/hud.js PauseButton`, `ui/matchStatus.js pauseAvailable`; `Space` toggles it), a paused overlay offers 继续作战 / 放弃模拟, and the HUD clocks freeze at the pause moment. A disconnect or leave resumes (the server takes the field over — nobody is left to resume it); the battle phase ending drops the pause. -**Spectating (research 09 §3.1 / §6.3).** No live spectating while your own normal battle runs (`g.watch` → `WRONG_PHASE 'own battle running'`; the UI toasts "作战中无法查看队友"). After your own battle ends you see "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (`m.public.fields[].progress`); tapping a teammate's avatar expands **前往查看** → `g.watch` → `b.start` (display replica fast-forwarded to the field clock); the own row shows **返回战场**. Prep: the same flow shows the teammate's board (`m.field` with `prep: true`) and sends it again when that board changes (GitHub #87). The scout is framed by the prep camera in its shop-folded form and renders the teammate’s hand (整备区) and 临时整备区 as units on their rows — the own prep bench’s exact look (user playtest #2 item 1, the GitHub #44 suggestion; the spectator seat's scout view is the same, §23.19's never-a-private-view staying the no-`m.private` rule). While scouting, the 本局信息 drawer shows the watched player's 策略 in place of one's own (`game.js scoutPid` → `enemyDrawer.js InfoTab bandId / bandOwner`, labelled "X 的策略"; user playtest #2 item 2), and the right effects column follows the watched field too: the scout meta carries the player's effects view, a display-replica battle derives it from the spec's `playerEffects` (`battle/observe.js spectateEffects`) — undefined (empty column, never one's own) for 联防 / boss pair fields and server-run watched fields. 联防: every human receives the unite spec (helpers + observers). Final Assault: each human sees its own pair field; the other pair → `BAD_TARGET 'other group hidden'`. In 联防/FA fields the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / "👁 name" / "全景" / "无人在家"); there is **no** view switcher during normal combat. Eliminated players and spectator seats follow a player through every phase reset (0.2.0, community report item 56; the official DeadAutoObDn { obIndex, state, preparation }; the idea of PR #189): the player they last watched with a manual `g.watch` (`Match.watchPref`; a shared field records the `playerId` tapped, else nobody), else the first player still in (seat order, AI seats included) — the round start pushes that player's prep board (`Match._followScout`, adopted by the screen like a 前往查看 tap: `battle/observe.js followedScout`; kept through 机变 and 休整期), each battle starts on that player's field, a resync / a spectator joining in prep gets the board again; they may watch any field. +**Spectating (research 09 §3.1 / §6.3).** No live spectating while your own normal battle runs (`g.watch` → `WRONG_PHASE 'own battle running'`; the UI toasts "作战中无法查看队友"). After your own battle ends you see "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (`m.public.fields[].progress`); tapping a teammate's avatar expands **前往查看** → `g.watch` → `b.start` (display replica fast-forwarded to the field clock); the own row shows **返回战场**. Prep: the same flow shows the teammate's board (`m.field` with `prep: true`) and sends it again when that board changes (GitHub #87). In a boss round's prep that board is built on the boss field — the player's half (rows − 7, the right half mirrored with RIGHT ↔ LEFT, `server/match/finalAssault.js bossFieldPlacement`), `kind: 'boss'`, `side`, the leader in `nextEnemies` at its spawn tile — and framed by the boss-field prep camera of that side, so a watcher sees the leader as the players do (0.2.0, community report item 55). The scout is framed by the prep camera in its shop-folded form and renders the teammate’s hand (整备区) and 临时整备区 as units on their rows — the own prep bench’s exact look (user playtest #2 item 1, the GitHub #44 suggestion; the spectator seat's scout view is the same, §23.19's never-a-private-view staying the no-`m.private` rule). While scouting, the 本局信息 drawer shows the watched player's 策略 in place of one's own (`game.js scoutPid` → `enemyDrawer.js InfoTab bandId / bandOwner`, labelled "X 的策略"; user playtest #2 item 2), and the right effects column follows the watched field too: the scout meta carries the player's effects view, a display-replica battle derives it from the spec's `playerEffects` (`battle/observe.js spectateEffects`) — undefined (empty column, never one's own) for 联防 / boss pair fields and server-run watched fields. 联防: every human receives the unite spec (helpers + observers). Final Assault: each human sees its own pair field; the other pair → `BAD_TARGET 'other group hidden'`. In 联防/FA fields the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / "👁 name" / "全景" / "无人在家"); there is **no** view switcher during normal combat. Eliminated players and spectator seats follow a player through every phase reset (0.2.0, community report item 56; the official DeadAutoObDn { obIndex, state, preparation }; the idea of PR #189): the player they last watched with a manual `g.watch` (`Match.watchPref`; a shared field records the `playerId` tapped, else nobody), else the first player still in (seat order, AI seats included) — the round start pushes that player's prep board (`Match._followScout`, adopted by the screen like a 前往查看 tap: `battle/observe.js followedScout`; kept through 机变 and 休整期), each battle starts on that player's field, a resync / a spectator joining in prep gets the board again; they may watch any field. **Client runner.** `public/js/battle/runner.js` owns the local Battles: fixed-step clock at the battle speed (60 ticks per real second at 2×, at most `max(8, 4·speed)` ticks per frame, bounded fast-forward of ≤ 240 ticks per frame when far behind; a new battle is caught up silently before it is shown), feeds the existing renderer through the same `snapshot()`/`drainEvents()` wire formats every frame (`view.setLocalFeed` shrinks the interpolation buffer to ~2 frames), publishes the field meta (m.field shape + `local`, `battleId`, `players`, `sides`, `speed`) into `store.match.field` and its state into `store.match.battle` (with `leaks: { [fieldId]: counted leaks so far }` of every normal field it simulates and, while the 联防 field is on screen, `uniteLeft` per leaker (§20.6), authoritative or display replica — the settle rule's count, `/sim/spec.js battleProgress`; published in the frame a count changes: the live LP of §17.5), sends `b.progress`/`b.result` when authoritative (results retried once on timeout; a result lost with the socket — the request failed `DISCONNECTED` / `OFFLINE` or timed out twice — is kept and sent again when the session is back online and whenever a `b.start` still names that finished battle `authoritative: true`; the server answers a duplicate `ok` and changes nothing; a server refusal is final), follows `m.public.paused` (solo pause: every local clock stands still, resume shifts it by the paused time), handles `b.pool`/`b.end`, fast-forwards on reconnect/observe, and survives tab backgrounding (a 250 ms interval pump keeps authoritative battles on their clock while hidden; the server deadline + takeover cover extreme cases). Catch-up frames keep the state events and every enemy form fx (`keepsState`); while hidden, the battle on screen holds them for the first frame back (`hold`; past `HELD_MAX` the superseded status / skill toggles are dropped, `compactHeld`, and only a backlog still past it re-enters the view from the field meta), and a battle that ended while hidden delivers them when the tab is shown; a catch-up frame or a hidden step drains its events every `EV_SLICE` (15) ticks, each batch stamped with its own game time (`gt`), so a late form fx reaches the render engine as late as it is; the game screen's pre-entry buffer (`keepEarly`, also while a re-sent field meta re-enters the field on screen) and the render engine's event queue (`render/interp.js isCosmeticEvent`) never drop a form fx either (§21.4). Sim content errors are logged as console warnings (the server logs its own). The sim loader fetches the ten data files (one retry each) and fails when any is missing — a battle on partial data would report a plausible wrong result — so the next `b.start` retries and the server's deadline covers the field meanwhile. `public/js/battle/observe.js` holds the observing rules and labels. diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 4450d41d..a0526d2d 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -400,7 +400,10 @@ function MatchScreen() { const pf = (Array.isArray(pub?.fields) ? pub.fields : []).find((f) => f && f.fieldId === field.fieldId); const members = Array.isArray(pf?.players) ? pf.players : Array.isArray(field.players) ? field.players : []; const sides = field.sides && typeof field.sides === 'object' ? field.sides : null; - const side = sides && sides[myId] ? sides[myId] : members.length > 1 && members.indexOf(myId) === 1 ? 'R' : 'L'; + // a scouted board of a boss round's prep (Match.prepFieldMeta: the boss-field rows, `side` = the scouted player's + // half) is framed by the boss-field prep camera of that half — the leader standing at its spawn (item 55) + const side = field.prep && (field.side === 'L' || field.side === 'R') ? field.side + : sides && sides[myId] ? sides[myId] : members.length > 1 && members.indexOf(myId) === 1 ? 'R' : 'L'; // local simulation (client-side combat) feeds a frame per animation frame: no network jitter buffer view.raw?.setLocalFeed?.({ on: !!field.local, speed: field.speed }); setLayer('ALL'); diff --git a/server/match/match/views.js b/server/match/match/views.js index f14bf08d..f1e0b552 100644 --- a/server/match/match/views.js +++ b/server/match/match/views.js @@ -1,6 +1,7 @@ // server/match/match/views.js — Match methods: the state builders — m.public (publicView with statusOf / fieldOf, the // fields' progress and the teammates' live pendingLp / uniteLeft), the nextEnemies preview of m.private and the prep -// scout's m.field (prepFieldMeta: board, hand and temp as units, the scouted player's effects and coming enemies). +// scout's m.field (prepFieldMeta: board, hand and temp as units, the scouted player's effects and coming enemies; in a +// boss round's prep on the player's half of the boss field). // Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). import { PHASE, GEO } from '../../../shared/constants.js'; @@ -9,6 +10,7 @@ import { bondList, offBondCounts } from '../bondsMeta.js'; import { cardView } from '../choices.js'; import { bountySpawns, previewOf } from '../waves.js'; import { timelineAt } from '../fields.js'; +import { bossFieldPlacement } from '../finalAssault.js'; import { battleProgress } from '../../sim/spec.js'; export class MatchViews { @@ -253,6 +255,24 @@ export class MatchViews { try { nextEnemies = this.nextEnemiesFor(ps); } catch (e) { this.reportError('nextEnemies', e); } // the scouted player's effects column (策略 / 机变 / 悬赏 …), display-ready (user playtest #2: while scouting, the // right column shows the watched player's effects, not one's own) - return { t: 'm.field', fieldId: `n:${ps.playerId}`, kind: 'normal', rect: { ...GEO.NORMAL_RECT }, stageId: this.stageId, units, effects: ps.effectsView(), prep: true, nextEnemies }; + const meta = { t: 'm.field', fieldId: `n:${ps.playerId}`, kind: 'normal', rect: { ...GEO.NORMAL_RECT }, stageId: this.stageId, units, effects: ps.effectsView(), prep: true, nextEnemies }; + // 最终攻势 / 隐秘核心 prep: the pieces stand on the player's half of the boss field — the scout shows them there (rows + // − 7, the right half mirrored with RIGHT ↔ LEFT: finalAssault.js bossFieldPlacement, as the own prep view), with the + // round's leader at its spawn tile (nextEnemies `start`), framed by the boss-field prep camera of the player's side + // (`side`). Until 0.2.0 an eliminated player or a spectator seat scouting a player then saw the normal board and no + // leader at all (community report of 2026-10-06, item 55). + const g = this.fields.length ? null : this.bossGroupOf(ps); + if (g) return { ...meta, kind: 'boss', rect: { ...GEO.BOSS_RECT }, side: g.side, units: units.map((u) => this._onBossHalf(u, g.side)) }; + return meta; + } + + /** A prep UnitInfo (board, bench or temp row) placed on side 'L' | 'R' of the boss field (bossFieldPlacement). */ + _onBossHalf(u, side) { + const p = bossFieldPlacement(side, u.y, u.x, u.dir || 'RIGHT'); + const v = { ...u, x: p.col, y: p.row }; + // a board piece keeps facing the same way relative to the leader (mirrored on the right half); a bench piece has no + // stored facing (it faces right, as the own prep bench draws it) + if (u.dir) { v.dir = p.dir; v.facing = p.dir === 'LEFT' ? -1 : 1; } + return v; } } diff --git a/test/match/feedback5-boss-prep-scout.test.js b/test/match/feedback5-boss-prep-scout.test.js new file mode 100644 index 00000000..b9b60bc1 --- /dev/null +++ b/test/match/feedback5-boss-prep-scout.test.js @@ -0,0 +1,76 @@ +// Community report of 2026-10-06, item 55: 「死亡后观战视角看不到场上的关底boss」. Reproduced in the browser (an eliminated +// guest at the boss round, notes i55-repro): in the Final Assault fight the watched boss field shows the leader, but in +// the boss round's prep the eliminated player saw its own empty normal board — and a scout of a teammate (m.field +// prep) was the normal board too, while the players themselves prepare on their half of the boss field with the leader +// standing at its spawn (render/app.js setLeader, only on the boss-field prep camera). Now the scout of a boss round's +// prep is built on the boss field: the scouted player's pieces on its half (bossFieldPlacement: rows − 7, the right +// half mirrored, RIGHT ↔ LEFT), `side` for the camera, the leader in nextEnemies with its spawn tile; with item 56 an +// eliminated viewer is pushed that scout at the round start without a tap. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { GEO, PHASE } from '../../shared/constants.js'; +import { makeMatch, give, legalTileFor, chessOfTier } from './harness.js'; +import { bossFieldPlacement } from '../../server/match/finalAssault.js'; +import { viewKind, leaderShown } from '../../public/js/render/app/view.js'; +import { leaderStand } from '../../public/js/render/prepfield.js'; + +function bossPrep(seed) { + const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 4, seed, fake: true }).start(); + const m = h.m; + h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { ready: true }); + return h; +} + +test('a scout of a boss round\'s prep is the boss field: the player\'s half (mirrored on the right), its side, the leader\'s spawn', () => { + const h = bossPrep(55); + const m = h.m; + assert.deepEqual(m.bossWaves.map((w) => w.players), [['p_0', 'p_1'], ['p_2', 'p_3']]); + const p1 = h.ps('p_1'); + // a board piece facing RIGHT and one in the hand + for (const k of [...p1.board.keys()]) p1.board.delete(k); + const id = chessOfTier(1)[0]; + const tile = legalTileFor(m, p1, id); + const onBoard = give(m, p1, id, 'board', tile); + onBoard.dir = 'RIGHT'; + give(m, p1, chessOfTier(1)[1], 'hand', 2); + h.ps('p_3').eliminate(13); + assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true }); + const meta = h.lastTo('p_3', 'm.field'); + assert.deepEqual([meta.fieldId, meta.prep, meta.kind, meta.side], ['n:p_1', true, 'boss', 'R']); + assert.deepEqual(meta.rect, GEO.BOSS_RECT); + const u = meta.units.find((x) => x.uid === onBoard.uid); + const want = bossFieldPlacement('R', tile[0], tile[1], 'RIGHT'); + assert.deepEqual([u.y, u.x, u.dir, u.facing], [want.row, want.col, 'LEFT', -1], 'mirrored onto the right half, still facing the leader'); + const bench = meta.units.find((x) => x.y === 0); + assert.ok(bench && bench.x === 20 - 2, `the hand row on the half's bench row (boss row 0, mirrored): ${JSON.stringify(bench)}`); + for (const x of meta.units) assert.ok(x.y >= 0 && x.y <= 5 && x.x >= 10 && x.x <= 20, `on the right half: ${x.y},${x.x}`); + // the leader stands at its spawn tile: the client's leaderStand finds it in the scout's nextEnemies + const stand = leaderStand(meta.nextEnemies, () => null, (x, y) => [[y, x]]); + assert.ok(stand && stand.entry.boss && stand.row >= 0 && stand.row <= 5, `the leader with its spawn tile: ${JSON.stringify(stand?.entry)}`); + assert.deepEqual(meta.nextEnemies, m.nextEnemiesFor(p1), 'the scouted player\'s own preview (its pair\'s wave)'); + // the client frames a scout with that rect on the boss-field prep camera, which shows the leader + assert.equal(viewKind('prep', { rect: meta.rect, side: meta.side }), 'bossPrep'); + assert.equal(leaderShown('bossPrep'), true); + // the left player of the pair: its own half, unmirrored + assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true }); + const left = h.lastTo('p_3', 'm.field'); + assert.deepEqual([left.kind, left.side], ['boss', 'L']); + for (const x of left.units) assert.ok(x.x <= 10, 'the left half'); + m.dispose(); +}); + +test('the eliminated viewer is pushed the boss-field scout at the boss round\'s start (item 56); a normal round\'s scout stays the normal board', () => { + const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 3, seed: 56, fake: true }).start(); + const m = h.m; + h.toPrep(1); + h.ps('p_2').eliminate(1); + h.toPrep(2); + const normal = h.lastTo('p_2', 'm.field'); + assert.deepEqual([normal.fieldId, normal.kind, normal.prep, normal.side], ['n:p_0', 'normal', true, undefined]); + assert.deepEqual(normal.rect, GEO.NORMAL_RECT); + h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { ready: true }); + const meta = h.lastTo('p_2', 'm.field'); + assert.deepEqual([meta.fieldId, meta.kind, meta.prep, meta.side], ['n:p_0', 'boss', true, 'L']); + assert.ok(leaderStand(meta.nextEnemies, () => null, (x, y) => [[y, x]]), 'the leader at its spawn'); + m.dispose(); +});