feedback5 CB5: the boss round's prep shows both players of a pair together — the partner's pieces on the other half, as the battle places them — and frames the pair's two avatars in the official green (community report of 2026-10-06, item 51)

The own half was already the official one (the right-hand player on the right half with the
right_boss_*_camera_param camera, its layout mirrored col c -> 20 - c with RIGHT <-> LEFT, so its operators
face the leader as before: research 09 §1.2, DESIGN §15); the partner's half stood empty and nothing framed
the pair.
Server: in a boss round's prep m.private.bossMate { playerId, side, units } = the partner's board as UnitInfo
on its half (Match.bossMateView, finalAssault.js bossFieldPlacement); markPrivate marks the partner's
m.private with the mover's, so it follows live; a scout of either player (Match.prepFieldMeta) carries both
halves (`mate`), and its signature covers the partner's board.
Client: render/app.js syncMates draws the partner's units read-only ('m:<uid>' views: never picked, dragged
or swept with the own pieces) and the whole boss field is lit; the team panel frames the pair's avatars
(gameLogic teamFrameIds: the players still in by seat, two by two, as prepCamera — the boss field in the
fight) from the round start through the Final Assault / Hidden Core, with the official ui/battle
bg_team_border tinted the autochess green (#4ed8af; a plain ring without the local art); none for a lone
player, an eliminated player, a spectator seat, or 联防.
Overlap with PR #191 (same-field frames, issue #190; not merged): it published m.public bossPairing and
framed the partner alone in gold, 联防 included, plus a banner naming the partner; here the pairing is read
as the prep camera already does, both avatars get the official green frame, boss rounds only.
[ASSUMED] the partner's bench is not shown; one frame per avatar.

Golden unchanged. Tests: test/match/feedback5-boss-pair-prep.test.js (new, 4), the item 55 test now checks
the scouted player's own pieces (the partner's stand on the other half); SP_E2E
test/ui/feedback5-boss-pair.e2e.test.js (new, 2: both halves and the frames in prep and fight; item 55's
eliminated guest on the boss field with the leader). Docs: design/client.md §10 / §15, design/network.md,
PLAYING.md.
This commit is contained in:
sganggs committed 2026-10-07 02:29:49 +08:00
1 parent f14f03a59f
commit ef10164773
16 files changed
+436 -33

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
// Community report of 2026-10-06, item 51: 「还原原版最终boss战准备阶段:像战斗中那样把会成一组的两个玩家放在一起(靠右边的
// 那个他自己的视角就是靠右,他之前的摆位也镜像过去,干员位置面向相对boss是不变的),同组两个玩家屏幕左边的头像也像原版那样用绿框
// 框起来」. The own half (mirrored for the right-hand player, RIGHT ↔ LEFT) and its camera were already the official ones
// (research 09 §1.2, DESIGN §15); missing were the partner's pieces on the other half and the frames. Now m.private
// carries `bossMate` in a boss round's prep — the partner's board on its half as the battle places it — kept current
// when the partner moves (the partner's m.private is marked with the mover's), a scout of either player shows both
// halves, and the team panel frames the pair (gameLogic teamFrameIds). Overlap: PR #191 (same-field frames, issue #190)
// published the pairing as m.public bossPairing and framed the partner alone in gold — not merged; here the client reads
// the pairing as prepCamera already does, and both avatars get the official green frame (ui/battle bg_team_border).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { makeMatch, give, legalTileFor, chessOfTier } from './harness.js';
import { bossFieldPlacement } from '../../server/match/finalAssault.js';
import { teamFrameIds } from '../../public/js/ui/gameLogic.js';
function bossPrep(humans, seed) {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans, seed, fake: true }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { ready: true });
for (const ps of m.players.values()) { ps.ready = false; for (const k of [...ps.board.keys()]) ps.board.delete(k); ps.recompute(); }
m.flush(true);
return h;
}
const place = (h, pid, id) => {
const ps = h.ps(pid);
const tile = legalTileFor(h.m, ps, id);
const piece = give(h.m, ps, id, 'board', tile);
piece.dir = 'RIGHT';
ps.recompute();
return { piece, tile };
};
test('m.private bossMate: each player of a pair gets the other\'s board on its half — mirrored on the right, facing kept', () => {
const h = bossPrep(4, 5101);
const m = h.m;
assert.deepEqual(m.bossWaves.map((w) => w.players), [['p_0', 'p_1'], ['p_2', 'p_3']]);
const a = place(h, 'p_0', chessOfTier(1)[0]);
const b = place(h, 'p_1', chessOfTier(1)[1]);
m.flush(true);
const v0 = h.ps('p_0').privateView();
const v1 = h.ps('p_1').privateView();
assert.deepEqual([v0.bossMate.playerId, v0.bossMate.side], ['p_1', 'R']);
assert.deepEqual([v1.bossMate.playerId, v1.bossMate.side], ['p_0', 'L']);
const onR = v0.bossMate.units.find((u) => u.uid === b.piece.uid);
const wantR = bossFieldPlacement('R', b.tile[0], b.tile[1], 'RIGHT');
assert.deepEqual([onR.y, onR.x, onR.dir, onR.facing, onR.ownerId], [wantR.row, wantR.col, 'LEFT', -1, 'p_1'], 'the right half: mirrored, still facing the leader');
const onL = v1.bossMate.units.find((u) => u.uid === a.piece.uid);
const wantL = bossFieldPlacement('L', a.tile[0], a.tile[1], 'RIGHT');
assert.deepEqual([onL.y, onL.x, onL.dir, onL.ownerId], [wantL.row, wantL.col, 'RIGHT', 'p_0'], 'the left half as it is');
assert.equal(v0.bossMate.units.length, 1, 'the partner\'s board only (its bench stays its own)');
m.dispose();
});
test('a move of the partner reaches the player\'s m.private and a scout of either player at once', () => {
const h = bossPrep(4, 5102);
const m = h.m;
const b = place(h, 'p_1', chessOfTier(1)[1]);
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);
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
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');
// 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]}`]));
const before = h.allTo('p_3', 'm.field').length;
assert.deepEqual(m.handle('p_1', { t: 'g.move', uid: b.piece.uid, to: { area: 'board', row: to[0], col: to[1] }, dir: 'UP' }), { ok: true });
m.flush(true);
const want = bossFieldPlacement('R', to[0], to[1], 'UP');
const u0 = h.lastTo('p_0', 'm.private').bossMate.units.find((u) => u.uid === b.piece.uid);
assert.deepEqual([u0.y, u0.x, u0.dir], [want.row, want.col, 'UP']);
assert.ok(h.allTo('p_3', 'm.field').length > before, 'the scout of p_0 was pushed again');
scout = h.lastTo('p_3', 'm.field');
const us = scout.units.find((u) => u.uid === b.piece.uid);
assert.deepEqual([us.y, us.x, us.dir], [want.row, want.col, 'UP']);
m.dispose();
});
test('no bossMate for a lone player, outside a boss round\'s prep, or once the fight is on', () => {
const h = bossPrep(3, 5103);
const m = h.m;
assert.deepEqual(m.bossWaves.map((w) => w.players), [['p_0', 'p_1'], ['p_2']]);
assert.equal(h.ps('p_2').privateView().bossMate, undefined, 'the lone player of a 3-player Final Assault');
assert.ok(h.ps('p_0').privateView().bossMate, 'the pair has one');
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.equal(h.ps('p_0').privateView().bossMate, undefined, 'the fight: the boss field itself is shown');
m.dispose();
const h2 = makeMatch({ mode: 'coop', humans: 2, seed: 5104, fake: true }).start();
h2.toPrep(1);
assert.equal(h2.ps('p_0').privateView().bossMate, undefined, 'a normal round');
h2.m.dispose();
});
test('client: teamFrameIds frames the viewer\'s pair in a boss round (prep and fight), nobody otherwise', () => {
const players = [0, 1, 2, 3].map((i) => ({ playerId: `p_${i}`, seat: i, alive: true }));
const pub = (o) => ({ round: 14, bossRound: 14, hiddenRound: 15, phase: PHASE.PREP, players, fields: [], ...o });
const ids = (s) => [...s].sort();
assert.deepEqual(ids(teamFrameIds(pub({}), 'p_0')), ['p_0', 'p_1']);
assert.deepEqual(ids(teamFrameIds(pub({}), 'p_3')), ['p_2', 'p_3']);
assert.deepEqual(ids(teamFrameIds(pub({ phase: PHASE.ROUND_START }), 'p_2')), ['p_2', 'p_3'], 'from the round start');
assert.deepEqual(ids(teamFrameIds(pub({ phase: PHASE.SP_DRAFT, round: 15 }), 'p_1')), ['p_0', 'p_1'], 'the Hidden Core prep');
// seat 1 out: the players still in pair by seat (p_0, p_2), p_3 alone
const out = players.map((p) => (p.playerId === 'p_1' ? { ...p, alive: false } : p));
assert.deepEqual(ids(teamFrameIds(pub({ players: out }), 'p_2')), ['p_0', 'p_2']);
assert.deepEqual(ids(teamFrameIds(pub({ players: out }), 'p_3')), [], 'a lone player: no frame');
assert.deepEqual(ids(teamFrameIds(pub({ players: out }), 'p_1')), [], 'an eliminated viewer: no pair');
assert.deepEqual(ids(teamFrameIds(pub({ round: 13 }), 'p_0')), [], 'a normal round');
assert.deepEqual(ids(teamFrameIds(pub({}), 's_spec')), [], 'a spectator seat');
const fight = pub({ phase: PHASE.FINAL_ASSAULT, fields: [{ fieldId: 'b1', kind: 'boss', players: ['p_0', 'p_1'] }, { fieldId: 'b2', kind: 'boss', players: ['p_2'] }] });
assert.deepEqual(ids(teamFrameIds(fight, 'p_1')), ['p_0', 'p_1'], 'the fight: the boss field listing the viewer');
assert.deepEqual(ids(teamFrameIds(fight, 'p_2')), [], 'a lone boss field');
assert.deepEqual(ids(teamFrameIds(pub({ phase: PHASE.UNITE, round: 12 }), 'p_0')), [], '联防 is not framed (the owner asked for the boss rounds)');
});
+4 -2
View File
@@ -43,7 +43,7 @@ test('a scout of a boss round\'s prep is the boss field: the player\'s half (mir
assert.deepEqual([u.y, u.x, u.dir, u.facing], [want.row, want.col, 'LEFT', -1], 'mirrored onto the right half, still facing the leader');
const bench = meta.units.find((x) => x.y === 0);
assert.ok(bench && bench.x === 20 - 2, `the hand row on the half's bench row (boss row 0, mirrored): ${JSON.stringify(bench)}`);
for (const x of meta.units) assert.ok(x.y >= 0 && x.y <= 5 && x.x >= 10 && x.x <= 20, `on the right half: ${x.y},${x.x}`);
for (const x of meta.units.filter((x) => x.ownerId === 'p_1')) assert.ok(x.y >= 0 && x.y <= 5 && x.x >= 10 && x.x <= 20, `on the right half: ${x.y},${x.x}`);
// the leader stands at its spawn tile: the client's leaderStand finds it in the scout's nextEnemies
const stand = leaderStand(meta.nextEnemies, () => null, (x, y) => [[y, x]]);
assert.ok(stand && stand.entry.boss && stand.row >= 0 && stand.row <= 5, `the leader with its spawn tile: ${JSON.stringify(stand?.entry)}`);
@@ -55,7 +55,9 @@ test('a scout of a boss round\'s prep is the boss field: the player\'s half (mir
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
const left = h.lastTo('p_3', 'm.field');
assert.deepEqual([left.kind, left.side], ['boss', 'L']);
for (const x of left.units) assert.ok(x.x <= 10, 'the left half');
// its own pieces on the left half (since item 51 the partner p_1's board stands on the right half beside them)
for (const x of left.units.filter((x) => x.ownerId === 'p_0')) assert.ok(x.x <= 10, 'the left half');
assert.ok(left.units.some((x) => x.ownerId === 'p_1' && x.uid === onBoard.uid && x.x === want.col), 'p_1\'s piece on the right half');
m.dispose();
});
+146
View File
@@ -0,0 +1,146 @@
// Real-server browser E2E of the community reports of 2026-10-06, items 51 / 55 (the boss round's prep). A co-op match
// jumped to the boss round (fastServer SP_START_ROUND=boss), 2 humans (seats 0 and 3) + 2 AI seats, every board placed
// by the jump (SP_AUTO_PLACE), so the pairs are (host, AI 1) and (AI 2, guest) — the guest is the right-hand player.
// * item 51: each human's prep shows its own pieces on its half (the guest's mirrored onto the right half) and its
// partner's pieces on the other half (read-only 'm:<uid>' views from m.private bossMate, facing as the battle will
// place them), the leader standing on the boss field; the team panel frames the pair's two avatars (green, the
// official bg_team_border or a plain ring) from the prep through the Final Assault;
// * item 55: with the guest eliminated at the jump, its prep shows the followed player's half of the boss field (the
// prep scout of item 56, kind 'boss' on the boss-field prep camera) with the leader — it used to be its own empty
// normal board.
// Unit counterparts: test/match/feedback5-boss-pair-prep.test.js, feedback5-boss-prep-scout.test.js,
// feedback5-watch-follow.test.js.
//
// SP_E2E=1 node --test test/ui/feedback5-boss-pair.e2e.test.js
//
// Screenshots: test/e2e/out/boss-pair-*.png.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
const HOST_KIT = ['chess_char_4_23_a', 'chess_char_4_24_a', 'chess_char_3_06_a', 'chess_char_3_13_a'];
const GUEST_KIT = ['chess_char_1_03_a', 'chess_char_1_09_a', 'chess_char_2_01_a', 'chess_char_3_21_a'];
const BOT_KIT = ['chess_char_4_01_a', 'chess_char_4_02_a', 'chess_char_3_01_a', 'chess_char_2_06_a'];
/** What the view shows in the boss round's prep: own pieces, the partner's ('m:'), the leader, the framed rows. */
const prepView = (c) => c.page.evaluate(() => {
const s = globalThis.__SP__.store.get();
const v = globalThis.__SP_VIEW__?.raw;
const entries = v?.debug?.views ? [...v.debug.views.entries()] : [];
const pick = (pre) => entries.filter(([k]) => String(k).startsWith(pre)).map(([, x]) => ({ id: x.info?.defId, owner: x.info?.ownerId ?? null, x: Math.round(x.x), y: Math.round(x.y), dir: x.info?.dir ?? null }));
return {
me: s.me.playerId, phase: s.match.public?.phase, camKind: v?.debug?.camKind ?? null, mode: v?.mode ?? null,
field: s.match.field ? { fieldId: s.match.field.fieldId, kind: s.match.field.kind, prep: !!s.match.field.prep, side: s.match.field.side ?? null } : null,
own: pick('p:'), mates: pick('m:'), scout: entries.filter(([k]) => typeof k === 'number' || /^\d+$/.test(String(k))).length,
leader: v?.debug?.leader ? { key: v.debug.leader.key, shown: !!v.debug.leader.view?.root?.visible } : null,
framed: [...document.querySelectorAll('.team__row.is-team .team__name')].map((n) => n.textContent),
names: Object.fromEntries((s.match.public?.players || []).map((p) => [p.playerId, p.name])),
bossMate: s.match.private?.bossMate ? { playerId: s.match.private.bossMate.playerId, side: s.match.private.bossMate.side } : null,
};
});
async function coopAtBoss(P, base, prefix) {
const host = new Client(P, base, 'host', { prefix: `${prefix}-host`, w: 1280, h: 720 });
const guest = new Client(P, base, 'guest', { prefix: `${prefix}-guest`, w: 1280, h: 720 });
await host.open();
await host.enter('凯尔希');
await host.click('.mode-card', '同盟模拟');
await host.click('.diff-card', '标准模拟');
await host.click('.create-box button', '创建同盟');
const room = (await host.waitFor((s) => !!s.room?.code, 'room created')).room;
for (let i = 0; i < 2; i++) { await host.click('button', '添加 AI 队友'); await sleep(600); }
await guest.open(`?room=${room.code}`);
await guest.enter('阿米娅');
await guest.waitFor((s) => s.room?.code === room.code, 'guest joined');
for (let i = 0; i < 6; i++) { await guest.click('.room-bar__right button', '准备就绪', { optional: true, timeout: 3000 }); await sleep(500); }
await host.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
for (const c of [host, guest]) await c.click('.brief__foot .btn--primary', '准备就绪');
for (const c of [host, guest]) await c.waitFor((s) => s.phase !== 'INFO_CHECK', 'band draft', 40000);
const picked = new Set();
const t0 = Date.now();
while (picked.size < 2 && Date.now() - t0 < 120000) {
for (const c of [host, guest]) {
const s = await c.st();
if (s.phase !== 'BAND_DRAFT') { picked.add(c.label); continue; }
if (picked.has(c.label) || s.draft?.turn !== s.me) continue;
await c.click('.dband:not(.is-taken)', null, { nth: c === host ? 2 : 5 });
picked.add(c.label);
}
await sleep(250);
}
return { host, guest };
}
describe('the boss round\'s prep: the pair together, the frames, a watcher\'s boss field (items 51 / 55, real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
test('item 51: own half (mirrored on the right) + the partner\'s pieces on the other half + the pair framed, prep and fight', { timeout: 6 * 60 * 1000 }, async () => {
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 2, startRound: 'boss', idleBots: true, autoPlace: true, kits: [HOST_KIT, GUEST_KIT], botChess: BOT_KIT } });
const P = (await import('puppeteer-core')).default;
let host = null;
let guest = null;
try {
({ host, guest } = await coopAtBoss(P, srv.base, 'boss-pair'));
await host.waitFor((s) => s.phase === 'PREP' && !s.ready, 'boss-round prep', 90000);
await host.page.waitForFunction(() => [...(globalThis.__SP_VIEW__?.raw?.debug?.views?.keys() || [])].some((k) => String(k).startsWith('m:')), { timeout: 20000 });
await guest.page.waitForFunction(() => [...(globalThis.__SP_VIEW__?.raw?.debug?.views?.keys() || [])].some((k) => String(k).startsWith('m:')), { timeout: 20000 });
await sleep(800);
const hv = await prepView(host);
const gv = await prepView(guest);
await host.shot('prep');
await guest.shot('prep');
// the host holds the left half, its AI partner the right one; the guest the right half, its AI partner the left
assert.equal(hv.camKind, 'bossPrep');
assert.equal(gv.camKind, 'bossPrep');
assert.deepEqual([hv.bossMate?.side, gv.bossMate?.side], ['R', 'L'], JSON.stringify({ hv: hv.bossMate, gv: gv.bossMate }));
assert.ok(hv.own.length >= 4 && hv.own.every((u) => u.x <= 10), `host's pieces on the left half: ${JSON.stringify(hv.own)}`);
assert.ok(gv.own.length >= 4 && gv.own.every((u) => u.x >= 10), `guest's pieces mirrored onto the right half: ${JSON.stringify(gv.own)}`);
assert.ok(hv.mates.length >= 4 && hv.mates.every((u) => u.x >= 10 && u.owner === hv.bossMate.playerId), `the AI partner's pieces on the right half: ${JSON.stringify(hv.mates)}`);
assert.ok(gv.mates.length >= 4 && gv.mates.every((u) => u.x <= 10 && u.owner === gv.bossMate.playerId), `the AI partner's pieces on the left half: ${JSON.stringify(gv.mates)}`);
assert.ok(hv.mates.some((u) => u.dir === 'LEFT'), 'mirrored onto the right half: RIGHT reads LEFT');
assert.ok(hv.leader?.shown && gv.leader?.shown, 'the leader stands on the boss field for both');
assert.deepEqual(hv.framed.sort(), [hv.names[hv.me], hv.names[hv.bossMate.playerId]].sort(), 'the host and its partner framed');
assert.deepEqual(gv.framed.sort(), [gv.names[gv.me], gv.names[gv.bossMate.playerId]].sort(), 'the guest and its partner framed');
// the fight keeps the frames
await host.click('.readybtn');
await guest.click('.readybtn');
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'FINAL_ASSAULT', 'Final Assault', 90000);
await sleep(1500);
assert.deepEqual((await prepView(host)).framed.sort(), hv.framed.sort(), 'the fight: the same pair framed');
assert.deepEqual(problemsOf([host, guest]), []);
} finally {
for (const c of [host, guest]) if (c?.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n'));
await host?.close();
await guest?.close();
await srv.stop();
}
});
test('item 55: an eliminated guest\'s prep shows the followed player\'s half of the boss field with the leader', { timeout: 6 * 60 * 1000 }, async () => {
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 2, startRound: 'boss', idleBots: true, autoPlace: true, kits: [HOST_KIT, []], botChess: BOT_KIT, eliminate: [1] } });
const P = (await import('puppeteer-core')).default;
let host = null;
let guest = null;
try {
({ host, guest } = await coopAtBoss(P, srv.base, 'boss-scout'));
await host.waitFor((s) => s.phase === 'PREP' && !s.ready, 'boss-round prep', 90000);
await guest.page.waitForFunction(() => globalThis.__SP__.store.get().match.field?.kind === 'boss', { timeout: 20000 });
await sleep(1500);
const gv = await prepView(guest);
await guest.shot('scout');
assert.equal((await guest.st()).alive, false, 'the guest is eliminated');
assert.deepEqual([gv.field?.prep, gv.field?.kind, gv.camKind], [true, 'boss', 'bossPrep'], JSON.stringify(gv.field));
assert.equal(gv.field.fieldId, `n:${Object.keys(gv.names)[0]}`, 'following the first player still in (the host)');
assert.ok(gv.leader?.shown, `the leader on the boss field: ${JSON.stringify(gv.leader)}`);
assert.ok(await guest.exists('.gm__watching'), 'the read-only watching banner');
assert.deepEqual(problemsOf([host, guest]), []);
} finally {
for (const c of [host, guest]) if (c?.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n'));
await host?.close();
await guest?.close();
await srv.stop();
}
});
});