From 19c7b268503adfc4e3c831d51eb03e7eaeb6e1ff Mon Sep 17 00:00:00 2001 From: Xinglan233 Date: Thu, 8 Oct 2026 21:04:58 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feedback7=20R22-LATE:=20a=20=E7=94=BB?= =?UTF-8?q?=E5=8D=B7=20copy=20that=20completes=20a=20three-copy=20merge=20?= =?UTF-8?q?copies=20the=20target's=20equipment=20again;=20golden:=20unchan?= =?UTF-8?q?ged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub #389 / PR #390 by @Xinglan233 (this commit is substantially the PR: the snapshot and its test file, ported). onArt read target.items after grantChess; a gain that completes a merge runs _mergeChess first, which consumes the target and empties its items, so the copy loop ran on an empty list (two base 维式重锤 never became the +25% one). The item ids are now recorded before the gain. Added on top of the PR: after a promotion the copied items are never equipped onto the elite (an already-advanced item used to go straight back onto it via equipDirect). The promotion unequips the copies (PRTS 帮助 自动卸除 on 合并) and the 画卷 note says the obtained equipment arrives unequipped, so they wait in the hand; a copied normal item merges with the returned original as before. The non-promotion path is unchanged. Tests: test/match/scroll-copy-merge.test.js (8; 4 fail on the parent, the advanced-item case also fails with the PR's snapshot alone). Golden unchanged (no scenario casts 画卷 into a promotion). --- server/sim/content/items/meta.js | 14 +++- test/match/scroll-copy-merge.test.js | 113 +++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 test/match/scroll-copy-merge.test.js diff --git a/server/sim/content/items/meta.js b/server/sim/content/items/meta.js index df07541c..ea90ac93 100644 --- a/server/sim/content/items/meta.js +++ b/server/sim/content/items/meta.js @@ -119,17 +119,23 @@ export function registerMeta(registry) { // 画卷 — trap_copy_front_char: copy the operator in range (elite status included) with its equipment. A copied // normal item that completes a pair with an owned one merges at once and the golden stays in the hand (research 04 // addendum "两个同名道具(无论是否被装备)会自动合并…并自动返回整备区"); the built-in equipped that golden on the copy. + // A copy that completes a three-copy merge (GitHub #389, PR #390): the elite wears nothing — the promotion returns the + // copies' equipment to the hand (PRTS 帮助 "在失去该干员(…合并等)…时自动卸除") — and the copied items stay in the hand + // too (PRTS 画卷 备注 "获得的装备为未装备状态"), a copied normal item merging with the returned original. wrap(registry, 'chess_item_6_02_m', () => ({ onArt(ctx, ev) { const target = (ev.targets || []).find((p) => p && p.kind === 'chess'); if (!target) { ev.error = 'BAD_TARGET'; ev.detail = 'no operator in range'; return; } + // snapshot before the gain: a gain that completes a merge consumes the target and empties `target.items`, and an + // item merge detaches the original's copy from `target.items` while we iterate (the built-in's live loop then + // skipped the next item) + const itemIds = (target.items || []).map((it) => it.id); const copy = ctx.grantChess(target.id, { requirePool: false, source: 'item:chess_item_6_02_m' }); if (!copy) { ev.error = 'HAND_FULL'; return; } - // snapshot first: a merge detaches the original's copy of the item from `target.items` while we iterate - // (the built-in's live loop then skipped the next item) - for (const itemId of (target.items || []).map((it) => it.id)) { + const promoted = copy.id !== target.id; + for (const itemId of itemIds) { const got = ctx.grantItem(itemId, { source: 'item:chess_item_6_02_m' }); - const holder = got ? ctx.piece(copy.uid) : null; + const holder = got && !promoted ? ctx.piece(copy.uid) : null; if (got && got.id === itemId && holder && holder.kind === 'chess') ctx.equipDirect(got.uid, holder.uid); } }, diff --git a/test/match/scroll-copy-merge.test.js b/test/match/scroll-copy-merge.test.js new file mode 100644 index 00000000..ca4a597c --- /dev/null +++ b/test/match/scroll-copy-merge.test.js @@ -0,0 +1,113 @@ +// A Picture Scroll (画卷) gain can consume its own target in an immediate three-copy promotion (GitHub #389, PR #390). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeMatch, legalTileFor } from './harness.js'; + +const OP = 'chess_char_1_06_a'; // 刺玫, the operator of the report +const SCROLL = 'chess_item_6_02_m'; +const HAMMER = 'chess_item_1_01_e_a'; +const SHIELD = 'chess_item_1_02_e_a'; +const FILLER = 'chess_item_6_09_e_a'; // non-mergeable +const OK = { ok: true }; + +function prep(t, { copies = 2, golden = false, items = [HAMMER] } = {}) { + const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seed: 8 }).start(); + t.after(() => h.m.dispose()); + h.toPrep(1, { band: 'band_dusk' }); + const m = h.m, ps = h.ps('p_0'); + assert.equal(m.gd.mergeCount(OP), 3); + const id = golden ? m.gd.goldenIdOf(OP) : OP; + const target = ps.acquireChess(id); + const [row, col] = legalTileFor(m, ps, id); + assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: target.uid, to: { area: 'board', row, col }, dir: 'RIGHT' }), OK); + for (let i = 1; i < copies; i++) ps.acquireChess(id); + for (const itemId of items) { + const item = ps.acquireItem(itemId); + assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: target.uid }), OK); + } + const art = ps.hand.find((p) => p?.id === SCROLL); + assert.ok(art, 'Dusk grants the scroll in round 1'); + h.invariants(); + const cast = () => m.handle('p_0', { t: 'g.art', itemUid: art.uid, row, col, dir: 'RIGHT' }); + return { h, m, ps, target, art, row, col, cast }; +} + +function itemIds(ps) { + return [...ps.hand, ...ps.temp, ...ps.board.values()].filter(Boolean) + .flatMap((p) => p.kind === 'item' ? [p.id] : (p.items || []).map((it) => it.id)).sort(); +} + +test('Picture Scroll: a third normal copy preserves the target equipment through promotion', (t) => { + const { h, m, ps, target, art, row, col, cast } = prep(t); + const funds = ps.funds; + assert.deepEqual(cast(), OK); + const elite = ps.board.get(`${row},${col}`); + assert.equal(elite.id, m.gd.goldenIdOf(OP)); + assert.equal(elite.dir, 'RIGHT'); + assert.equal(ps.find(target.uid), null, 'the original target was consumed'); + assert.equal(ps.find(art.uid), null, 'the scroll was consumed'); + assert.deepEqual(itemIds(ps), [m.gd.item(HAMMER).goldenId]); + assert.ok(ps.hand.some((p) => p?.id === m.gd.item(HAMMER).goldenId), 'the original and copied hammer merge into an advanced hammer in hand'); + assert.deepEqual(elite.items, []); + assert.equal(ps.stats.merges, 1); + assert.equal(ps.stats.itemMerges, 1); + assert.equal(ps.funds, funds); + h.invariants(); +}); + +for (const advanced of [false, true]) { + test(`Picture Scroll: promotion copies both ${advanced ? 'advanced' : 'normal'} equipped items, none of them worn by the elite`, (t) => { + const items = [HAMMER, SHIELD].map((id) => advanced ? id.replace(/_a$/, '_b') : id); + const { h, m, ps, row, col, cast } = prep(t, { items }); + assert.deepEqual(cast(), OK); + const elite = ps.board.get(`${row},${col}`); + const upgraded = items.map((id) => m.gd.item(id).goldenId || id).sort(); + // advanced items do not merge: the returned originals and the copies all wait in the hand ("获得的装备为未装备状态") + assert.deepEqual(itemIds(ps), advanced ? [...items, ...items].sort() : upgraded); + assert.deepEqual(elite.items, [], 'the promotion unequips, and the copies are not put back on the elite'); + assert.equal([...ps.hand, ...ps.temp].filter((p) => p?.kind === 'item').length, advanced ? 4 : 2); + h.invariants(); + }); +} + +for (const golden of [false, true]) { + test(`Picture Scroll: one ${golden ? 'elite' : 'normal'} target still copies its equipment without promotion`, (t) => { + const { h, m, ps, target, cast } = prep(t, { copies: 1, golden }); + assert.deepEqual(cast(), OK); + assert.equal(ps.find(target.uid)?.piece, target); + assert.equal(ps.allChess().length, 2); + assert.equal(ps.allChess().filter((p) => p.id === target.id).length, 2); + assert.deepEqual(itemIds(ps), [m.gd.item(HAMMER).goldenId]); + assert.equal(ps.stats.merges, 0); + h.invariants(); + }); +} + +test('Picture Scroll: a third copy without equipment still promotes normally', (t) => { + const { h, m, ps, row, col, cast } = prep(t, { items: [] }); + assert.deepEqual(cast(), OK); + assert.equal(ps.board.get(`${row},${col}`).id, m.gd.goldenIdOf(OP)); + assert.deepEqual(itemIds(ps), []); + h.invariants(); +}); + +test('Picture Scroll: a promotion at full hand and temp still copies the hammer', (t) => { + const { h, m, ps, cast } = prep(t); + while (ps.hand.some((p) => p == null) || ps.temp.some((p) => p == null)) assert.ok(ps.acquireItem(FILLER)); + h.invariants(); + assert.deepEqual(cast(), OK); + assert.deepEqual(itemIds(ps).filter((id) => id !== FILLER), [m.gd.item(HAMMER).goldenId]); + assert.equal(itemIds(ps).filter((id) => id === FILLER).length, 13); + h.invariants(); +}); + +test('Picture Scroll: a full hand and temp without promotion keeps the refused scroll and target equipment', (t) => { + const { h, ps, target, art, cast } = prep(t, { copies: 1 }); + while (ps.hand.some((p) => p == null) || ps.temp.some((p) => p == null)) assert.ok(ps.acquireItem(FILLER)); + const before = ps.privateView(); + assert.equal(cast().error, 'HAND_FULL'); + assert.ok(ps.find(art.uid)); + assert.deepEqual(target.items.map((p) => p.id), [HAMMER]); + assert.deepEqual(ps.privateView(), before); + h.invariants(); +}); From 2df1cf15bf50aa6ddf4f53de4381d68813815b84 Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 21:11:25 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feedback7=20R22-LATE:=20a=20watched=20teamm?= =?UTF-8?q?ate's=20bond=20popup=20counts=20their=20bench=20like=20their=20?= =?UTF-8?q?own=20popup=20=E2=80=94=20hand=20operators=20owned=20(and=20cou?= =?UTF-8?q?nted=20for=20=E6=8A=95=E8=B5=84=E4=BA=BA=20/=20=E8=BF=9C?= =?UTF-8?q?=E8=A7=81=20/=20=E5=A5=87=E8=BF=B9),=20temp=20ones=20never,=20n?= =?UTF-8?q?either=20=E5=9C=A8=E5=9C=BA;=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub #385 / PR #387 by @zasx77771. The prep scout sends the hand and the 临时整备区 as units, and ownerBoard put every op unit on the popup's synthetic board, so the popup marked bench operators 在场 and its 成员 header disagreed with the server's ring (炎: ring 1, header 3; 远见: ring 2, header 3 with the temp copy). The scout's units now carry `area` 'board' | 'hand' | 'temp' (the server tags from PR #387; the tag survives the boss-half remap, the row does not). ownerBoard sorts them into board / hand / temp instead of dropping the bench as the PR did: dropping the hand would break the bonds that count the 整备区 (投资人 / 远见 / 奇迹 — a hand-only 远见 member: ring 1, header 0). bondMembers / memberHeadCount then count exactly as the player's own popup: hand = owned and in hand, temp = owned only. A unit without an area (battles, older servers) stays on the board. Docs: PLAYING.md's 观看谁 paragraph (the bench is listed in prep); comments in bondStrip.js / game.js. Tests: test/ui/watch-bonds.test.js (36; 2 fail on the parent), test/match/prep-bench.test.js + feedback5-boss-pair-prep.test.js (area tags; 4 fail on the parent). Golden unchanged (views only). --- docs/PLAYING.md | 2 +- public/js/screens/game.js | 2 +- public/js/ui/bondStrip.js | 4 ++- public/js/ui/watchBonds.js | 35 +++++++++--------- server/match/match/views.js | 16 ++++++--- test/match/feedback5-boss-pair-prep.test.js | 4 +++ test/match/prep-bench.test.js | 8 ++++- test/ui/watch-bonds.test.js | 39 ++++++++++++++++++--- 8 files changed, 80 insertions(+), 30 deletions(-) diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 853e658d..c35229e5 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -137,7 +137,7 @@ - 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。 - 被淘汰后可以自由观看任何战场,而且**不用每回合重选**:你上次点「前往查看」的是谁,之后每个阶段(回合开始、机变、休整期、作战、最终攻势)都会自动跟到他的阵地或战场;没选过或他也被淘汰时,跟随座位顺序第一位还在场的博士(包括 AI 队友)。阶段中途换人立即生效,之后就跟随新选的人。 - **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——自动跟随你上次查看的博士(没查看过则是座位顺序第一位还在场的博士):休整期显示他的阵地,作战时观看他的战场,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;模拟开始后,顶栏延迟旁会出现一个「👁 人数」的小胶囊(没有观战者时不显示),点开是观战者名单(离线的有标记),创建者可以在名单里用 ✕ 把观战者移出,被移出的人立刻回到大厅、不再收到战场画面(观战席上限仍是 2 个,不能调整);观战者在等待室有空位时可以点「入座」成为博士。 -- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。 +- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏标出他在场上的干员;休整期查看时他整备区、临时整备区里的干员也会列出、标为「整备区」,和他自己看到的一样计数:投资人、远见、奇迹这类含整备区的盟约把整备区的算进成员人数,临时整备区的哪个盟约都不算;作战中只列出场上的);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。 ## 9. 结算称号 diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 93a4e18d..7aa61f9f 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -1296,7 +1296,7 @@ function MatchScreen() { }, [strip.ownerId]); // the popup shows the player it was opened for (ui/watchBonds.js popupView): their entry + live layers, their name, // and as members your pieces or their operators on the field on screen — under client-side combat the battle's own - // (the runner's field meta is taken before they deploy); their hand is never sent + // (the runner's field meta is taken before they deploy); a battle sends no bench (a prep scout does: ownerBoard) const popOps = cc && battleRunner && field?.local && bondOpen && bondOpen.ownerId !== myId ? battleRunner.ownerOps(bondOpen.ownerId, field.fieldId) : null; const bondPop = popupView({ open: bondOpen, pub, priv, myId, field, units: popOps, live: liveLayers }); const openBond = (id, ownerId, from) => { setBondOpen((b) => toggleBond(b, id, ownerId, from)); audio.sfx('click', { volume: 0.4 }); }; diff --git a/public/js/ui/bondStrip.js b/public/js/ui/bondStrip.js index b656f4f4..e606bc9f 100644 --- a/public/js/ui/bondStrip.js +++ b/public/js/ui/bondStrip.js @@ -17,7 +17,9 @@ // already did. // Watching a teammate (DESIGN §20.15, ui/watchBonds.js) the strip and the popup show THAT player's bonds and layers: // the strip carries an amber "👁 name" tag (`owner`, the observing pill's spelling, research 09 §3.1) and amber rings, -// the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field (no hand: never sent). +// the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field — in prep their bench +// too, counted like their own popup counts it (ui/watchBonds.js ownerBoard: hand / temp by the scout's `area`, GitHub +// #385); a battle sends no bench. // 0.2.0 补位: a member the player fields as its stand-in (the own m.private.standIns; a teammate's unit that says so — // UnitInfo `standInFor`) is drawn as the stand-in — avatar, name, a small 「替补」 mark on the thumbnail — and its row // opens the stand-in's card (gameLogic memberStandIn; the owner's recall of the official mode, 2026-10-06). diff --git a/public/js/ui/watchBonds.js b/public/js/ui/watchBonds.js index cab69058..970d35ef 100644 --- a/public/js/ui/watchBonds.js +++ b/public/js/ui/watchBonds.js @@ -151,27 +151,30 @@ export function withLiveLayers(bonds, live) { * A teammate's board as far as the screen shows it, in the m.private shape the bond popup's member list reads * (gameLogic bondMembers): their operators on the field meta on screen (the prep scouting board, a server-run battle * field's units) and, under client-side combat, the battle on screen's (`extra` = battle/runner.js ownerOps: the meta - * the runner publishes is taken before the operators deploy). Their hand is never sent — members count as owned only - * when they are on the field. Their operators keep the items the unit info carries (UnitInfo `items`), so a 变形同构体 - * wearer is listed as a member of the bond it grants (gameLogic grantedBonds), and a 补位 unit its `standInFor` (the - * popup draws the stand-in). null without a field or battle. - * @param {any} field @param {string} ownerId @param {any[]|null} [extra] UnitInfo-like { kind, ownerId, defId, items?, standInFor? } + * the runner publishes is taken before the operators deploy). A prep scout also carries their bench, each unit tagged + * with its `area` (server prepFieldMeta): a 'hand' operator goes to the hand list, a 'temp' one to the temp list, so the + * popup counts them as the player's own popup does (GitHub #385, PR #387) — owned, not 在场; a hand operator counts for + * the bonds that count the 整备区 (投资人 远见 奇迹: memberHeadCount), the 5 temporary slots for none. A unit without + * an area (a battle's, an older server's) is on the board; a battle sends no bench. Their operators keep the items the + * unit info carries (UnitInfo `items`), so a 变形同构体 wearer is listed as a member of the bond it grants (gameLogic + * grantedBonds), and a 补位 unit its `standInFor` (the popup draws the stand-in). null without a field or battle. + * @param {any} field @param {string} ownerId @param {any[]|null} [extra] UnitInfo-like { kind, ownerId, defId, area?, items?, standInFor? } */ export function ownerBoard(field, ownerId, extra = null) { const fromField = isObj(field) && Array.isArray(field.units) ? field.units : null; const fromBattle = Array.isArray(extra) ? extra : null; if (!fromField && !fromBattle) return null; - const board = [...(fromField || []), ...(fromBattle || [])] - .filter((u) => isObj(u) && u.ownerId === ownerId && u.kind === 'op' && typeof u.defId === 'string') - .map((u) => { - const p = Array.isArray(u.items) && u.items.length ? { kind: 'chess', id: u.defId, items: u.items.filter((x) => typeof x === 'string') } : { kind: 'chess', id: u.defId }; - // 0.2.0 自选编队: a DIY slot's unit names its operator (UnitInfo diy) — the popup counts it for that operator's bonds - if (isObj(u.diy) && typeof u.diy.charId === 'string') p.diy = u.diy; - // 0.2.0 补位: a unit fighting as its stand-in says so (UnitInfo standInFor) — the popup draws that member as the stand-in - if (typeof u.standInFor === 'string' && u.standInFor) p.standInFor = u.standInFor; - return p; - }); - return { board, hand: [], temp: [] }; + const out = { board: /** @type {any[]} */ ([]), hand: /** @type {any[]} */ ([]), temp: /** @type {any[]} */ ([]) }; + for (const u of [...(fromField || []), ...(fromBattle || [])]) { + if (!isObj(u) || u.ownerId !== ownerId || u.kind !== 'op' || typeof u.defId !== 'string') continue; + const p = Array.isArray(u.items) && u.items.length ? { kind: 'chess', id: u.defId, items: u.items.filter((x) => typeof x === 'string') } : { kind: 'chess', id: u.defId }; + // 0.2.0 自选编队: a DIY slot's unit names its operator (UnitInfo diy) — the popup counts it for that operator's bonds + if (isObj(u.diy) && typeof u.diy.charId === 'string') p.diy = u.diy; + // 0.2.0 补位: a unit fighting as its stand-in says so (UnitInfo standInFor) — the popup draws that member as the stand-in + if (typeof u.standInFor === 'string' && u.standInFor) p.standInFor = u.standInFor; + out[u.area === 'hand' ? 'hand' : u.area === 'temp' ? 'temp' : 'board'].push(p); + } + return out; } /** diff --git a/server/match/match/views.js b/server/match/match/views.js index 6b51b37e..a6f5a13e 100644 --- a/server/match/match/views.js +++ b/server/match/match/views.js @@ -225,7 +225,8 @@ export class MatchViews { return p ? { charId: p.charId, skillIndex: p.skillIndex, uniEquipId: p.uniEquipId } : undefined; } - /** UnitInfo of a player's board pieces on their (board) tiles — a prep scout's board, the boss partner's (bossMateView). */ + /** UnitInfo of a player's board pieces on their (board) tiles — a prep scout's board, the boss partner's (bossMateView). + * `area: 'board'` (a bench unit says 'hand' / 'temp': prepFieldMeta). */ _prepBoardUnits(ps) { const units = []; // the player's own view of the data (0.2.0 自选编队: its slotted DIY slots are its operators — player/diy.js) @@ -241,6 +242,7 @@ export class MatchViews { const lo = piece.kind === 'chess' && chess ? ps.loadoutFor(chess) : null; units.push({ id: piece.uid, uid: piece.uid, kind: piece.kind === 'token' ? 'token' : 'op', side: 'ally', ownerId: ps.playerId, defId: piece.id, + area: 'board', 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: c, y: r, dir: pieceDir(piece), facing: pieceDir(piece) === 'LEFT' ? -1 : 1, maxHp: rec && rec.stats && Number.isFinite(rec.stats.maxHp) ? rec.stats.maxHp : 1, @@ -292,7 +294,11 @@ export class MatchViews { // (test/match/spectator.test.js). User playtest #2 item 1 (GitHub #44). // (a held chess the player fields as its stand-in is the stand-in there too — name, art, max HP — and carries // `standInFor`, like a board piece: the owner's recall of the official mode, 2026-10-06, the hand shows the stand-in) - const benchUnit = (piece, i, y) => { + // Each unit says where it waits (`area` 'hand' | 'temp'; a board unit 'board'): the bond popup of a watched player + // counts it like the player's own (ui/watchBonds.js ownerBoard — a hand operator is owned and counts for 投资人 远见 + // 奇迹, a temp one counts for nothing; GitHub #385, PR #387). The tag survives the boss-half remap (_onBossHalf), the + // row does not. + const benchUnit = (piece, i, y, area) => { const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : gd.chess(piece.id); const standIn = piece.kind === 'chess' && rec && ps.fieldsStandIn(rec) ? this.gd.standIn(rec.chessId) : null; const body = standIn || rec; @@ -300,7 +306,7 @@ export class MatchViews { 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, + side: 'ally', ownerId: ps.playerId, defId: piece.id, area, name: body ? body.name : piece.id, tier: rec && Number.isInteger(rec.tier) ? rec.tier : 1, golden: !!(rec && rec.isGolden), spine: assets.spine || (body && body.charId) || piece.id, avatar: assets.avatar || (body && body.charId) || piece.id, x: i, y, maxHp: body && body.stats && Number.isFinite(body.stats.maxHp) ? body.stats.maxHp : 1, @@ -312,10 +318,10 @@ export class MatchViews { }); }; for (let i = 0; i < ps.hand.length; i++) { - if (ps.hand[i]) benchUnit(ps.hand[i], i, GEO.HAND_ROW); + if (ps.hand[i]) benchUnit(ps.hand[i], i, GEO.HAND_ROW, 'hand'); } for (let i = 0; i < ps.temp.length; i++) { - if (ps.temp[i]) benchUnit(ps.temp[i], GEO.TEMP_C0 + i, GEO.TEMP_ROW); + if (ps.temp[i]) benchUnit(ps.temp[i], GEO.TEMP_C0 + i, GEO.TEMP_ROW, 'temp'); } // `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 })) diff --git a/test/match/feedback5-boss-pair-prep.test.js b/test/match/feedback5-boss-pair-prep.test.js index cc8b27a7..f1775eb1 100644 --- a/test/match/feedback5-boss-pair-prep.test.js +++ b/test/match/feedback5-boss-pair-prep.test.js @@ -56,6 +56,7 @@ test('a move of the partner reaches the player\'s m.private and a scout of eithe const h = bossPrep(4, 5102); const m = h.m; const b = place(h, 'p_1', chessOfTier(1)[1]); + const held = give(m, h.ps('p_0'), chessOfTier(1)[2]); // into p_0's hand m.flush(true); // p_3 (eliminated) scouts p_0: the scout shows p_0's half and p_1's board on the other half h.ps('p_3').eliminate(13); @@ -63,6 +64,9 @@ test('a move of the partner reaches the player\'s m.private and a scout of eithe let scout = h.lastTo('p_3', 'm.field'); assert.deepEqual([scout.kind, scout.side, scout.mate], ['boss', 'L', { playerId: 'p_1', side: 'R' }]); assert.ok(scout.units.some((u) => u.uid === b.piece.uid && u.x >= 10), 'the partner\'s piece on the right half'); + // the area tags survive the boss-half remap (the rows do not): the bond popup tells the bench from the board (GitHub #385) + assert.equal(scout.units.find((u) => u.uid === b.piece.uid).area, 'board'); + assert.equal(scout.units.find((u) => u.uid === held.uid).area, 'hand', 'a held operator stays tagged as held'); // p_1 moves its piece: p_0's m.private and p_3's scout of p_0 follow const ps1 = h.ps('p_1'); const to = legalTileFor(m, ps1, b.piece.id, new Set([`${b.tile[0]},${b.tile[1]}`])); diff --git a/test/match/prep-bench.test.js b/test/match/prep-bench.test.js index ec7e765a..baae0d12 100644 --- a/test/match/prep-bench.test.js +++ b/test/match/prep-bench.test.js @@ -37,6 +37,7 @@ test('#44 prep scout: held pieces are units on the hand row; deploying moves the 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.area, 'hand', 'tagged with its area: the bond popup counts it as held, not in play (GitHub #385)'); assert.equal(u.kind, 'op'); assert.equal(u.items, undefined, 'no items equipped yet'); } @@ -48,6 +49,7 @@ test('#44 prep scout: held pieces are units on the hand row; deploying moves the 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(u.area, '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(); }); @@ -74,6 +76,7 @@ test('#44 prep scout: a held item is a kind-item unit on the hand row; equipped assert.ok(iu, 'the held item is a unit of the scout'); assert.equal(iu.kind, 'item'); assert.equal(iu.y, GEO.HAND_ROW); + assert.equal(iu.area, 'hand'); // 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'); @@ -109,7 +112,10 @@ test("PR #129 review: moving a hand piece to another slot is a scout change (x f 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); + assert.equal(tu.area, 'temp'); + const hu = first.units.find((x) => x.uid === a.uid); + assert.equal(hu.x, 0); + assert.equal(hu.area, 'hand'); // 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 }); diff --git a/test/ui/watch-bonds.test.js b/test/ui/watch-bonds.test.js index bcd5f4dc..ef7411ce 100644 --- a/test/ui/watch-bonds.test.js +++ b/test/ui/watch-bonds.test.js @@ -281,16 +281,45 @@ describe('§20.15 the bonds: views + live layers', () => { assert.deepEqual(mate.bonds.map((b) => [b.bondId, b.layers]), [['sargonShip', 44], ['kjeragShip', 7]], 'THEIR bonds, THEIR live count'); assert.deepEqual(playerBonds({ pub, priv: PRIV, myId: ME, ownerId: 'p3', live }).map((b) => b.layers), [120]); }); - test('ownerBoard: the teammate\'s operators on the field on screen feed the popup\'s member list (no hand)', () => { + test('ownerBoard: the teammate\'s operators on the field on screen feed the popup\'s member list; a scout\'s bench goes to hand / temp', () => { const field = { fieldId: 'n:p2', prep: true, units: [ - { id: 1, kind: 'op', side: 'ally', ownerId: 'p2', defId: 'chess_char_1_19_a' }, - { id: 2, kind: 'token', side: 'ally', ownerId: 'p2', defId: 'tok' }, + { id: 1, kind: 'op', side: 'ally', ownerId: 'p2', defId: 'chess_char_1_19_a', area: 'board' }, + { id: 2, kind: 'token', side: 'ally', ownerId: 'p2', defId: 'tok', area: 'board' }, { id: 3, kind: 'op', side: 'ally', ownerId: 'p3', defId: 'chess_other' }, { id: 4, kind: 'enemy', side: 'enemy', ownerId: null, defId: 'enemy_1' }, + { id: 5, kind: 'op', side: 'ally', ownerId: 'p2', defId: 'chess_char_1_03_a', area: 'hand' }, + { id: 6, kind: 'op', side: 'ally', ownerId: 'p2', defId: 'chess_char_2_04_a', area: 'temp' }, + { id: 7, kind: 'item', side: 'ally', ownerId: 'p2', defId: 'chess_item_1_01_e_a', area: 'hand' }, ] }; - assert.deepEqual(ownerBoard(field, 'p2'), { board: [{ kind: 'chess', id: 'chess_char_1_19_a' }], hand: [], temp: [] }); + assert.deepEqual(ownerBoard(field, 'p2'), { + board: [{ kind: 'chess', id: 'chess_char_1_19_a' }], hand: [{ kind: 'chess', id: 'chess_char_1_03_a' }], temp: [{ kind: 'chess', id: 'chess_char_2_04_a' }], + }); + // a unit without an area (a battle's, an older server's prep scout) is on the board + assert.deepEqual(ownerBoard({ units: [{ kind: 'op', ownerId: 'p2', defId: 'chess_char_1_19_a' }] }, 'p2').board, [{ kind: 'chess', id: 'chess_char_1_19_a' }]); assert.equal(ownerBoard(null, 'p2'), null); }); + test('GitHub #385: a scouted bench counts in the popup\'s 成员 header as in the player\'s own (hand for 远见 / 奇迹 / 投资人, temp never)', () => { + // 远见 (BOARD_AND_DECK, countsHand: the server's 在场 is 2 — 赫默 on the board, 初雪 in the hand; 伊内丝 waits in temp) + // 炎 (BOARD: 在场 1 — 小满; 琳琅诗怀雅 in the hand, 烛煌 in temp) + const u = (id, defId, area) => ({ id, kind: 'op', side: 'ally', ownerId: 'p2', defId, area }); + const field = { fieldId: 'n:p2', prep: true, units: [ + u(1, 'chess_char_2_02_a', 'board'), u(2, 'chess_char_2_04_a', 'board'), + u(3, 'chess_char_3_14_a', 'hand'), u(4, 'chess_char_3_04_a', 'hand'), + u(5, 'chess_char_4_04_a', 'temp'), u(6, 'chess_char_5_03_a', 'temp'), + ] }; + const head = (bondId) => { + const v = BondPopup({ bondId, entry: bond(bondId, 0, 0, 0), priv: ownerBoard(field, 'p2'), owner: '阿米娅', onClose() {} }); + const h4 = [...walk(v)].find((x) => x.type === 'h4' && /成员/.test(textOf(x))); + const members = [...walk(v)].filter((x) => hasClass(x, 'bpop__member')); + return { head: textOf(h4).replace(/\s+/g, ''), on: members.filter((x) => hasClass(x, 'is-on')).length, owned: members.filter((x) => hasClass(x, 'is-owned')).length }; + }; + const visi = head('visiShip'); + assert.match(visi.head, /^成员2\//, '远见: the board member and the hand member (含整备区), not the temp one'); + assert.deepEqual([visi.on, visi.owned], [1, 2], 'only 赫默 is 在场; 初雪 and 伊内丝 are owned'); + const yan = head('yanShip'); + assert.match(yan.head, /^成员1\//, '炎: the board member only'); + assert.deepEqual([yan.on, yan.owned], [1, 2]); + }); test('ownerBoard: under client-side combat the battle on screen\'s operators join (the runner\'s meta predates their deploy)', () => { const meta = { fieldId: 'b1', local: true, units: [{ id: 90, kind: 'enemy', side: 'enemy', ownerId: null, defId: 'boss' }] }; const ops = [{ kind: 'op', ownerId: 'p2', defId: 'chess_char_1_09_a' }, { kind: 'op', ownerId: 'p2', defId: 'chess_char_1_03_a' }]; @@ -405,7 +434,7 @@ describe('§20.15 the popup of a card\'s bond chip: the UNIT owner\'s bond (a sh assert.deepEqual([v.entry.bondId, v.entry.count, v.entry.active, v.entry.layers], ['sargonShip', 2, true, 55], 'the partner\'s count / tier, their live layers'); assert.notEqual(v.priv, PRIV, 'not the viewer\'s pieces'); assert.deepEqual(v.priv.board.map((x) => x.id).sort(), ['chess_char_1_09_a', 'chess_char_1_12_a'], 'the partner\'s operators on the field on screen'); - assert.deepEqual([v.priv.hand, v.priv.temp], [[], []], 'their hand is never sent'); + assert.deepEqual([v.priv.hand, v.priv.temp], [[], []], 'a battle field sends no bench'); }); test('popupView: your own bond (m.private entry with thresholds, your pieces, no name); a bond not listed → no entry; nothing open → null', () => { const own = popupView({ open: { id: 'yanShip', ownerId: ME, from: 'detail' }, pub, priv: PRIV, myId: ME, field: b1, live: null }); From a1bc37ed90fa6eb6d878851853041d4d9c1ada21 Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 21:20:27 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feedback7=20R22-LATE:=20while=20a=20facing?= =?UTF-8?q?=20is=20chosen,=20Enter=20follows=20the=20focus=20=E2=80=94=20?= =?UTF-8?q?=E2=9C=95=20cancels,=20any=20other=20button=20does=20nothing,?= =?UTF-8?q?=20the=20wheel=20confirms;=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub #394 / PR #395 by @Xinglan233. The wheel's capture listener committed the previewed direction on Enter whatever had the focus, so Tab to 准备 / 设置 / the exit button / ✕ and Enter deployed (the PR let only a focused ✕ keep its native Enter). Now gameLogic facingEnter decides: Enter on ✕ cancels; on any other control (button, link, form field, ARIA control) it is swallowed — the wheel already holds Space and the shortcuts; elsewhere it commits the previewed direction, and does nothing while none is previewed. The wheel (role="dialog") takes the focus when it opens (tabIndex -1, no focus ring over the screen), so a button focused before the drop cannot swallow the confirming Enter and the first Tab reaches ✕; nothing restores the old focus when it closes (a restored 准备 would take the next key). Docs: PLAYING.md's key paragraph. Tests: test/ui/feedback5-hotkeys.test.js (facingEnter and its wiring, 2 new); test/ui/facing.e2e.test.js (SP_E2E, the PR's drag / Tab flow, 4 cases: run here in headless Chrome, 4/4 pass, 4/4 fail on the parent — Enter on the focused exit button deployed). Golden unchanged (client only). --- docs/PLAYING.md | 2 +- public/css/screens/game-panels.css | 2 + public/js/ui/facingWheel.js | 23 +++- public/js/ui/gameLogic.js | 2 +- public/js/ui/gameLogic/shortcuts.js | 25 ++++- test/ui/facing.e2e.test.js | 157 ++++++++++++++++++++++++++++ test/ui/feedback5-hotkeys.test.js | 55 +++++++++- 7 files changed, 256 insertions(+), 10 deletions(-) create mode 100644 test/ui/facing.e2e.test.js diff --git a/docs/PLAYING.md b/docs/PLAYING.md index c35229e5..4ed62f68 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -171,7 +171,7 @@ | `Space` | 准备就绪 / 取消准备;独立模拟的作战中暂停 / 继续 | | `Esc` | 取消 / 关闭:依次关闭表情面板、敌人预览、盟约浮层、详情卡、本局信息 / 敌方情报(固定,不能修改) | -刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。 +刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消;按 Tab 先到轮盘上的「点击取消」,在它上面按 `Enter` 是取消,Tab 到其他按钮上按 `Enter` 没有反应)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。 **修改快捷键**:标题画面的「设置」按钮、大厅和房间顶栏的「设置」(玩法说明左边)或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 Tab 移到它上面再按 Enter),出现「按下新按键…」后按新的键;按 `Esc` 取消,设置窗口不会关闭。可以用字母、数字、空格、标点符号键和 Delete / Insert / Home / End / PageUp / PageDown;`Esc`、`Tab`、`Enter`、方向键和 F1–F12 留给界面使用,也不能搭配 Ctrl / Alt / ⌘。新按键已被另一个快捷键使用时两者互换,列表下方会写明(例如把「撤退选中干员」改成 `X`,「出售选中干员」就换成原来的 `Q`)。「恢复默认」改回上表的按键。修改立即生效,商店栏、准备按钮和单位底部「撤退 / 出售」按钮上显示的按键也跟着变;设置和音量等一起保存在当前浏览器里(换浏览器或清除网站数据后回到默认)。按键按键帽上印的字符识别(AZERTY 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。 diff --git a/public/css/screens/game-panels.css b/public/css/screens/game-panels.css index 1cda7adb..94773851 100644 --- a/public/css/screens/game-panels.css +++ b/public/css/screens/game-panels.css @@ -515,6 +515,8 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; } /* ---- direction wheel (ui/facingWheel.js, research 09 §1.2): white diamond, 4 chevrons, "✕ 点击取消", orange range ---- */ .fwheel { position: fixed; inset: 0; z-index: 6; touch-action: none; user-select: none; -webkit-user-select: none; cursor: crosshair; } +/* the wheel takes the keyboard focus when it opens (Enter confirms there, GitHub #394): no ring around the whole screen */ +.fwheel:focus-visible { outline: none; } .fwheel__stripes { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; } .fwheel__dia { position: absolute; transform: translate(-50%, -50%); pointer-events: none; animation: fwheel-in 160ms var(--ease-out); } @keyframes fwheel-in { from { opacity: 0; transform: translate(-50%, -50%) scale(.82); } } diff --git a/public/js/ui/facingWheel.js b/public/js/ui/facingWheel.js index a4987ccf..c688a1d8 100644 --- a/public/js/ui/facingWheel.js +++ b/public/js/ui/facingWheel.js @@ -13,7 +13,8 @@ // LEFT, facing.js boardDir): the chevrons follow the finger, the range / model / intent use the board direction; // * release outside the centre commits (g.move {uid, to, dir} / g.art {…, dir}); release inside the centre, a tap // on ✕, a press outside the diamond or Esc cancels (the piece returns to where it came from). Arrow keys preview a -// direction and Enter commits it (keyboard alternative). Mouse, pen and touch all use pointer events. +// direction and Enter commits it (keyboard alternative) — Enter on a focused ✕ cancels, on any other focused +// button does nothing while the wheel is open (GitHub #394). Mouse, pen and touch all use pointer events. // // View bridge: the field view (render/app.js or the DOM fallback) may implement `tileScreen(row, col)`, // `holdPiece(uid, tile|null)` and `setPieceDir(uid, dir)` (render/app.js does, DESIGN §9); a view without them falls @@ -25,7 +26,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from '../../vendor/hooks import { html } from './components.js'; import { LocalSprite } from './gameComponents.js'; import { DIRS, DIR_LABEL, DEAD_ZONE_TILES, dirFromDelta, dirFromKey, rangeTiles, normDir, boardDir, viewMirrored } from './facing.js'; -import { facingSwallows } from './gameLogic.js'; +import { facingSwallows, facingEnter } from './gameLogic.js'; import { settingsStore } from './settings.js'; import { t } from '../../../shared/i18n.js'; @@ -236,24 +237,36 @@ export function FacingWheel({ view, row, col, grid, name = '', onPreview, onComm }; const onPointerCancel = () => { setDrag(null); setDir(null); }; - // keyboard: arrows preview, Enter commits, Esc cancels (capture: the game's own shortcuts must not see them) + // keyboard: arrows preview, Enter commits, Esc cancels (capture: the game's own shortcuts must not see them). Enter + // follows the focus (gameLogic facingEnter, GitHub #394): on ✕ it cancels, on any other button (Tab reaches the HUD + // behind the wheel) it does nothing, elsewhere it commits the previewed direction. The wheel takes the focus when it + // opens, so a button focused before the drop (a click leaves it focused) cannot swallow the confirming Enter; Tab then + // reaches ✕ first. Nothing gives the focus back when it closes (a restored 准备 would take the next Enter / Space). + const rootRef = useRef(null); useEffect(() => { const onKey = (e) => { const L = live.current; if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); L.onCancel(); return; } const k = dirFromKey(e.key); if (k) { e.preventDefault(); e.stopImmediatePropagation(); setDir(k); return; } - if (e.key === 'Enter' && L.dir) { e.preventDefault(); e.stopImmediatePropagation(); L.onCommit(L.bdir); return; } + const enter = facingEnter(e, !!L.dir); + if (enter) { + e.preventDefault(); e.stopImmediatePropagation(); + if (enter === 'cancel') L.onCancel(); + else if (enter === 'commit') L.onCommit(L.bdir); + return; + } // no ready / shop while choosing: Space and every key of the player's map (设置 → 快捷键) if (facingSwallows(e, settingsStore.get().keys)) { e.preventDefault(); e.stopImmediatePropagation(); } }; window.addEventListener('keydown', onKey, true); + try { rootRef.current?.focus({ preventScroll: true }); } catch { /* no focus: Enter still follows whatever has it */ } return () => window.removeEventListener('keydown', onKey, true); }, []); const outside = !!drag && !!dir; const box = half * 2; - return html`