From bce182703b1331e14fa442c51907c952461a568d Mon Sep 17 00:00:00 2001 From: Luo Xingyu <158294294+2321Robin@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:47:33 +0800 Subject: [PATCH] =?UTF-8?q?Watching=20a=20teammate=20shows=20their=20hand,?= =?UTF-8?q?=20=E4=B8=B4=E6=97=B6=E6=95=B4=E5=A4=87=E5=8C=BA,=20equipment,?= =?UTF-8?q?=20strategy=20and=20effects=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prep scout of another player's board carries their 整备区 hand and 临时整备区 as units on the bench rows (like the own prep bench), the equipped items of their operators, their strategy in the drawer and their effects column (the official mode shows a teammate's hand too). The scout signature includes the hand / temp slots, so a moved card refreshes the watcher. Closes #128. Contributed by @2321Robin. --- docs/DESIGN.md | 8 +- public/js/battle/observe.js | 30 ++++++ public/js/battle/runner.js | 3 + public/js/render/app.js | 23 ++++- public/js/screens/game.js | 15 ++- public/js/ui/detailPanel.js | 2 + public/js/ui/enemyDrawer.js | 16 ++-- public/js/ui/gameLogic.js | 7 ++ server/match/Match.js | 50 +++++++++- test/match/prep-bench.test.js | 138 +++++++++++++++++++++++++++ test/match/prep-watch.test.js | 6 +- test/ui/playtest-item-detail.test.js | 85 +++++++++++++++++ test/ui/prep-bench.test.js | 51 ++++++++++ test/ui/spectate-effects.test.js | 61 ++++++++++++ test/ui/teammate-band.test.js | 46 +++++++++ 15 files changed, 521 insertions(+), 20 deletions(-) create mode 100644 test/match/prep-bench.test.js create mode 100644 test/ui/playtest-item-detail.test.js create mode 100644 test/ui/prep-bench.test.js create mode 100644 test/ui/spectate-effects.test.js create mode 100644 test/ui/teammate-band.test.js diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 9ee8f239..c672134c 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -421,14 +421,14 @@ C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · ` S→C: - `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards /* bounty cards carry descRaw?, §20.6 */,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers,harmony? /* 调和's +1 is in count, §21.26 */}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — in 联防 min(lpCapPerRound, uniteLeft) — omitted when 0 and in every other phase (§17.5 live LP) */, uniteLeft? /* 联防: the leaker's enemies still standing — not spawned yet + alive + counted leaks, uncapped, live (§20.6) */}] , fields:[{fieldId, kind, players:[playerId...]}] }` - `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change. -- `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo] }` (the units on the field, knocked-out operators waiting to redeploy included — `Battle.fieldMeta`, §18.3) +- `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo], effects? }` (the units on the field, knocked-out operators waiting to redeploy included — `Battle.fieldMeta`, §18.3). The prep-scouting field (`Match.prepFieldMeta`) puts the scouted player's **hand (整备区) and 临时整备区 into `units` as well**: held pieces as units on the hand row (row 7, col = hand slot) and temp pieces on the temp row (row 8, cols 4..8 = the temp slots; PRTS 帮助 counts the temp area with the hand; a held item is a `kind: 'item'` unit the client draws as its floating plate) — the scout renders exactly like the own prep bench, for every watcher alike, the spectator seat's copy equal to a teammate's (§23.19's "never a private view" stays the no-`m.private` rule); the scout signature `_prepScoutSig` carries the hand / temp slots, so a piece moved to another slot is a change — and carries `effects`: the scouted player's effects view (策略 / 机变 / 悬赏 …), the watched player's effects column ready-made - NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type). - `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem? }` 20 Hz. Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0) - `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12; `extra.form` = the unit's model form from then on (an enemy's mode, a 傀儡师's 替身 §22.11) — `shared/protocol.js fxForm`, state like a spawn: never dropped by the client, §21.4), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]` - `m.toast {kind, text}` · `m.ticker {text, id, type, priority, playerId}` (the strip plays the highest priority first, §21.10) · `m.emote {playerId, id}` · `m.result {...}` - `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}}` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16) as its next battle starts (§18.5) -`UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId?, items?, form? }` (`dir` = the ally's deploy direction; the renderer picks the model (Back for UP, mirrored for LEFT) and the ground wedge from it; `skillIndex` = the ally's equipped skill (§16): the renderer plays that skill's Spine clip (`anims.skills[i]`) and the audio its own ON_SKILL_START sound; `moduleId` = an elite ally's equipped module (uniEquipId | 'none'); `items` = an ally operator's equipped item ids (absent without any; never on enemies or summons): the bond popup lists a 变形同构体 wearer from them and the detail card shows its 同构 chip and 装备 (§21.11); all three also on the prep-scouting `m.field` units (`Match.prepFieldMeta`), so a teammate's unit shows its owner's loadout in the detail card; `form` = the unit's current model form (an enemy's mode §21.4, a 傀儡师's 替身 §22.11; a view built mid-battle starts in it)). +`UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId?, items?, form? }` (`dir` = the ally's deploy direction; the renderer picks the model (Back for UP, mirrored for LEFT) and the ground wedge from it; `skillIndex` = the ally's equipped skill (§16): the renderer plays that skill's Spine clip (`anims.skills[i]`) and the audio its own ON_SKILL_START sound; `moduleId` = an elite ally's equipped module (uniEquipId | 'none'); `items` = an ally operator's equipped item ids (absent without any; never on enemies or summons): the bond popup lists a 变形同构体 wearer from them and the detail card shows its 同构 chip and 装备 (§21.11); all three also on the prep-scouting `m.field` units (`Match.prepFieldMeta`, the held pieces included), so a teammate's unit shows its owner's loadout in the detail card and its equipped items as item pips on the unit (prep surfaces; user playtest #2 item 3); `form` = the unit's current model form (an enemy's mode §21.4, a 傀儡师's 替身 §22.11; a view built mid-battle starts in it)). ### 8.3 Exact view shapes (normative for match ⇄ UI) @@ -561,7 +561,7 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer **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). 联防: 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 start each combat phase on the first field (auto-observe) and 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). 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 start each combat phase on the first field (auto-observe) and 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. @@ -635,7 +635,7 @@ v2.2 answered "a click selected the unit one row lower" with body picking: every - **Live LP (#2)**: in a normal round the top bar's LP tower and the own team row show `lp − min(lpCapPerRound, counted leaks)` as soon as the own battle's enemies enter the blue gate — the number in red followed by a −N plate that pops again on every leak, clamped at 0, with a tooltip (`ui/hud.js liveLp` / `pendingLoss` / `pendingTip`). The count is `ownLeaks(local, server)` = the larger of the local runner's `state().leaks[own field]` (§14) and `m.public players[].pendingLp` (§8.2; the authority's `b.progress` leaks, the field's result, the recorded result in 联防) — both only grow within a round, so a paused display replica never freezes the number. The pending part is dropped as soon as the settled `m.private` lands (its `lp` or `stats.leaks` changed; `Match.flush` sends it before the SETTLE `m.public`), the phase leaves COMBAT / 联防 or the round changes — never subtracted twice. 联防 (**revised by §20.6**, user playtest #6 item 7): a leaker's count is its enemies still standing — the local replica's while the 联防 field is on screen, else m.public `uniteLeft` — shown uncapped as ×N in the capsule and in the leakers' team rows, and the LP tower shows lp − min(10, N), tagged 联防中. Teammates' rows use their `pendingLp` (`teamPanel.js rowLp`); server-run fields (bots, taken-over fields) report none before their result is released. Boss rounds are unchanged (the team LP moves live through `b.pool`). - **Temp row (#3, UI)**: while `m.private temp` holds pieces during the own prep, a dashed red frame surrounds the 临时整备区 row on the board and a label says how many are waiting, what to do and when they go (`underframe.js TempRowNotice` / `tempRowFrame`, placed through `view.tileScreen` — it follows the camera, the mirrored Final Assault prep and the DOM fallback; no pointer events; the label hides while dragging or placing). Its wording follows the server rule (§6.2, `tempRowRule(ready)`): not ready — "放入整备区或战场、配发或使用后才能准备;休整期结束时仍在此处的将被销毁"; ready in PREP (so whatever lies there arrived after Ready) — "已准备就绪后进入的单位保留到下个休整期,届时仍在此处的将被销毁(取消准备则在本休整期结束时销毁)". The 准备就绪 button shows "临时整备区 N 个单位待处理" under it (not only on hover; the effects column moves down a line meanwhile, `.gm.has-readywhy`) and Space toasts the reason (`hud.js TEMP_RULE` / `tempReadyReason`). -- **Detail card order (#8)** (`detailPanel.js CHESS_SECTIONS`): header (portrait, name, tier, class, bonds) → **特质** (the operator's own effect — garrison trigger chip such as 休整期结束时 + description, a compact gold block, visible without scrolling at 1920×1080, 1280×720, iPhone 14 and 640×360) → 特性 (class trait) → stats (a compact 4×2 grid) + attack range → 技能 → 模组 → 装备 → 天赋. On phones ≤ 460 px tall the portrait is shorter and the romanised name is hidden. +- **Detail card order (#8)** (`detailPanel.js CHESS_SECTIONS`): header (portrait, name, tier, class, bonds) → **特质** (the operator's own effect — garrison trigger chip such as 休整期结束时 + description, a compact gold block, visible without scrolling at 1920×1080, 1280×720, iPhone 14 and 640×360) → 特性 (class trait) → stats (a compact 4×2 grid) + attack range → 技能 → 模组 → 装备 → 天赋. On phones ≤ 460 px tall the portrait is shorter and the romanised name is hidden. A teammate's 策略 is not on the card — while scouting, the 本局信息 drawer shows the watched player's band in place of one's own (§14). - **Busy indicators and texts (#9)**: every text of the game (operators, skills, bonds, items, enemies, 特质 …) is static data, `/data/*.json` loaded once per page and never fetched during a match; the "spinners" the player saw were busy indicators of requests in flight next to texts — and the 特质 chip's type icon: it was picked by the trigger, so every <休整期开始时 / 结束时> 特质 showed the spoked 特异化 glyph (it reads like a loading spinner) before its text; the chip now shows the garrison's own official `eventTypeIcon` (`detailPanel.js garrisonTypeIconKey`: crossed swords 作战能力, gold 整备能力, chain link 持续 / 单次叠加, spokes only for 特异化). Now: no `cursor: wait / progress` anywhere; a loading `Button` keeps its icon and shows a thin sweeping bar along its bottom edge after 250 ms (no spinner ring); a 机变 card in flight shows a 选择中 strip until the pick appears in `m.public` (`choiceOverlay.js pickBusy`); the 作战结束,等待队友完成作战 hourglass is still; the hexagon `Spinner` is kept for genuine loading screens only (entering the match, the field view, connecting, guide images, the first 干员调配 load, which says the data loads once per page). `main.js warmGameData` downloads every in-match file (`gameComponents GAME_FILES`) in the background once the player is in a room, and the match screen waits for them; `data.js` retries a transient failure (network error, HTTP 5xx / 408 / 429) after 600 ms and 2 s (`RETRY_DELAYS_MS`) — a 404 or invalid JSON is final. - Tests: `test/ui/playtest3.test.js`, `test/match/runner.test.js` (live leak counts), `test/match/temp.test.js` (§6.2), `test/docs-consistency.test.js`. diff --git a/public/js/battle/observe.js b/public/js/battle/observe.js index fbf88dd2..e7fa93da 100644 --- a/public/js/battle/observe.js +++ b/public/js/battle/observe.js @@ -9,6 +9,7 @@ // * eliminated: anything. import { PHASE } from '../../../shared/constants.js'; +import { data } from '../data.js'; const isObj = (v) => !!v && typeof v === 'object'; const COMBAT = new Set([PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]); @@ -130,3 +131,32 @@ export function layerCamera(field, layer, mySide = 'L') { if (layer === 'L' || layer === 'R') return { rect, side: layer, half: true }; return { rect, side: mySide }; } + +/** + * The watched player's effects column for a battle watched as a display replica (user playtest #2: while spectating, + * the right column shows the spectated player's effects, not one's own). The spec's raw playerEffects (Battle spec: + * `{ id, source = iconKind, counter, … }`) resolved client-side — a band effect through bands.json (its effectId), + * everything else through effects.json; `effectIconUrl`'s per-kind fallbacks cover the missing icon ids. Undefined + * where whose column would be ambiguous (联防 / boss pairs) or there are no effects data (a server-run field's meta + * comes from the server without effects — the column stays empty rather than showing one's own). + */ +export function spectateEffects(spec, members) { + if (!Array.isArray(members) || members.length !== 1 || !isObj(spec)) return undefined; + const p = Array.isArray(spec.players) ? spec.players.find((x) => isObj(x) && x.playerId === members[0]) : null; + if (!p || !Array.isArray(p.playerEffects)) return undefined; + const out = []; + for (const pe of p.playerEffects) { + if (!isObj(pe) || typeof pe.id !== 'string' || !pe.id) continue; + const counter = pe.counter != null ? { counter: pe.counter } : {}; + if (pe.source === 'band') { + const band = data.list('bands').find((b) => b && b.effectId === pe.id); + if (band) { + out.push({ id: pe.id, name: band.effectName || band.name, desc: band.desc || '', iconKind: 'band', iconId: band.iconId || band.bandId, ...counter }); + continue; + } + } + const rec = data.lookup('effects', pe.id); + out.push({ id: pe.id, name: rec?.name || pe.id, desc: rec?.desc || rec?.descRaw || '', iconKind: pe.source || 'choice', iconId: pe.id, ...counter }); + } + return out; +} diff --git a/public/js/battle/runner.js b/public/js/battle/runner.js index aad7db34..585a6e67 100644 --- a/public/js/battle/runner.js +++ b/public/js/battle/runner.js @@ -65,6 +65,7 @@ import { net as appNet } from '../net.js'; import { store as appStore } from '../store.js'; import { unitStatsEntry, fxForm } from '../../../shared/protocol.js'; +import { spectateEffects } from './observe.js'; const TICK = 1 / 30; /** Fast-forward budget per frame (ticks) when far behind. */ @@ -561,6 +562,8 @@ export function createBattleRunner(deps) { const field = { t: 'm.field', ...meta, fieldId: e.fieldId, kind: e.kind, rect: meta.rect ?? e.spec.rect, stageId: meta.stageId ?? e.spec.stageId, live: !e.done, battleId: e.battleId, players: e.members.slice(), local: true, speed: e.speed, + // the watched player's effects column (user playtest #2; undefined for 联防 / boss pairs and server-run fields) + effects: spectateEffects(e.spec, e.members), // which half each player holds (联防: the first helper takes the right half; boss pairs: L / R) sides: Object.fromEntries((e.spec.players || []).filter((p) => p && p.playerId).map((p) => [p.playerId, p.side === 'R' || Number(p.colOffset) >= 8 ? 'R' : 'L'])), }; diff --git a/public/js/render/app.js b/public/js/render/app.js index 2686cb4f..97df1c9d 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -294,6 +294,10 @@ export function renderInfo(u) { // tap hands them to the detail card (a teammate's unit shows its owner's skill / module) skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined, moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined, + // the ally's equipped item ids (UnitInfo.items, DESIGN §16 / §21.11): the detail card needs them for a teammate's + // unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own + // unit takes its items from the piece instead, so only other players' boards ever read this field + items: Array.isArray(u.items) ? u.items.filter((x) => typeof x === 'string') : undefined, }; } @@ -1445,12 +1449,29 @@ export async function createFieldView(host, options = {}) { return info; } + // a hand item on a scouted prep board (UnitInfo kind 'item'): the plate's icon and colour resolve client-side, + // exactly like the own prep bench (pieceInfo) + function scoutItemInfo(info) { + const rec = data.item(info.defId); + const tier = rec?.tier || info.tier || 1; + return { ...info, + icon: assets.itemIcon ? assets.itemIcon(rec ? { trapId: rec.trapId, iconId: rec.iconId } : info.defId) : null, + color: (info.golden || rec?.isGolden) ? 0xffc600 : TIER_COLORS[tier] || TIER_COLORS[1] }; + } + function battleView(id) { let v = views.get(id); if (v) return v; const info = infos.get(id); if (!info || gone.has(id)) return null; - v = info.kind === 'device' ? new DeviceView(ctx, info) : new UnitView(ctx, info, { prep: !!battleMeta?.prep && info.side === 'ally' }); + v = info.kind === 'device' ? new DeviceView(ctx, info) + : info.kind === 'item' ? new ItemView(ctx, scoutItemInfo(info)) + : new UnitView(ctx, info, { prep: !!battleMeta?.prep && info.side === 'ally' }); + // a teammate's operator shows its equipped items like the own prep bench does (item pips; user playtest #2: + // at the unit, not only in the detail card) — prep surfaces only, the battle HUD stays as it is + if (v.setItems && battleMeta?.prep && Array.isArray(info.items) && info.items.length) { + v.setItems(info.items.map((it) => { const r = data.item(it); return assets.itemIcon ? assets.itemIcon(r ? { trapId: r.trapId, iconId: r.iconId } : it) : null; })); + } v.setWorld(info.x, info.y, 0); v._seen = false; v._born = performance.now(); diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 90c7fee4..47a1d602 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -96,7 +96,7 @@ import { snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers, countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason, previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout, - mergeTarget, modeOffBonds, readyFundsPrompt, + mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, } from '../ui/gameLogic.js'; import { toast } from '../ui/toasts.js'; import { BriefingScreen } from './briefing.js'; @@ -234,6 +234,10 @@ function MatchScreen() { const alive = spectator ? false : priv ? priv.alive !== false : meP?.alive !== false; const home = homeFieldId(pub, myId); const watchingOther = !!watching && watching !== home && watching !== ownFieldId(myId); + // watching a teammate's prep board: the 本局信息 drawer shows THEIR 策略 in place of one's own (user playtest #2 item 2) + const scoutPid = watchingOther && field?.prep && typeof field.fieldId === 'string' && field.fieldId.startsWith('n:') ? field.fieldId.slice(2) : null; + const scoutBandId = scoutPid ? ownerBandId(pub, scoutPid) : null; + const scoutBandOwner = scoutPid ? (players.find((p) => p.playerId === scoutPid)?.name || null) : null; const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther; const showShop = !!priv && alive && (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && !watchingOther; const layersDisabled = phase === PHASE.UNITE || isBossPhase(phase); @@ -404,7 +408,9 @@ function MatchScreen() { evBufRef.current.delete(field.fieldId); const earlySnap = snapBufRef.current.get(field.fieldId); snapBufRef.current.delete(field.fieldId); - const kind = field.kind === 'hidden' ? 'boss' : field.kind || 'normal'; + // a scouted prep board frames like the own prep with the shop folded (the bench row included, app.js camRect); + // 'prep' expands the rect's top to the hand row + const kind = field.prep ? 'prep' : (field.kind === 'hidden' ? 'boss' : field.kind || 'normal'); 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; @@ -415,7 +421,7 @@ function MatchScreen() { // a lone player's boss field (solo modes, the odd player of a co-op Final Assault: the `_s` templates route every // enemy to the left objective) is framed on its own half like the ‹ › half view; pairs start on 全景 const lone = kind === 'boss' && members.length === 1; - setCam(kind, lone ? { rect: field.rect, side, half: true } : { rect: field.rect, side }); + setCam(kind, lone ? { rect: field.rect, side, half: true } : { rect: field.rect, side, ...(field.prep ? { shop: false } : {}) }); audio.setFieldUnits(field.units); if (early && early.length) { // replay state-bearing events only (a burst of stale hit sparks / damage numbers would look wrong) @@ -1246,7 +1252,7 @@ function MatchScreen() { self=${Number.isFinite(priv?.lp) ? { lp: priv.lp, pending: liveLpNow.pending, unite: liveLpNow.unite, left: liveLpNow.left } : null} observe=${cc ? { canObserve: (p) => observeTarget(p, pub, myId, { observing: watchingOther, ownDone: localDone }), observing: watchingOther, onBack: backHome } : null} /> -
<${EffectsList} effects=${priv?.effects} />
+
<${EffectsList} effects=${watchingOther && field ? (field.effects ?? null) : priv?.effects} />
${watchingOther && !combat ? html`
<${GIcon} name="eye" />正在查看 ${watchedName} 的阵地(只读) @@ -1283,6 +1289,7 @@ function MatchScreen() {
${drawer ? html`<${EnemyDrawer} tab=${drawer} onTab=${setDrawer} pub=${pub} priv=${priv} onClose=${() => setDrawer(null)} + bandId=${scoutBandId} bandOwner=${scoutBandOwner} onEnemy=${(k, n) => setDetail({ kind: 'enemy', id: k, count: n })} onChess=${(id) => setDetail({ kind: 'chess', id })} />` : null} ${bondPop ? html`<${BondPopup} bondId=${bondPop.bondId} entry=${bondPop.entry} priv=${bondPop.priv} banned=${pub?.bannedChess || []} owner=${bondPop.name} diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index 61cc1f2c..a72dfab7 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -629,6 +629,8 @@ export function resolveDetail(target, pieces) { const u = target.unit || {}; const own = Number.isInteger(u.uid) ? pieces?.get(u.uid) : null; if (u.side === 'enemy') { const en = data.lookup('enemies', u.defId); return en ? { type: 'enemy', enemy: en, unitId: u.id } : null; } + // a hand item on a scouted prep board (m.field units, kind 'item'): the item's own card + if (u.kind === 'item') { const it = data.lookup('items', u.defId); return it ? { type: 'item', item: it } : null; } const c = data.lookup('chess', u.defId); if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null }; const t = data.lookup('tokens', u.defId); diff --git a/public/js/ui/enemyDrawer.js b/public/js/ui/enemyDrawer.js index 4b058848..c5055374 100644 --- a/public/js/ui/enemyDrawer.js +++ b/public/js/ui/enemyDrawer.js @@ -59,19 +59,20 @@ function EnemiesTab({ pub, priv, onEnemy }) { `; } -function InfoTab({ pub, priv, onChess }) { +function InfoTab({ pub, priv, onChess, bandId = null, bandOwner = null }) { const { bonds, banned, perBond, stateOf } = matchInfoModel(pub, { bonds: data.list('bonds'), chess: (id) => data.lookup('chess', id), mode: data.get('config')?.modes?.[pub?.modeId], }); const disabled = new Set(bonds.filter((b) => stateOf(b.bondId)).map((b) => b.bondId)); - const band = priv?.bandId ? data.lookup('bands', priv.bandId) : null; + // while scouting a teammate's prep board the drawer shows THEIR 策略 in place of one's own (user playtest #2 item 2) + const band = (bandId || priv?.bandId) ? data.lookup('bands', bandId || priv.bandId) : null; const stage = pub?.stageId ? data.lookup('stages', pub.stageId) : null; const withBans = bonds.filter((b) => disabled.has(b.bondId) || (perBond.get(b.bondId) || 0) > 0) .sort((a, b) => (disabled.has(b.bondId) - disabled.has(a.bondId)) || ((perBond.get(b.bondId) || 0) - (perBond.get(a.bondId) || 0))); return html`
${band ? html`
<${BandIcon} bandId=${band.bandId} size="md" /> -
<${MicroLabel} tone="mint">STRATEGY // 我的策略${band.name} ${band.effectName} +
<${MicroLabel} tone="mint">STRATEGY // ${bandOwner ? `${bandOwner} 的策略` : '我的策略'}${band.name} ${band.effectName} <${RichText} text=${band.descRaw || band.desc} class="iband__desc" />
` : null} ${stage ? html`

<${Icon} name="rook" />战场:${stage.name || stage.id}

` : null} @@ -88,14 +89,17 @@ function InfoTab({ pub, priv, onChess }) { } /** - * @param {{ tab:'enemies'|'info', onTab:(t:string)=>void, pub:any, priv:any, onClose:Function, onEnemy:(key:string, count:number)=>void, onChess:(id:string)=>void }} props + * @param {{ tab:'enemies'|'info', onTab:(t:string)=>void, pub:any, priv:any, onClose:Function, onEnemy:(key:string, count:number)=>void, onChess:(id:string)=>void, + * bandId?: string|null, bandOwner?: string|null }} props + * bandId / bandOwner: while scouting a teammate's prep board, the watched player's 策略 (m.public players[].bandId) + * replaces one's own in the 本局信息 tab (game.js scoutBandId; user playtest #2 item 2) */ -export function EnemyDrawer({ tab, onTab, pub, priv, onClose, onEnemy, onChess }) { +export function EnemyDrawer({ tab, onTab, pub, priv, onClose, onEnemy, onChess, bandId = null, bandOwner = null }) { return html``; } diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index 18a877c1..c4316474 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -150,6 +150,13 @@ export function boardTileOf(field, r, c) { return [r - BOSS_ROW_SHIFT, field === 'bossR' ? MAX_COL - c : c]; } +/** The band (策略) a player picked, from m.public.players[].bandId (Match.js marksPublic) — the detail card shows it + * on a teammate's unit (user playtest #2 item 2: watching a teammate revealed nothing about their 策略). */ +export function ownerBandId(pub, ownerId) { + const p = Array.isArray(pub?.players) ? pub.players.find((x) => x && x.playerId === ownerId) : null; + return typeof p?.bandId === 'string' && p.bandId ? p.bandId : null; +} + /** Banner shown when a phase starts: { title, sub?, tone } or null. */ export function phaseBanner(phase, pub) { const r = int(pub?.round, 0); diff --git a/server/match/Match.js b/server/match/Match.js index 5b5ee11c..f7b43932 100644 --- a/server/match/Match.js +++ b/server/match/Match.js @@ -943,7 +943,8 @@ export class Match { return previewOf([...this.wave.spawns, ...bounty]); } - /** UnitInfo list of a player's board (prep scouting). */ + /** UnitInfo list of a player's board and hand (prep scouting): board pieces on their tiles, held pieces on the + * hand row (row 7) — the scout renders like the own prep bench. */ prepFieldMeta(ps) { const units = []; for (const { r, c, piece } of boardOrder(ps.board)) { @@ -963,20 +964,63 @@ export class Match { items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined, }); } + // the hand (整备区) and the 临时整备区 scout exactly like the own prep bench renders them: pieces as units on + // their rows (hand row 7, col = hand slot; temp row 8, cols 4..8 = temp slots; no dir — bench pieces face right), + // items included (the client draws their floating plates). PRTS 帮助 counts the temp area with the hand (review of + // PR #129). Part of the meta for every watcher alike — the spectator seat's copy equals a teammate's + // (test/match/spectator.test.js). User playtest #2 item 1 (GitHub #44). + const benchUnit = (piece, i, y) => { + const rec = piece.kind === 'item' ? this.gd.item(piece.id) : piece.kind === 'token' ? this.gd.token(piece.id) : this.gd.chess(piece.id); + const assets = (rec && rec.assets) || {}; + const lo = piece.kind === 'chess' && rec ? ps.loadoutFor(rec) : null; + units.push({ + id: piece.uid, uid: piece.uid, kind: piece.kind === 'token' ? 'token' : piece.kind === 'item' ? 'item' : 'op', + side: 'ally', ownerId: ps.playerId, defId: piece.id, + name: rec ? rec.name : piece.id, tier: rec && Number.isInteger(rec.tier) ? rec.tier : 1, golden: !!(rec && rec.isGolden), + spine: assets.spine || (rec && rec.charId) || piece.id, avatar: assets.avatar || (rec && rec.charId) || piece.id, + x: i, y, maxHp: rec && rec.stats && Number.isFinite(rec.stats.maxHp) ? rec.stats.maxHp : 1, + skillIndex: lo && Number.isInteger(lo.skillIndex) ? lo.skillIndex : undefined, + moduleId: lo && typeof lo.moduleId === 'string' ? lo.moduleId : undefined, + items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined, + }); + }; + for (let i = 0; i < ps.hand.length; i++) { + if (ps.hand[i]) benchUnit(ps.hand[i], i, GEO.HAND_ROW); + } + for (let i = 0; i < ps.temp.length; i++) { + if (ps.temp[i]) benchUnit(ps.temp[i], GEO.TEMP_C0 + i, GEO.TEMP_ROW); + } // `nextEnemies`: the scouted player's coming enemies — their preview pen shows on the scouting board too (research 09 // §2.2 "Teammates"; render/app.js enterBattle({ prep: true, nextEnemies })) let nextEnemies = []; try { nextEnemies = this.nextEnemiesFor(ps); } catch (e) { this.reportError('nextEnemies', e); } - return { t: 'm.field', fieldId: `n:${ps.playerId}`, kind: 'normal', rect: { ...GEO.NORMAL_RECT }, stageId: this.stageId, units, prep: true, nextEnemies }; + // 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 }; } - /** Board signature of a prep scout view (units only: a shop or funds change is not a board change). */ + /** Board signature of a prep scout view (units and hand: a shop or funds change is not a board change). */ + /** Board signature of a prep scout view (board, hand and temp rows: a shop or funds change is not a board change). + * Hand / temp entries carry their slot — `prepFieldMeta` draws x from it, so a piece moved to another slot is a + * change (review of PR #129). */ _prepScoutSig(ps) { const parts = []; for (const { r, c, piece } of boardOrder(ps.board)) { const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : ''; parts.push(`${piece.uid}:${piece.id}@${r},${c}:${pieceDir(piece)}:${items}`); } + for (let i = 0; i < ps.hand.length; i++) { + const piece = ps.hand[i]; + if (!piece) continue; + const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : ''; + parts.push(`h${i}:${piece.uid}:${piece.id}:${items}`); + } + for (let i = 0; i < ps.temp.length; i++) { + const piece = ps.temp[i]; + if (!piece) continue; + const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : ''; + parts.push(`t${i}:${piece.uid}:${piece.id}:${items}`); + } return parts.join(';'); } diff --git a/test/match/prep-bench.test.js b/test/match/prep-bench.test.js new file mode 100644 index 00000000..ac3f8534 --- /dev/null +++ b/test/match/prep-bench.test.js @@ -0,0 +1,138 @@ +// 观战队友的整备区(游玩记录 #2 item 1「观战队友时看不到队友的未上场的干员」,建议见 GitHub #44 +// 「看不见队友的待战栏位」):prep 侦察的 m.field(`Match.prepFieldMeta`)把被侦察玩家的手牌当作 +// 单位放进手牌行(row 7,col = 手牌槽位,道具是 kind 'item')——客户端按自己整备区的同一渲染路径 +// 画出这一行(app.js battleView / ItemView)。手牌变化(部署 / 购入 / 装备)跟棋盘变化一样触发 +// 推送(`_prepScoutSig`);观战席副本与玩家完全一致(test/match/spectator.test.js)。客户端: +// test/ui/prep-bench.test.js。 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { GEO, PHASE } from '../../shared/constants.js'; +import { makeMatch, give, giveItem, legalTileFor, chessOfTier } from './harness.js'; + +const MELEE = (c) => c.position === 'MELEE' && c.profession === 'TANK'; + +test('#44 prep scout: held pieces are units on the hand row; deploying moves them onto the field', () => { + const S = 's_spec'; + const h = makeMatch({ mode: 'coop', humans: 2, seed: 44, fake: true, spectators: [S] }).start(); + h.toPrep(1); + const m = h.m; + assert.equal(m.phase, PHASE.PREP); + const b = h.ps('p_1'); + assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: false }), { ok: true }); + for (const p of [...b.board.values(), ...b.hand.filter(Boolean), ...b.temp.filter(Boolean)]) { + if (p.kind === 'chess') b.returnCopies(p); + } + b.board.clear(); + b.hand.fill(null); + b.temp.fill(null); + b.recompute(); + const id = chessOfTier(1, MELEE).find((x) => m.pool.has(x)); + const piece = give(m, b, id); // into the hand + assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true }); + assert.deepEqual(m.handle(S, { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true }); + + for (const pid of ['p_0', S]) { + const first = h.lastTo(pid, 'm.field'); + assert.equal(first.prep, true); + const u = first.units.find((x) => x.uid === piece.uid); + assert.ok(u, `${pid}: the held operator is a unit of the scout`); + assert.deepEqual([u.x, u.y], [0, GEO.HAND_ROW], `${pid}: standing on the first hand slot`); + assert.equal(u.kind, 'op'); + assert.equal(u.items, undefined, 'no items equipped yet'); + } + assert.deepEqual(h.lastTo('p_0', 'm.field').effects, h.ps('p_1').effectsView(), 'the scouted effects column rides the meta'); + + // deploying it is a hand AND a board change: one new push with the piece on the field and off the hand row + const [r, c] = legalTileFor(m, b, id); + assert.deepEqual(m.handle('p_1', { t: 'g.move', uid: piece.uid, to: { area: 'board', row: r, col: c }, dir: 'LEFT' }), { ok: true }); + const last = h.lastTo('p_0', 'm.field'); + const u = last.units.find((x) => x.uid === piece.uid); + assert.deepEqual([u.x, u.y], [c, r], 'deployed onto the watched board'); + assert.equal(last.units.some((x) => x.uid === piece.uid && x.y === GEO.HAND_ROW), false, 'and gone from the hand row'); + m.dispose(); +}); + +test('#44 prep scout: a held item is a kind-item unit on the hand row; equipped items ride their operator', () => { + const h = makeMatch({ mode: 'coop', humans: 2, seed: 46, fake: true }).start(); + h.toPrep(1); + const m = h.m; + const b = h.ps('p_1'); + assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: false }), { ok: true }); + for (const p of [...b.board.values(), ...b.hand.filter(Boolean), ...b.temp.filter(Boolean)]) { + if (p.kind === 'chess') b.returnCopies(p); + } + b.board.clear(); + b.hand.fill(null); + b.temp.fill(null); + b.recompute(); + const id = chessOfTier(1, MELEE).find((x) => m.pool.has(x)); + const op = give(m, b, id); + const item = giveItem(m, b, 'chess_item_1_01_e_a'); // 维式重锤, into the hand + assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true }); + const first = h.lastTo('p_0', 'm.field'); + const iu = first.units.find((x) => x.uid === item.uid); + assert.ok(iu, 'the held item is a unit of the scout'); + assert.equal(iu.kind, 'item'); + assert.equal(iu.y, GEO.HAND_ROW); + // equip the item onto the operator (still in the hand): the operator's unit carries the item id + assert.deepEqual(m.handle('p_1', { t: 'g.equip', itemUid: item.uid, targetUid: op.uid }), { ok: true }); + const after = h.lastTo('p_0', 'm.field'); + assert.equal(after.units.some((x) => x.uid === item.uid), false, 'the equipped item left the hand row'); + const ou = after.units.find((x) => x.uid === op.uid); + assert.deepEqual(ou.items, ['chess_item_1_01_e_a'], 'the operator scouts with its equipment'); + m.dispose(); +}); + +test("PR #129 review: moving a hand piece to another slot is a scout change (x follows the slot), and the temp row rides along", () => { + const h = makeMatch({ mode: 'coop', humans: 2, seed: 47, fake: true }).start(); + h.toPrep(1); + const m = h.m; + const b = h.ps('p_1'); + assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: false }), { ok: true }); + for (const p of [...b.board.values(), ...b.hand.filter(Boolean), ...b.temp.filter(Boolean)]) { + if (p.kind === 'chess') b.returnCopies(p); + } + b.board.clear(); + b.hand.fill(null); + b.temp.fill(null); + b.recompute(); + const id = chessOfTier(1, MELEE).find((x) => m.pool.has(x)); + const a = give(m, b, id, 'hand', 0); + const c = give(m, b, id, 'temp', 0); // 临时整备区: PRTS 帮助把它算进手牌区,一起侦察 + assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true }); + + const first = h.lastTo('p_0', 'm.field'); + const tu = first.units.find((x) => x.uid === c.uid); + assert.deepEqual([tu.x, tu.y], [GEO.TEMP_C0, GEO.TEMP_ROW], 'the temp piece scouts on the temp row (first slot, col 4)'); + assert.equal(first.units.find((x) => x.uid === a.uid).x, 0); + + // a hand piece moved to another slot keeps uid and id — the slot must be in the signature for the push to fire + assert.deepEqual(m.handle('p_1', { t: 'g.move', uid: a.uid, to: { area: 'hand', idx: 3 } }), { ok: true }); + const last = h.lastTo('p_0', 'm.field'); + assert.ok(last, 'a new scout push arrived'); + assert.equal(last.units.find((x) => x.uid === a.uid).x, 3, 'the piece stands on its new hand slot'); + m.dispose(); +}); + +test('#44 prep scout: an empty hand adds no units and never an m.private to a spectator seat', () => { + const S = 's_spec'; + const h = makeMatch({ mode: 'coop', humans: 2, seed: 45, fake: true, spectators: [S] }).start(); + h.toPrep(1); + const m = h.m; + const b = h.ps('p_1'); + assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: false }), { ok: true }); + for (const p of [...b.board.values(), ...b.hand.filter(Boolean), ...b.temp.filter(Boolean)]) { + if (p.kind === 'chess') b.returnCopies(p); + } + b.board.clear(); + b.hand.fill(null); + b.temp.fill(null); + b.recompute(); + assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true }); + assert.deepEqual(m.handle(S, { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true }); + const first = h.lastTo('p_0', 'm.field'); + assert.equal(first.units.length, 0, 'nothing held, nothing deployed: no units'); + assert.equal(first.bench, undefined, 'no separate bench field (the hand rides units)'); + assert.equal(h.allTo(S, 'm.private').length, 0, 'still no private state to a scout'); + m.dispose(); +}); diff --git a/test/match/prep-watch.test.js b/test/match/prep-watch.test.js index b16ce2ee..1932072c 100644 --- a/test/match/prep-watch.test.js +++ b/test/match/prep-watch.test.js @@ -1,7 +1,7 @@ // GitHub #87: watching another board during prep follows its moves. A spectator uses the same prep scout. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { PHASE } from '../../shared/constants.js'; +import { GEO, PHASE } from '../../shared/constants.js'; import { makeMatch, give, legalTileFor, chessOfTier } from './harness.js'; const MELEE = (c) => c.position === 'MELEE' && c.profession === 'TANK'; @@ -31,7 +31,9 @@ test('#87 prep watch: seat B moves during prep and seat A\'s view of B changes b const first = h.lastTo(pid, 'm.field'); assert.equal(first.fieldId, 'n:p_1'); assert.equal(first.prep, true); - assert.equal(first.units.some((u) => u.uid === piece.uid), false, `${pid}: the piece is still in the hand`); + const hu = first.units.find((u) => u.uid === piece.uid); + assert.ok(hu, `${pid}: the piece is held — it scouts as a unit on the hand row`); + assert.equal(hu.y, GEO.HAND_ROW, `${pid}: on the bench (row 7), not on the field`); } assert.equal(h.allTo(S, 'm.private').length, 0); const beforeA = h.allTo('p_0', 'm.field').length; diff --git a/test/ui/playtest-item-detail.test.js b/test/ui/playtest-item-detail.test.js new file mode 100644 index 00000000..73f0e3d9 --- /dev/null +++ b/test/ui/playtest-item-detail.test.js @@ -0,0 +1,85 @@ +// 点击 / 观战队友单位时详情卡显示装备(游玩记录 #2:「观战队友时看不到干员的装备」)。DESIGN §16 +// 承诺 "a teammate's unit shows its owner's loadout in the detail card":服务器把 an ally operator 的 +// 装备 id 放进战斗快照与侦察 meta 的 UnitInfo.items(server/sim/snapshot.js、Match.prepFieldMeta), +// detailPanel 的 unit 分支也从 `u.items` 取 `unitItems` —— 但 renderInfo(public/js/render/app.js) +// 构建 info 白名单时把 items 丢了,队友单位点开的卡永远没有 装备 区,变形同构体的「同构」pairing +// chips 对队友单位同样失效。renderInfo 在 app.js 里(模块顶层有 canvas 副作用,node 无法 import), +// 按docs-consistency / feedback1d 的惯例对源码断言;消费端行为已由 morph-bonds.test.js 锁住, +// 这里再走一遍 unit → resolveDetail → ChessDetail 的完整用户可见结果。 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const load = (f) => JSON.parse(readFileSync(path.join(ROOT, 'data', f), 'utf8')); +const chess = load('chess.json'); +const WEARER = 'chess_char_1_01_a'; // 隐现 +const ISO = 'chess_item_6_09_e_a'; // 变形同构体 +const HAMMER = 'chess_item_1_01_e_a'; // 维式重锤 → 维多利亚 + +// ---- renderInfo: the snapshot UnitInfo reaches the client info object --------------------------------------------- + +test('renderInfo passes UnitInfo.items through (the teammate-unit equipment into the detail card)', () => { + const app = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8'); + const start = app.indexOf('export function renderInfo('); + assert.ok(start > 0, 'renderInfo exists'); + const body = app.slice(start, app.indexOf('\n}', start)); + assert.match( + body, + /items: Array\.isArray\(u\.items\) \? u\.items\.filter\(\(x\) => typeof x === 'string'\) : undefined/, + 'the equipped item ids must survive the info whitelist (DESIGN §16: a teammate\'s unit shows its owner\'s loadout)', + ); +}); + +// ---- the user-visible end of the chain: a teammate unit's card with its equipment ---------------------------------- + +globalThis.fetch = async (url) => { + const name = String(url).split('/').pop(); + try { + const body = readFileSync(path.join(ROOT, 'data', name), 'utf8'); + return { ok: true, status: 200, json: async () => JSON.parse(body) }; + } catch { + return { ok: false, status: 404, json: async () => ({}) }; + } +}; +const { data } = await import('../../public/js/data.js'); +await data.loadAll('bonds', 'chess', 'items', 'assets', 'garrisons'); +const { ChessDetail, resolveDetail } = await import('../../public/js/ui/detailPanel.js'); +const { BondChips } = await import('../../public/js/ui/detailPanel.js'); + +/** Every vnode of a preact tree (htm output), depth first. */ +function* walk(v) { + if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; } + if (!v || typeof v !== 'object') return; + yield v; + yield* walk(v.props?.children); +} +const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c); + +test('a teammate unit carrying items opens with a read-only 装备 section and the worn 同构 pairing', () => { + // the info object exactly as the battle view holds it after renderInfo (unit: info in pieceClick) + const unit = { id: 7, uid: null, kind: 'op', side: 'ally', ownerId: 'p2', defId: WEARER, items: [ISO, HAMMER] }; + const d = resolveDetail({ kind: 'unit', unit }, new Map()); + assert.equal(d.type, 'chess'); + assert.deepEqual(d.unitItems, [ISO, HAMMER], 'no own piece for a teammate: the unit\'s items become unitItems'); + + const mine = [{ bondId: 'victoriaShip', count: 3, active: true, tier: 1, layers: 0, thresholds: [3, 6] }]; + const blocks = ChessDetail({ chess: d.chess, piece: null, editable: false, bonds: mine, loadout: null, unitItems: d.unitItems }); + const equip = blocks.find((b) => b?.key === 'equip'); + assert.ok(equip, 'the 装备 section renders without an own piece'); + assert.deepEqual([...walk(equip)].filter((x) => x.props?.itemId).map((x) => x.props.itemId), [ISO, HAMMER]); + const chips = [...walk(blocks)].find((x) => x.type === BondChips); + assert.ok(chips, 'the card has bond chips'); + const vic = [...walk(BondChips(chips.props))].find((x) => hasClass(x, 'dbond') && x.props['data-bond'] === 'victoriaShip'); + assert.ok(vic && hasClass(vic, 'is-granted'), '变形同构体 marks the wearer as a 维多利亚 member on a teammate\'s card too'); +}); + +test('a unit without items (enemies, summons, plain allies) still opens without a 装备 section', () => { + const d = resolveDetail({ kind: 'unit', unit: { id: 8, kind: 'op', side: 'ally', ownerId: 'p2', defId: WEARER } }, new Map()); + assert.equal(d.unitItems, null, 'renderInfo leaves items undefined; resolveDetail hands null on'); + const mine = [{ bondId: 'victoriaShip', count: 3, active: true, tier: 1, layers: 0, thresholds: [3, 6] }]; + const blocks = ChessDetail({ chess: d.chess, piece: null, editable: false, bonds: mine, loadout: null, unitItems: d.unitItems }); + assert.equal(blocks.find((b) => b?.key === 'equip'), undefined, 'no empty 装备 section'); +}); diff --git a/test/ui/prep-bench.test.js b/test/ui/prep-bench.test.js new file mode 100644 index 00000000..4a00e463 --- /dev/null +++ b/test/ui/prep-bench.test.js @@ -0,0 +1,51 @@ +// 观战整备区的客户端(游玩记录 #2 item 1):被侦察玩家的手牌是 m.field(prep:true).units 里手牌行 +// (row 7)上的单位——干员站立、道具 kind 'item'(浮牌),和自己整备区同一渲染路径;观战棋盘用 +// prep 相机(收起商店形态,bench 行进画面)。battleView 为 kind 'item' 建 ItemView(图标/颜色客户端 +// 解析,同 pieceInfo),prep 面上给带装备的队友干员挂 item pips。服务器侧:test/match/prep-bench.test.js。 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const read = (p) => readFileSync(path.join(ROOT, p), 'utf8'); + +globalThis.fetch = async (url) => { + const name = String(url).split('/').pop(); + try { + const body = readFileSync(path.join(ROOT, 'data', name), 'utf8'); + return { ok: true, status: 200, json: async () => JSON.parse(body) }; + } catch { + return { ok: false, status: 404, json: async () => ({}) }; + } +}; +const { data } = await import('../../public/js/data.js'); +await data.loadAll('bonds', 'chess', 'items', 'assets'); +const { resolveDetail } = await import('../../public/js/ui/detailPanel.js'); + +describe('the scouted prep board renders the hand like the own bench', () => { + test('the game screen frames it with the prep camera in its shop-folded form (source)', () => { + const game = read('public/js/screens/game.js'); + assert.match(game, /field\.prep \? 'prep' : \(field\.kind === 'hidden' \? 'boss' : field\.kind \|\| 'normal'\)/); + assert.match(game, /field\.prep \? \{ shop: false \} : \{\}/); + assert.match(game, /const scoutPid = watchingOther && field\?\.prep/); + assert.ok(!game.includes('ScoutedBench'), 'the floating strip is gone — the hand rides the board'); + assert.ok(!read('public/css/screens/game.css').includes('.sbench'), 'its styles too'); + }); + + test('battleView builds an ItemView for a hand item and item pips for a teammate operator (source)', () => { + const app = read('public/js/render/app.js'); + assert.match(app, /info\.kind === 'item' \? new ItemView\(ctx, scoutItemInfo\(info\)\)/); + assert.match(app, /battleMeta\?\.prep && Array\.isArray\(info\.items\) && info\.items\.length/); + assert.match(app, /function scoutItemInfo\(info\)/); + }); + + test('tapping a held item on the scouted board opens its card (resolveDetail unit → item)', () => { + const IT = 'chess_item_1_01_e_a'; + const d = resolveDetail({ kind: 'unit', unit: { id: 3, kind: 'item', side: 'ally', ownerId: 'p2', defId: IT } }, new Map()); + assert.equal(d.type, 'item'); + assert.equal(d.item.itemId || d.item.id, IT); + assert.equal(resolveDetail({ kind: 'unit', unit: { id: 4, kind: 'item', side: 'ally', ownerId: 'p2', defId: 'nope' } }, new Map()), null); + }); +}); diff --git a/test/ui/spectate-effects.test.js b/test/ui/spectate-effects.test.js new file mode 100644 index 00000000..a67d7a04 --- /dev/null +++ b/test/ui/spectate-effects.test.js @@ -0,0 +1,61 @@ +// 观战对象的效果列(游玩记录 #2 追加反馈:「观战队友时右侧的 effect 列也改成观战对象的」): +// * 整备期侦察:m.field 直接带被侦察玩家的 effectsView(server Match.prepFieldMeta,现成显示形状); +// * 战斗观战(显示复刻):runner 从 spec 的原始 playerEffects 客户端还原显示条目 +// (battle/observe.js spectateEffects — 策略效果经 bands.json 的 effectId,其余经 effects.json); +// * 联防 / boss 对半场与服务器运行场的 meta 无 effects —— 列留空,不再显示自己的。 +// 服务器侧:test/match/prep-bench.test.js。 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const read = (p) => readFileSync(path.join(ROOT, p), 'utf8'); + +globalThis.fetch = async (url) => { + const name = String(url).split('/').pop(); + try { + const body = readFileSync(path.join(ROOT, 'data', name), 'utf8'); + return { ok: true, status: 200, json: async () => JSON.parse(body) }; + } catch { + return { ok: false, status: 404, json: async () => ({}) }; + } +}; +const { data } = await import('../../public/js/data.js'); +await data.loadAll('effects', 'bands', 'assets'); +const { spectateEffects } = await import('../../public/js/battle/observe.js'); + +describe('spectateEffects — the watched battle\'s effects column from the spec', () => { + const spec = { players: [ + { playerId: 'p1', playerEffects: [ + { id: 'aceffect_band_21', source: 'band' }, // 华法琳【重点监护】 + { id: 'aceffect_char_1', source: 'choice', counter: 2 }, + { id: 'effect_not_in_data', source: 'team' }, + ] }, + { playerId: 'p2', playerEffects: [] }, + ] }; + + test('the single member\'s playerEffects resolved: band via bands.json, the rest via effects.json', () => { + const out = spectateEffects(spec, ['p1']); + assert.ok(Array.isArray(out) && out.length === 3); + assert.deepEqual([out[0].name, out[0].iconKind, out[0].iconId], ['重点监护', 'band', 'icon_bldsk']); + assert.match(out[0].desc, /开始作战时/); + assert.deepEqual([out[1].name, out[1].iconKind, out[1].counter], ['未精英化', 'choice', 2]); + assert.equal(out[2].name, 'effect_not_in_data', 'an unknown id falls back to the id'); + assert.equal(out[2].iconKind, 'team'); + }); + + test('undefined where whose column would be ambiguous or missing', () => { + assert.equal(spectateEffects(spec, ['p1', 'p2']), undefined, '联防 / boss pairs'); + assert.equal(spectateEffects({ players: [{ playerId: 'p1' }] }, ['p1']), undefined, 'no playerEffects on the spec'); + assert.equal(spectateEffects(null, ['p1']), undefined); + assert.deepEqual(spectateEffects(spec, ['p2']), [], 'an empty list stays a list (empty column)'); + }); + + test('wiring: the runner derives it for replica fields, the game screen prefers the field\'s list (source)', () => { + assert.match(read('public/js/battle/runner.js'), /effects: spectateEffects\(e\.spec, e\.members\)/); + assert.match(read('public/js/battle/runner.js'), /import \{ spectateEffects \} from '\.\/observe\.js'/); + assert.match(read('public/js/screens/game.js'), /watchingOther && field \? \(field\.effects \?\? null\) : priv\?\.effects/); + }); +}); diff --git a/test/ui/teammate-band.test.js b/test/ui/teammate-band.test.js new file mode 100644 index 00000000..f1fd6983 --- /dev/null +++ b/test/ui/teammate-band.test.js @@ -0,0 +1,46 @@ +// 队友的策略(游玩记录 #2 item 2:「观战队友时看不到队友的策略详情」):观战队友的整备区棋盘时, +// 「本局信息」抽屉的策略块从自己的 priv.bandId 切换成被观看玩家的 m.public players[].bandId,标签 +// 从「我的策略」变为「XX 的策略」(ui/enemyDrawer.js InfoTab 的 bandId / bandOwner 覆盖;game.js +// scoutPid / scoutBandId)。不进详情卡——策略和自己的查看位置一致。 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const read = (p) => readFileSync(path.join(ROOT, p), 'utf8'); + +import { ownerBandId } from '../../public/js/ui/gameLogic.js'; + +describe('ownerBandId — the picked 策略 of a player', () => { + const pub = { players: [ + { playerId: 'p1', name: '博士一', bandId: 'band_bldsk' }, + { playerId: 'p2', name: '博士二' }, // not picked yet (draft running) + ] }; + test('the bandId of the owner, null for anyone without one or an unknown id', () => { + assert.equal(ownerBandId(pub, 'p1'), 'band_bldsk'); + assert.equal(ownerBandId(pub, 'p2'), null); + assert.equal(ownerBandId(pub, 'pX'), null); + assert.equal(ownerBandId(null, 'p1'), null); + assert.equal(ownerBandId({ players: [] }, 'p1'), null); + }); +}); + +describe('the drawer shows the watched player\'s 策略 while scouting', () => { + test('game.js derives the scouted player and hands bandId / bandOwner to the drawer (source)', () => { + const game = read('public/js/screens/game.js'); + assert.match(game, /const scoutPid = watchingOther && field\?\.prep && typeof field\.fieldId === 'string' && field\.fieldId\.startsWith\('n:'\) \? field\.fieldId\.slice\(2\) : null/); + assert.match(game, /const scoutBandId = scoutPid \? ownerBandId\(pub, scoutPid\) : null/); + assert.match(game, /bandId=\$\{scoutBandId\} bandOwner=\$\{scoutBandOwner\}/); + }); + + test('InfoTab overrides the band and labels it with the owner (source); the detail card has no 策略 section', () => { + const drawer = read('public/js/ui/enemyDrawer.js'); + assert.match(drawer, /const band = \(bandId \|\| priv\?\.bandId\) \? data\.lookup\('bands', bandId \|\| priv\.bandId\) : null/); + assert.match(drawer, /bandOwner \? `\$\{bandOwner\} 的策略` : '我的策略'/); + const panel = read('public/js/ui/detailPanel.js'); + assert.doesNotMatch(panel, /key="band"/, 'no 策略 section on the detail card'); + assert.doesNotMatch(panel, /'band'/, "CHESS_SECTIONS has no 'band' block"); + }); +});