feedback5: merge CB5 — community reports (match / UI): module 特性追加 shown after the base trait (text only), a shop upgrade fills its new slot at once, spectators follow the player they picked through every phase (g.watch carries the playerId — PR #189's idea), the boss shows in boss-round prep scouting, the final-boss prep shows the pair together with green avatar frames (PR #191's idea, official colour); §25.19 after the hotkeys' §25.15; golden: the 18 bot matches (the upgrade's new card), battles unchanged

This commit is contained in:
sganggs committed 2026-10-07 02:41:19 +08:00
commit 37558f0d5e
40 files changed
+1815 -607

No files matched your search

+502 -504
View File
File diff suppressed because it is too large. Load diff
+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)');
});
@@ -0,0 +1,78 @@
// Community report of 2026-10-06, item 55: 「死亡后观战视角看不到场上的关底boss」. Reproduced in the browser (an eliminated
// guest at the boss round, notes i55-repro): in the Final Assault fight the watched boss field shows the leader, but in
// the boss round's prep the eliminated player saw its own empty normal board — and a scout of a teammate (m.field
// prep) was the normal board too, while the players themselves prepare on their half of the boss field with the leader
// standing at its spawn (render/app.js setLeader, only on the boss-field prep camera). Now the scout of a boss round's
// prep is built on the boss field: the scouted player's pieces on its half (bossFieldPlacement: rows − 7, the right
// half mirrored, RIGHT ↔ LEFT), `side` for the camera, the leader in nextEnemies with its spawn tile; with item 56 an
// eliminated viewer is pushed that scout at the round start without a tap.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { GEO, PHASE } from '../../shared/constants.js';
import { makeMatch, give, legalTileFor, chessOfTier } from './harness.js';
import { bossFieldPlacement } from '../../server/match/finalAssault.js';
import { viewKind, leaderShown } from '../../public/js/render/app/view.js';
import { leaderStand } from '../../public/js/render/prepfield.js';
function bossPrep(seed) {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 4, seed, fake: true }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { ready: true });
return h;
}
test('a scout of a boss round\'s prep is the boss field: the player\'s half (mirrored on the right), its side, the leader\'s spawn', () => {
const h = bossPrep(55);
const m = h.m;
assert.deepEqual(m.bossWaves.map((w) => w.players), [['p_0', 'p_1'], ['p_2', 'p_3']]);
const p1 = h.ps('p_1');
// a board piece facing RIGHT and one in the hand
for (const k of [...p1.board.keys()]) p1.board.delete(k);
const id = chessOfTier(1)[0];
const tile = legalTileFor(m, p1, id);
const onBoard = give(m, p1, id, 'board', tile);
onBoard.dir = 'RIGHT';
give(m, p1, chessOfTier(1)[1], 'hand', 2);
h.ps('p_3').eliminate(13);
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
const meta = h.lastTo('p_3', 'm.field');
assert.deepEqual([meta.fieldId, meta.prep, meta.kind, meta.side], ['n:p_1', true, 'boss', 'R']);
assert.deepEqual(meta.rect, GEO.BOSS_RECT);
const u = meta.units.find((x) => x.uid === onBoard.uid);
const want = bossFieldPlacement('R', tile[0], tile[1], 'RIGHT');
assert.deepEqual([u.y, u.x, u.dir, u.facing], [want.row, want.col, 'LEFT', -1], 'mirrored onto the right half, still facing the leader');
const bench = meta.units.find((x) => x.y === 0);
assert.ok(bench && bench.x === 20 - 2, `the hand row on the half's bench row (boss row 0, mirrored): ${JSON.stringify(bench)}`);
for (const x of meta.units.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)}`);
assert.deepEqual(meta.nextEnemies, m.nextEnemiesFor(p1), 'the scouted player\'s own preview (its pair\'s wave)');
// the client frames a scout with that rect on the boss-field prep camera, which shows the leader
assert.equal(viewKind('prep', { rect: meta.rect, side: meta.side }), 'bossPrep');
assert.equal(leaderShown('bossPrep'), true);
// the left player of the pair: its own half, unmirrored
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
const left = h.lastTo('p_3', 'm.field');
assert.deepEqual([left.kind, left.side], ['boss', 'L']);
// 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();
});
test('the eliminated viewer is pushed the boss-field scout at the boss round\'s start (item 56); a normal round\'s scout stays the normal board', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 3, seed: 56, fake: true }).start();
const m = h.m;
h.toPrep(1);
h.ps('p_2').eliminate(1);
h.toPrep(2);
const normal = h.lastTo('p_2', 'm.field');
assert.deepEqual([normal.fieldId, normal.kind, normal.prep, normal.side], ['n:p_0', 'normal', true, undefined]);
assert.deepEqual(normal.rect, GEO.NORMAL_RECT);
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { ready: true });
const meta = h.lastTo('p_2', 'm.field');
assert.deepEqual([meta.fieldId, meta.kind, meta.prep, meta.side], ['n:p_0', 'boss', true, 'L']);
assert.ok(leaderStand(meta.nextEnemies, () => null, (x, y) => [[y, x]]), 'the leader at its spawn');
m.dispose();
});
@@ -0,0 +1,94 @@
// Community report of 2026-10-06, item 19: 「升级商店获得新的商店位时用新卡补上,而不是空着」. The official tutorial's 休整期
// page says 「升级:消耗资金升级调度中心。升级后将出现更多的商品栏位、可调度干员以及新装备」: the upgrade itself opens the
// new level's extra slot. Until 0.2.0 the shop kept its old slot count until the next roll (a refresh or the round start),
// so a level-up from 1 to 2 (险境: 3 → 4 operator slots) or 3 to 4 (4 → 5) showed no new card. Now the upgrade adds the
// extra slots at once, each with a new card drawn at the new level, and leaves the cards already shown where they are.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch, DATA } from './harness.js';
import { GameData } from '../../server/match/gamedata.js';
const ids = (slots) => slots.map((s) => (s ? `${s.kind}:${s.id}${s.sold ? '(sold)' : ''}` : null));
const tierOf = (id) => DATA.chess[id]?.tier ?? 0;
test('a level-up opens the new level\'s operator slot at once with a new card; the cards shown stay in place', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 7, fake: true }).start();
h.toPrep(1);
const ps = h.ps('p_0');
ps.funds = 200;
assert.deepEqual(h.m.gd.shopSlots(1), { chess: 3, item: 1 });
const s1 = ps.shop.slots.slice();
assert.equal(s1.length, 4);
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
// level 2: 4 operator slots + the item slot — the three cards and the item kept (the same objects), one new card
assert.equal(ps.shop.level, 2);
assert.deepEqual(ps.shop.layout, { chess: 4, item: 1 });
assert.equal(ps.shop.slots.length, 5);
for (let i = 0; i < 3; i++) assert.equal(ps.shop.slots[i], s1[i], `operator card ${i} kept`);
assert.equal(ps.shop.slots[4], s1[3], 'the item card stays after the operator cards');
const added = ps.shop.slots[3];
assert.ok(added && added.kind === 'chess' && !added.sold, `a new operator card: ${JSON.stringify(added)}`);
assert.ok(tierOf(added.id) >= 1 && tierOf(added.id) <= 2, `drawn at the new level (tier ${tierOf(added.id)})`);
assert.equal(added.frozen, false);
// the player sees it right away (m.private shop)
assert.deepEqual(ids(ps.privateView().shop.slots), ids(ps.shop.slots));
// 2 → 3 keeps 4 operator slots: nothing rerolled
const s2 = ps.shop.slots.slice();
h.m.handle('p_0', { t: 'g.levelUp' });
assert.equal(ps.shop.level, 3);
assert.deepEqual(ps.shop.slots, s2, 'no extra slot at level 3, no reroll');
// 3 → 4: the fifth operator slot
h.m.handle('p_0', { t: 'g.levelUp' });
assert.equal(ps.shop.level, 4);
assert.deepEqual(ps.shop.layout, { chess: 5, item: 1 });
for (let i = 0; i < 4; i++) assert.equal(ps.shop.slots[i], s2[i]);
assert.equal(ps.shop.slots[5], s2[4]);
assert.ok(ps.shop.slots[4]?.kind === 'chess' && tierOf(ps.shop.slots[4].id) <= 4);
h.invariants();
h.m.dispose();
});
test('a bought card stays sold; a frozen shop freezes the new card too [ASSUMED], and the round start keeps it', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 11, fake: true }).start();
h.toPrep(1);
const ps = h.ps('p_0');
ps.funds = 200;
assert.deepEqual(h.m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.equal(ps.shop.slots[0].sold, true);
assert.deepEqual(h.m.handle('p_0', { t: 'g.freeze' }), { ok: true });
h.m.handle('p_0', { t: 'g.levelUp' });
assert.equal(ps.shop.slots[0].sold, true, 'the bought slot is not refilled');
const added = ps.shop.slots[3];
assert.ok(added && !added.sold && added.frozen === true, 'the new card follows the freeze toggle');
const keep = ps.shop.slots.filter((s) => s && !s.sold && s.frozen).map((s) => s.id);
assert.equal(keep.length, 4, 'two operator cards, the new one and the item');
// the next round start keeps every frozen card (in place) and rolls only the sold slot
h.drive(() => h.m.phase === 'PREP' && h.m.round === 2);
assert.deepEqual(ps.shop.slots.slice(1).map((s) => s.id), keep);
assert.equal(ps.shop.slots.length, 5);
h.invariants();
h.m.dispose();
});
test('every mode\'s slot table: a level-up adds exactly the slots the new level has more of (标准: 3,4,4,4,4,5)', () => {
for (const [mode, difficulty] of [['solo', 'FUNNY'], ['solo', 'NORMAL'], ['coop', 'FUNNY'], ['coop', 'ABYSS']]) {
const h = makeMatch({ mode, difficulty, seed: 3, fake: true, humans: 1, bots: mode === 'coop' ? 1 : 0 }).start();
h.toPrep(1);
const ps = h.ps('p_0');
const gd = h.m.gd;
ps.funds = 500;
for (let lv = 2; lv <= gd.maxShopLevel; lv++) {
const before = ps.shop.slots.slice();
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
const { chess, item } = gd.shopSlots(lv);
assert.equal(ps.shop.slots.length, chess + item, `${mode} ${difficulty} L${lv}`);
const grew = chess - gd.shopSlots(lv - 1).chess;
const kept = ps.shop.slots.filter((s) => before.includes(s)).length;
assert.equal(kept, before.length, `${mode} ${difficulty} L${lv}: every shown card kept`);
assert.equal(ps.shop.slots.length - before.length, grew + (item - gd.shopSlots(lv - 1).item));
}
h.invariants();
h.m.dispose();
}
assert.deepEqual([1, 2, 3, 4, 5, 6].map((l) => new GameData(DATA, 'mode_single_funny').shopSlots(l).chess), [3, 4, 4, 4, 4, 5]);
});
+156
View File
@@ -0,0 +1,156 @@
// Community report of 2026-10-06, item 56: 「死亡观战时每一回合结束都会被转回自己家」. An eliminated human (and a spectator
// seat) now follows a player through every phase reset — the official keeps observing after death (research 09 §3.1:
// self_dead_dialog KEEP_WATCH, DeadAutoObDn { obIndex, state, preparation }, FindFirstAvailObTarget): the player it last
// watched with a manual g.watch (Match.watchPref), else the first player still in (seat order, AI seats included —
// they are players of the remake). Round start: that player's prep board (m.field prep); 各自行动 (both combat modes),
// 最终攻势 / 隐秘核心: that player's field; a resync / a spectator joining in prep: the board again. The idea and most
// cases come from PR #189 by @2321Robin (watch preference), not merged; the shared-field rule of its review is solved
// with g.watch's optional `playerId` (the tapped player). The client side: battle/observe.js followedScout.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { validateC2S } from '../../shared/protocol.js';
import { makeMatch } from './harness.js';
import { followedScout } from '../../public/js/battle/observe.js';
const S = 's_spec';
test('round start: an eliminated viewer scouts the board it last watched; a spectator without a pick the first player still in', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 1001, fake: true, spectators: [S] }).start();
h.toPrep(1);
const m = h.m;
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
assert.equal(m.watchPref.get('p_0'), 'p_1', 'the manual scout is the preference');
h.ps('p_0').eliminate(1);
h.toPrep(2);
assert.equal(m.watchers.get('p_0'), 'n:p_1', 'the eliminated viewer scouts its preference again');
const meta = h.lastTo('p_0', 'm.field');
assert.deepEqual([meta?.fieldId, meta?.prep], ['n:p_1', true], 'the prep board itself was pushed');
assert.equal(m.watchPref.get('p_0'), 'p_1', 'the automatic assignment leaves the preference alone');
assert.equal(m.watchers.get(S), 'n:p_1', 'the spectator (no pick) follows the first player still in: p_1 (p_0 is out)');
assert.equal(h.lastTo(S, 'm.field')?.fieldId, 'n:p_1');
m.dispose();
});
test('the item: one human with AI teammates — eliminated, it follows an AI seat\'s board every round, never its own empty one', () => {
const h = makeMatch({ mode: 'coop', humans: 1, bots: 3, seed: 1010, fake: true }).start();
h.toPrep(1);
const m = h.m;
h.ps('p_0').eliminate(1);
h.toPrep(2);
assert.equal(m.watchers.get('p_0'), 'n:ai_0', 'the first player still in');
assert.equal(h.lastTo('p_0', 'm.field')?.fieldId, 'n:ai_0');
// a row tapped: that AI seat from now on, in combat and at the next round start
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:ai_2' }), { ok: true });
h.runToPhase(PHASE.COMBAT, 2);
assert.equal(m.watchers.get('p_0'), 'n:ai_2', 'its battle (server-run combat)');
h.toPrep(3);
assert.equal(m.watchers.get('p_0'), 'n:ai_2', 'its board again at the round start');
assert.equal(h.lastTo('p_0', 'm.field')?.fieldId, 'n:ai_2');
m.dispose();
});
test('a dead preference falls back to the first player still in; a followed player who quits mid-prep hands over at once', () => {
const h = makeMatch({ mode: 'coop', humans: 4, seed: 1002, fake: true }).start();
h.toPrep(1);
const m = h.m;
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_3' }), { ok: true });
h.ps('p_0').eliminate(1);
h.ps('p_3').eliminate(1);
h.toPrep(2);
assert.equal(m.watchers.get('p_0'), 'n:p_1', 'p_3 is gone → the first player still in (p_1)');
// p_1 quits during the prep: whoever scouted it follows the next player still in at once
m.onLeave('p_1');
assert.equal(m.watchers.get('p_0'), 'n:p_2');
assert.deepEqual([h.lastTo('p_0', 'm.field')?.fieldId, h.lastTo('p_0', 'm.field')?.prep], ['n:p_2', true]);
m.dispose();
});
test('server-run combat: the default watch starts an eliminated viewer on its preference; a mid-phase switch re-records it', () => {
const h = makeMatch({ mode: 'coop', humans: 3, seed: 1003, fake: true }).start();
h.toPrep(1);
const m = h.m;
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_2' }), { ok: true });
h.ps('p_0').eliminate(1);
h.runToPhase(PHASE.COMBAT, 1);
assert.equal(m.watchers.get('p_0'), 'n:p_2');
assert.equal(m.watchers.get('p_1'), 'n:p_1', 'a fighting player stays on its own field');
assert.equal(m.watchPref.get('p_0'), 'p_2', 'the combat assignment does not touch the preference');
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
assert.equal(m.watchers.get('p_0'), 'n:p_1');
assert.equal(m.watchPref.get('p_0'), 'p_1', 'the manual switch is the new preference');
m.dispose();
});
test('client combat: an eliminated human is started on the field of the player it last watched; a reconnect puts it back there', () => {
const h = makeMatch({ mode: 'coop', humans: 3, seed: 1004, fake: true, clientCombat: true }).start();
h.toPrep(1);
const m = h.m;
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_2' }), { ok: true });
h.ps('p_0').eliminate(1);
h.runToPhase(PHASE.COMBAT, 1);
let start = h.lastTo('p_0', 'b.start');
assert.deepEqual([start?.watch, start?.fieldId], [true, 'n:p_2'], 'the preference\'s field, not the first seat\'s');
m.onDisconnect('p_0');
m.onReconnect('p_0');
start = h.lastTo('p_0', 'b.start');
assert.deepEqual([start?.watch, start?.fieldId], [true, 'n:p_2']);
m.dispose();
});
test('Final Assault: an eliminated viewer is started on its preference\'s boss field; a shared field records the tapped player only', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 4, seed: 54, fake: true, instant: false, script: (b) => (b.kind === 'boss' ? { bossDps: 1 } : {}) }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
h.ps('p_3').eliminate(13);
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'n:p_2' }), { ok: true }, 'records the preference in prep');
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.deepEqual(m.fields.map((f) => [f.fieldId, f.players]), [['b1', ['p_0', 'p_1']], ['b2', ['p_2']]]);
assert.equal(m.watchers.get('p_3'), 'b2', 'follows p_2 into b2 without a tap');
assert.equal(m.watchers.get('p_0'), 'b1', 'a fighting player stays on its own boss field');
// PR #189's review: a shared field's id does not say who was tapped — without `playerId` nothing is recorded
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b1' }), { ok: true });
assert.equal(m.watchPref.get('p_3'), 'p_2');
// with it (the client sends the row tapped), that player is the preference; a player not on the field is not
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b1', playerId: 'p_1' }), { ok: true });
assert.equal(m.watchPref.get('p_3'), 'p_1');
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b1', playerId: 'p_2' }), { ok: true });
assert.equal(m.watchPref.get('p_3'), 'p_1', 'p_2 is not on b1');
m.dispose();
});
test('a resync in prep sends the followed board: a spectator joining, an eliminated human reconnecting (client combat)', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 1006, fake: true, clientCombat: true }).start();
h.toPrep(1);
const m = h.m;
m.addSpectator(S);
let meta = h.lastTo(S, 'm.field');
assert.deepEqual([meta?.fieldId, meta?.prep], ['n:p_0', true], 'no pick → the first player still in');
assert.equal(m.watchers.get(S), 'n:p_0', 'registered as a scout: later board changes push again');
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
h.ps('p_0').eliminate(1);
m.onDisconnect('p_0');
const n = h.allTo('p_0', 'm.field').length;
m.onReconnect('p_0');
assert.ok(h.allTo('p_0', 'm.field').length > n, 'the reconnect pushed a board');
meta = h.lastTo('p_0', 'm.field');
assert.deepEqual([meta?.fieldId, meta?.prep], ['n:p_1', true], 'the preference survived the disconnect');
m.dispose();
});
test('g.watch\'s optional playerId passes the protocol check; junk does not', () => {
assert.equal(validateC2S({ t: 'g.watch', fieldId: 'b1' }), null);
assert.equal(validateC2S({ t: 'g.watch', fieldId: 'b1', playerId: 'p_1' }), null);
assert.notEqual(validateC2S({ t: 'g.watch', fieldId: 'b1', playerId: 7 }), null);
});
test('client: followedScout adopts the server\'s prep board for an eliminated player / spectator seat only, from home', () => {
const scout = { t: 'm.field', fieldId: 'n:p_1', prep: true };
assert.equal(followedScout({ field: scout, watching: null, alive: false, myId: 'p_0' }), 'n:p_1');
assert.equal(followedScout({ field: scout, watching: null, alive: false, spectator: true, myId: 's_x' }), 'n:p_1');
assert.equal(followedScout({ field: scout, watching: null, alive: true, myId: 'p_0' }), null, 'a living player keeps its own board');
assert.equal(followedScout({ field: scout, watching: 'n:p_2', alive: false, myId: 'p_0' }), null, 'already watching');
assert.equal(followedScout({ field: { ...scout, prep: false }, alive: false, myId: 'p_0' }), null, 'a battle field');
assert.equal(followedScout({ field: { ...scout, fieldId: 'n:p_0' }, alive: false, myId: 'p_0' }), null, 'its own board');
assert.equal(followedScout({ field: null, alive: false, myId: 'p_0' }), null);
});
+6 -4
View File
@@ -1,7 +1,8 @@
// Spectator seats, the match side (community report #26, owner's decision 2026-10-04 — a remake feature: the official
// room has no spectator seat). A spectator (opts.spectators / addSpectator, server/lobby.js spectate) is shown fields like
// an ELIMINATED player in every phase — the first field of each battle (b.start watch), any field on g.watch, the 联防
// spec, the first boss field, a prep board on g.watch 'n:<pid>' — and the settlement; it never receives an m.private
// an ELIMINATED player in every phase — the field of the player it follows in each battle (b.start watch: the one it last
// watched, else the first), any field on g.watch, the 联防 spec, that player's boss field, a prep board on g.watch
// 'n:<pid>' or of the player it follows — and the settlement; it never receives an m.private
// (shop, hand, funds …), an m.toast or m.unitStats, is never a field's player or authority, and every intent but g.watch
// is refused (SPECTATOR). The platform half (seats, cap, host removal, reconnect): test/lobby.test.js; over sockets with
// the real Match: test/match/lobby-integration.test.js.
@@ -65,8 +66,9 @@ test('a spectator seat watches a whole match (各自行动, 联防, 最终攻势
}
const kinds = new Set(starts.map((x) => x.kind));
for (const k of ['normal', 'unite', 'boss']) assert.ok(kinds.has(k), `a ${k} field was shown (${[...kinds]})`);
// what it was shown in 各自行动 is what an eliminated player is shown: the first field of the phase
for (const st of starts.filter((x) => x.kind === 'normal')) assert.equal(st.fieldId, 'n:p_0');
// what it was shown in 各自行动 is what an eliminated player is shown: the field of the player it follows — p_1, the one
// it scouted itself in prep (a manual watch is the preference: item 56 of 2026-10-06); with no pick the first field
for (const st of starts.filter((x) => x.kind === 'normal')) assert.equal(st.fieldId, 'n:p_1');
// the Final Assault's end reaches the boss field it watched, like every human shown that field
const ends = h.allTo(S, 'b.end');
assert.ok(ends.some((e) => e.fieldId === starts.filter((x) => x.kind === 'boss').pop().fieldId), 'b.end of the watched boss field');
+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();
}
});
});
+149
View File
@@ -0,0 +1,149 @@
// Community report of 2026-10-06, item 16.2: 「干员调配界面很多干员的某个模组只有额外特性没有基础特性,不清楚是文本错误还是
// 实际效果也有错」. A module's trait part either rewrites the class trait (official `overrideDescripton`) or adds a line
// after it (official `additionalDescription`, PRTS 「特性追加」). The data keeps both (`traitOverride.desc` = the class trait
// or its rewrite, `moduleDesc` = the added line) and the sim keeps the class trait with the module on; the text was wrong:
// the 干员调配 module card, its 局内数值, the detail card and the 自选 module row showed the added line alone (114 of the
// 164 modules the screen offers). Now the class trait comes first and the added line after it (the module card labels it 特性追加).
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)), '../..');
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 { traitLines, fullTraitText } = await import('../../public/js/ui/loadoutModel.js');
const { traitText } = await import('../../public/js/ui/detailPanel.js');
const { ModuleInfo } = await import('../../public/js/screens/loadout.js');
const { DiyPickerView } = await import('../../public/js/screens/diy.js');
const { chessLoadout } = await import('../../public/js/ui/gameLogic.js');
const { getDefaultSource } = await import('../../server/sim/simdata.js');
const { resolveProfile } = await import('../../server/sim/professions.js');
const { data } = await import('../../public/js/data.js');
const { KITTED_CHARS } = await import('../../server/sim/content/kits/index.js');
await data.loadAll('chess', 'bonds', 'assets', 'garrisons', 'items', 'backups');
const CHESS = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
const get = (id) => CHESS[id] ?? null;
const elites = Object.values(CHESS).filter((c) => c.visible !== false && c.isGolden && Array.isArray(c.modules));
const modules = elites.flatMap((g) => g.modules.map((m) => ({ g, m })));
const added = modules.filter(({ m }) => m.traitOverride?.moduleDesc);
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);
const textOf = (v) => {
if (v == null || typeof v === 'boolean') return '';
if (typeof v === 'string' || typeof v === 'number') return String(v);
if (Array.isArray(v)) return v.map(textOf).join('');
return typeof v === 'object' ? textOf(v.props?.children) : '';
};
const richText = (v) => String([...walk(v)].find((n) => typeof n.type === 'function' && n.type.name === 'RichText')?.props.text ?? '');
/** The module card's rows: { k, text, kind } (kind = data-trait). */
const cardRows = (tree) => [...walk(tree)].filter((n) => hasClass(n, 'lo-minfo__row')).map((row) => ({
k: textOf([...walk(row)].find((n) => hasClass(n, 'lo-minfo__k'))), text: richText(row), kind: row.props['data-trait'] ?? null,
}));
test('the data: 164 elite modules offered, 114 of them add a line after the class trait (the report\'s "only the extra trait")', () => {
assert.ok(modules.length >= 160, `${modules.length} elite module records`);
assert.ok(added.length >= 110, `${added.length} modules with an added line`);
for (const { g, m } of added) {
// the record keeps the class trait (or the module's rewrite of it) beside the added line
assert.ok(m.traitOverride.desc && m.traitOverride.desc !== m.traitOverride.moduleDesc, `${g.chessId} ${m.uniEquipId}`);
}
// 隐现's MAR-X: the official candidate is additionalDescription only, so the class trait stays
const inside = get('chess_char_1_01_b').modules.find((m) => m.typeName === 'MAR-X');
assert.equal(inside.traitOverride.desc, '优先攻击空中单位');
assert.equal(inside.traitOverride.moduleDesc, '攻击空中单位时攻击力提升至110%');
});
test('traitLines / fullTraitText: the class trait, then the added line; a rewrite or a plain trait is one line', () => {
assert.deepEqual(traitLines({ desc: 'A', descRaw: '<@ba.kw>A</>', moduleDesc: 'B', moduleDescRaw: '<@ba.kw>B</>' }), { base: '<@ba.kw>A</>', added: '<@ba.kw>B</>' });
assert.equal(fullTraitText({ desc: 'A', moduleDesc: 'B' }), 'A\nB');
assert.equal(fullTraitText({ desc: 'A' }), 'A');
assert.equal(fullTraitText({ moduleDesc: 'B' }), 'B');
assert.equal(fullTraitText(null), '');
assert.deepEqual(traitLines(undefined), { base: '', added: null });
});
test('局内数值 and the detail card (traitText): every added-line module shows the class trait first, then its line', () => {
for (const { g, m } of added) {
const lo = { [g.baseId]: { module: m.uniEquipId } };
const shown = traitText(g, true, chessLoadout(g, lo, get));
const t = m.traitOverride;
assert.equal(shown, `${t.descRaw || t.desc}\n${t.moduleDescRaw || t.moduleDesc}`, `${g.chessId} ${m.typeName}`);
}
// 不装备 keeps the bare class trait; a normal chess its own
const g = get('chess_char_1_01_b');
assert.equal(traitText(g, true, chessLoadout(g, { [g.baseId]: { module: 'none' } }, get)), g.traitBase.descRaw);
assert.equal(traitText(get('chess_char_1_01_a'), false, null), get('chess_char_1_01_a').trait.descRaw);
});
test('the 干员调配 module card: 特性 = the class trait (or the rewrite), 特性追加 = the added line', () => {
for (const { g, m } of modules) {
const rows = cardRows(ModuleInfo({ m: null, golden: g, opt: { id: m.uniEquipId, rec: m, isDefault: m.isDefault } }));
const t = m.traitOverride;
const trait = rows.filter((r) => r.kind);
if (t?.moduleDesc) {
assert.deepEqual(trait.map((r) => [r.k, r.text]), [['特性', t.descRaw || t.desc], ['特性追加', t.moduleDescRaw || t.moduleDesc]], `${g.chessId} ${m.typeName}`);
} else {
assert.deepEqual(trait.map((r) => [r.k, r.text]), [['特性', t.descRaw || t.desc]], `${g.chessId} ${m.typeName}: a rewrite is one line`);
}
}
});
test('the 自选 picker\'s module rows: the class trait, then the module\'s line', () => {
const pick = { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
const slot = { slotId: 'chess_char_5_diy1_a', tier: 5, shopLevel: 5 };
const tree = DiyPickerView({ m: null, slot, picks: { [slot.slotId]: pick }, kitted: [...KITTED_CHARS], onDone() {}, onClose() {},
filter: 'all', query: '', draft: { ...pick }, onFilter() {}, onQuery() {}, onDraft() {} });
const expand = (v, d = 6) => {
if (Array.isArray(v)) return v.map((x) => expand(x, d));
if (!v || typeof v !== 'object') return v;
if (typeof v.type === 'function' && v.type.name !== 'RichText' && d > 0) { try { return expand(v.type(v.props || {}), d - 1); } catch { return v; } }
const c = v.props?.children;
return c === undefined ? v : { ...v, props: { ...v.props, children: expand(c, d) } };
};
const rows = [...walk(expand(tree))].filter((v) => v.props?.role === 'radio' && v.props['data-module'] && v.props['data-module'] !== 'none');
assert.ok(rows.length >= 1);
const backups = data.get('backups');
const mods = Object.values(backups.units.char_112_siege.forms).flatMap((f) => f.modules || []);
for (const row of rows) {
const rec = mods.find((x) => x.uniEquipId === row.props['data-module']);
const t = rec.traitOverride;
const first = [...walk(row)].find((n) => typeof n.type === 'function' && n.type.name === 'RichText');
assert.equal(first.props.text, t.moduleDesc ? `${t.descRaw || t.desc}\n${t.moduleDescRaw || t.moduleDesc}` : (t.descRaw || t.desc));
}
});
test('the sim: an added-line module keeps the class trait (the same text, targeting and attack shape as 不装备)', () => {
const ds = getDefaultSource();
const keys = ['trait', 'targetPriority', 'attackKind', 'dmgType', 'canHitFly', 'splashRadius'];
const prof = ['attack', 'dmgType', 'canHitFly', 'maxTargets', 'hitAllBlocked', 'allInRange', 'priority', 'noAttack', 'noHeal', 'blockFly'];
let same = 0;
for (const { g, m } of added) {
const a = ds.getChess(g.chessId, { moduleId: m.uniEquipId });
const b = ds.getChess(g.chessId, { moduleId: 'none' });
assert.equal(a.trait, m.traitOverride.desc, `${g.chessId} ${m.typeName}: the battle's trait text = the card's`);
if (m.traitOverride.desc !== g.traitBase.desc) continue; // 圣约送葬人 REA-Y rewrites the class trait too
const pa = resolveProfile(a), pb = resolveProfile(b);
for (const k of keys) assert.deepEqual(a[k], b[k], `${g.chessId} ${m.typeName} ${k}`);
for (const k of prof) assert.deepEqual(pa[k], pb[k], `${g.chessId} ${m.typeName} profile.${k}`);
for (const k of Object.keys(b.traitBb || {})) assert.ok(k in a.traitBb, `${g.chessId} ${m.typeName}: keeps trait bb ${k}`);
same++;
}
assert.ok(same >= 110, `${same} added-line modules keep the class trait in battle`);
});
+5 -5
View File
@@ -196,9 +196,9 @@ test('特性 and 天赋 follow the chosen module: 隐现\'s trait upgrade is MAR
const rows = (id, level, entries) => kitRows(section(id, level, entries));
const { golden } = slot(INSIDE);
const traitOf = (rs) => rs.find((r) => r.k === '特性');
// default module: the module's upgraded trait text; 不装备: the plain class trait
assert.equal(traitOf(rows(INSIDE, 'elite', {})).text, golden.trait.moduleDescRaw);
assert.match(traitOf(rows(INSIDE, 'elite', {})).text, /110%/);
// default module: the class trait, then the module's added line (item 16.2 of 2026-10-06); 不装备: the plain class trait
assert.equal(traitOf(rows(INSIDE, 'elite', {})).text, `${golden.trait.descRaw}\n${golden.trait.moduleDescRaw}`);
assert.match(traitOf(rows(INSIDE, 'elite', {})).text, /^优先攻击空中单位\n[^\n]*110%/);
assert.equal(traitOf(rows(INSIDE, 'elite', { [INSIDE]: { module: 'none' } })).text, golden.traitBase.descRaw);
assert.equal(traitOf(rows(INSIDE, 'normal', {})).text, slot(INSIDE).base.trait.descRaw, '普通: its own trait');
// 天赋: named and not hidden, the record's list
@@ -292,8 +292,8 @@ test('特性 follows the chosen module also when the skill or the module differs
const chess = JSON.parse(fs.readFileSync(new URL('../../data/chess.json', import.meta.url), 'utf8'));
const get = (id) => chess[id];
const shown = (id, lo) => traitText(get(id), true, chessLoadout(get(id), lo, get));
// 隐现 (MAR-X): its other skill keeps the module's line, not the class trait 优先攻击空中单位
assert.match(shown('chess_char_1_01_b', { chess_char_1_01_a: { skill: 0, module: null } }), /攻击空中单位时攻击力提升至/);
// 隐现 (MAR-X): its other skill keeps the module's line after the class trait 优先攻击空中单位 (item 16.2)
assert.match(shown('chess_char_1_01_b', { chess_char_1_01_a: { skill: 0, module: null } }), /^优先攻击空中单位\n攻击空中单位时攻击力提升至/);
// 信仰搅拌机: SPT-Y shows its own line; 不装备 shows the class trait
assert.match(shown('chess_char_4_01_b', { chess_char_4_01_a: { skill: get('chess_char_4_01_b').skill.index, module: 'uniequip_003_rmixer' } }), /攻击距离\+1/);
assert.match(shown('chess_char_4_01_b', { chess_char_4_01_a: { skill: get('chess_char_4_01_b').skill.index, module: 'none' } }), /能够阻挡三个敌人/);