feedback5 WG: 补位 on the server — room.ownership { notOwned } (lenient check, stored per session / seat, a match takes the list its seat had at the start), PlayerState.standIns: the player's not-owned NORMAL chess fight as their stand-ins (battleInput standIn: true in every field, the stand-in's position / range, no summons), m.private.standIns, stand-in art in prep scouting

This commit is contained in:
sganggs committed 2026-10-05 19:13:46 +08:00
1 parent 7d20695adf
commit 24205581e1
17 files changed
+590 -24

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
// test/lobby-ownership.test.js — room.ownership end to end over WebSocket (0.2.0 补位, 干员持有): lenient validation
// (only a malformed list is refused; non-droppable ids are dropped), storage on the session (follows the player into
// rooms, survives a resume) and on the seat, seats[].notOwned handed to the Match when it starts (bots: none), never to a
// running match (ROOM_STARTED: stored for the next one — the setting is out of match), a spectator's kept on its session,
// and the real match exposing it as m.private.standIns.
import { describe, test, before, after, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../server/index.js';
import { StubMatch } from '../server/match/StubMatch.js';
import { TestClient } from './helpers/wsClient.js';
import { ERR, PHASE } from '../shared/constants.js';
const SILVER = 'chess_char_4_22_a'; // 银灰 (NORMAL)
const SARIA = 'chess_char_5_11_a'; // 塞雷娅 (NORMAL)
const CATHY = 'chess_char_4_11_a'; // 凯瑟琳 (PRESET)
class RecordingStub extends StubMatch {
static instances = [];
constructor(opts) { super(opts); this.opts = opts; RecordingStub.instances.push(this); }
}
function clientPool(getUrl) {
const open = new Set();
return {
async player(name, token) {
const c = await TestClient.connect(getUrl());
open.add(c);
const w = await c.hello(name, token);
c.id = w.playerId;
c.token = w.token;
return c;
},
async closeAll() {
await Promise.all([...open].map((c) => c.terminate().catch(() => {})));
open.clear();
},
};
}
const quietLog = () => {
const errors = [];
return { errors, log: { info() {}, warn() {}, debug() {}, error: (...a) => errors.push(a.map(String).join(' ')) } };
};
const ok = async (c, msg) => { const r = await c.request(msg); assert.equal(r.t, 'ok', `${msg.t}: ${JSON.stringify(r)}`); return r; };
const err = async (c, msg, code) => { const r = await c.request(msg); assert.equal(r.t, 'error', JSON.stringify(r)); assert.equal(r.code, code, JSON.stringify(r)); return r; };
async function createRoom(c, mode = 'coop', difficulty = 'NORMAL') {
await ok(c, { t: 'room.create', mode, difficulty });
return c.waitFor('room.state', (s) => s.hostId === c.id && s.mode === mode);
}
describe('room.ownership (lobby, stub match)', () => {
let srv;
let pool;
const cap = quietLog();
before(async () => {
srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, MatchClass: RecordingStub, heavyBurst: 12, heavyPerSec: 4 });
pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`);
});
afterEach(async () => { RecordingStub.instances = []; await pool.closeAll(); });
after(async () => { await srv?.close(); });
test('lenient: a malformed list is BAD_MSG (nothing stored); other ids are dropped; stored per session and seat; bots none', async () => {
const host = await pool.player('Host');
await err(host, { t: 'room.ownership', notOwned: { [SILVER]: true } }, ERR.BAD_MSG);
await err(host, { t: 'room.ownership', notOwned: ['bad id'] }, ERR.BAD_MSG);
// outside a room: accepted (PRESET / elite / unknown ids dropped), kept on the session
await ok(host, { t: 'room.ownership', notOwned: [SARIA, CATHY, 'chess_char_9_99_a', `${SILVER.slice(0, -1)}b`, SILVER, SILVER] });
const st = await createRoom(host);
const guest = await pool.player('Guest');
await ok(guest, { t: 'room.join', code: st.code });
await guest.waitFor('room.state', (s) => s.seats.some((x) => x && x.playerId === guest.id));
await ok(guest, { t: 'room.ownership', notOwned: [SARIA] });
await ok(guest, { t: 'room.ownership', notOwned: [SILVER] }); // re-sent: the latest wins
await ok(host, { t: 'room.addBot' });
// the guest drops and resumes: the session keeps its list
await guest.terminate();
const back = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
await back.hello('Guest', guest.token);
await ok(back, { t: 'room.ready', ready: true });
await ok(host, { t: 'room.start' });
await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
const seats = RecordingStub.instances.at(-1).opts.seats;
const by = (id) => seats.find((s) => s.playerId === id);
assert.deepEqual(by(host.id).notOwned, [SILVER, SARIA].sort());
assert.ok(Object.isFrozen(by(host.id).notOwned));
assert.deepEqual(by(guest.id).notOwned, [SILVER]);
assert.equal(seats.find((s) => s.isBot).notOwned, null, 'bots own every operator');
// during the match: refused for this match (ROOM_STARTED) but stored for the next one
const r = await err(host, { t: 'room.ownership', notOwned: [] }, ERR.ROOM_STARTED);
assert.match(r.detail || '', /next match/);
await back.terminate();
assert.deepEqual(cap.errors, []);
});
test('a spectator\'s list stays on its session (never on a seat, never handed to the match)', async () => {
const host = await pool.player('Host2');
const st = await createRoom(host);
const spec = await pool.player('Watcher');
await ok(spec, { t: 'room.spectate', code: st.code });
await spec.waitFor('room.state', (s) => (s.spectators || []).some((x) => x.playerId === spec.id));
await ok(spec, { t: 'room.ownership', notOwned: [SILVER] });
await ok(host, { t: 'room.start' });
await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
const seats = RecordingStub.instances.at(-1).opts.seats;
assert.ok(!seats.some((s) => s.playerId === spec.id));
assert.equal(seats[0].notOwned, null);
});
});
describe('room.ownership (lobby + real match)', () => {
let srv;
let pool;
const cap = quietLog();
before(async () => {
srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, seedFn: () => 4242 });
pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`);
});
afterEach(async () => { await pool.closeAll(); });
after(async () => { await srv?.close(); });
test('the list the seat had at the start is the match\'s m.private.standIns; a change during the briefing waits for the next match', async () => {
const a = await pool.player('Solo');
await ok(a, { t: 'room.ownership', notOwned: [SILVER] });
await createRoom(a, 'solo');
await ok(a, { t: 'room.start' });
await a.waitFor('m.public', (p) => p.phase === PHASE.INFO_CHECK, 5000);
const priv = await a.waitFor('m.private', (p) => Array.isArray(p.standIns), 3000);
assert.deepEqual(priv.standIns, [SILVER]);
// stored for the next match only: this match keeps [SILVER] (PlayerState.standIns is fixed; test/match/standin-ownership)
await err(a, { t: 'room.ownership', notOwned: [SARIA] }, ERR.ROOM_STARTED);
await ok(a, { t: 'g.infoReady' });
await a.waitFor('m.public', (p) => p.phase === PHASE.BAND_DRAFT, 3000);
assert.deepEqual(cap.errors, []);
});
});
+230
View File
@@ -0,0 +1,230 @@
// test/match/standin-ownership.test.js — 0.2.0 补位 end to end on the server (the approved plan, owner's decision
// 2026-10-05): the not-owned list (room.ownership → seats[].notOwned) is checked leniently, fixed for the match and
// applied to that player's own pieces only — the chess keeps its identity (name, bonds, price, merge) and its battle
// unit is the official stand-in (PlayerBattleInput `standIn: true`: the stand-in's body, skill, talents, module, range,
// position), in the player's own field, the 联防 field and the boss fields alike, client-run and server-run identical.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { C2S, validateC2S, checkNotOwned, OWNERSHIP_LIMITS, checkLoadout } from '../../shared/protocol.js';
import { isDroppableChess, standInRecord } from '../../shared/standIn.js';
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
import { DataSource, hasGeneratedData } from '../../server/sim/simdata.js';
import { uniteBattleOpts } from '../../server/match/unite.js';
import { placeClass } from '../../server/match/board.js';
import { PHASE, GEO } from '../../shared/constants.js';
import { DATA, makeMatch, give, legalTileFor } from './harness.js';
const REAL = { skip: !hasGeneratedData() };
const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
const wire = (msg) => JSON.parse(JSON.stringify(msg));
const SILVER = 'chess_char_4_22_a'; // 银灰 (NORMAL) → Sharp S2, melee
const MLYSS = 'chess_char_6_11_a'; // 缪尔赛思 (NORMAL, RANGED, summons 流形) → 郁金香 S3 + SOL-X, melee
const SARIA = 'chess_char_5_11_a'; // 塞雷娅 (NORMAL, MELEE) → Touch S3 + PHY-X, ranged
const CATHY = 'chess_char_4_11_a'; // 凯瑟琳 (PRESET): never droppable
const INSIDE = 'chess_char_1_01_a'; // 隐现 (PRESET) with a loadout choice
test('droppable chess = the 55 NORMAL base chess; checkNotOwned keeps those, drops the rest; the wire validator is structural', () => {
const all = Object.values(DATA.chess);
const drop = all.filter(isDroppableChess);
assert.equal(drop.length, 55);
assert.ok(drop.every((c) => c.chessType === 'NORMAL' && !c.isGolden && c.backup.charId !== c.charId));
assert.equal(all.filter((c) => c.chessType === 'PRESET' && isDroppableChess(c)).length, 0, 'PRESET always fields its own operator');
assert.equal(drop.filter((c) => c.visible).length, 53, '53 in this season\'s shop (2 retired forms are hidden)');
const res = checkNotOwned([SARIA, SILVER, SILVER, CATHY, chess(SILVER).goldenId, 'chess_char_9_99_a', 'chess_diy_5_1_a', MLYSS], chess);
assert.deepEqual(res, { ok: true, notOwned: [SILVER, SARIA, MLYSS].sort(), dropped: 5 });
assert.deepEqual(checkNotOwned([], chess), { ok: true, notOwned: [], dropped: 0 });
for (const bad of [null, {}, 'x', [1], ['a b'], new Array(OWNERSHIP_LIMITS.notOwned + 1).fill(SILVER)]) {
assert.equal(checkNotOwned(bad, chess).error, 'BAD_MSG', JSON.stringify(bad)?.slice(0, 40));
}
assert.ok(C2S['room.ownership']);
assert.equal(validateC2S({ t: 'room.ownership', notOwned: [SILVER, 'whatever_id'] }), null);
assert.match(validateC2S({ t: 'room.ownership', notOwned: { a: 1 } }), /bad field notOwned/);
assert.match(validateC2S({ t: 'room.ownership' }), /bad field notOwned/);
});
/** A co-op match: p_0 does not own 银灰 / 缪尔赛思 / 塞雷娅 (+ junk), p_1 owns everything, the bot never drops. */
function ownershipMatch(extra = {}) {
const loadout = checkLoadout({ [INSIDE]: { skill: 0 } }, chess).loadout;
const seats = [
{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout, notOwned: [SILVER, MLYSS, SARIA, CATHY, 'junk_id'] },
{ seat: 1, playerId: 'p_1', name: 'P1', isBot: false, connected: true },
{ seat: 2, playerId: 'ai_0', name: 'AI0', isBot: true, connected: true, notOwned: [SILVER] },
];
return makeMatch({ mode: 'coop', seats, seed: 11, ...extra });
}
function clearBoard(ps) {
for (const p of [...ps.board.values()]) ps.returnCopies(p);
ps.board.clear();
for (let i = 0; i < ps.hand.length; i++) if (ps.hand[i]) { ps.returnCopies(ps.hand[i]); ps.hand[i] = null; }
ps.recompute();
}
test('seats[].notOwned → PlayerState.standIns (re-checked, frozen; bots own everything); m.private.standIns', REAL, () => {
const h = ownershipMatch().start();
const p0 = h.ps('p_0');
assert.deepEqual([...p0.standIns], [SILVER, SARIA, MLYSS].sort());
assert.ok(Object.isFrozen(p0.standIns));
assert.deepEqual([...h.ps('p_1').standIns], []);
assert.deepEqual([...h.ps('ai_0').standIns], [], 'bots own every operator');
h.m.flush(true);
assert.deepEqual(h.lastTo('p_0', 'm.private').standIns, [SILVER, SARIA, MLYSS].sort());
assert.deepEqual(h.lastTo('p_1', 'm.private').standIns, []);
// a malformed list (stale client) changes nothing; a later call cannot happen through the protocol during a match
assert.equal(p0.setNotOwned({ bad: true }), false);
assert.equal(p0.standIns.length, 3);
h.m.dispose();
});
test('a not-owned chess keeps its identity and fights as the stand-in: backup selection over the loadout, the stand-in\'s position, no summons', REAL, () => {
const h = ownershipMatch().start();
const m = h.m;
h.toPrep(1);
const p0 = h.ps('p_0');
const p1 = h.ps('p_1');
for (const [id, body, skill] of [[SILVER, 'char_609_acguad', 1], [chess(SILVER).goldenId, 'char_609_acguad', 1], [MLYSS, 'char_608_acpion', 2]]) {
const rec = chess(id);
assert.ok(p0.fieldsStandIn(rec) && p0.fieldsStandIn(id), id);
assert.ok(!p1.fieldsStandIn(rec), 'only the player who dropped it');
const f = p0.fieldRecord(rec);
assert.equal(f.charId, body);
assert.equal(f.standInFor, rec.charId);
assert.deepEqual([f.chessId, f.tier, f.price, f.bonds], [rec.chessId, rec.tier, rec.price, rec.bonds], 'identity kept');
assert.equal(p0.loadoutFor(rec).skillIndex, skill, 'the backup skill');
assert.equal(p1.fieldRecord(rec), rec);
}
// the elite's module is the backup's (郁金香 SOL-X on 缪尔赛思's elite), never the player's loadout
assert.equal(p0.loadoutFor(chess(chess(MLYSS).goldenId)).moduleId, chess(chess(MLYSS).goldenId).backup.uniEquipId);
assert.equal(p0.loadoutFor(chess(INSIDE)).skillIndex, 0, 'an owned chess keeps its loadout');
// placement class = the deployed body's position (缪尔赛思 RANGED → 郁金香 melee; 塞雷娅 MELEE → Touch ranged)
assert.equal(placeClass(p1, chess(MLYSS)), 'ranged');
assert.equal(placeClass(p0, chess(MLYSS)), 'melee');
assert.equal(placeClass(p1, chess(SARIA)), 'melee');
assert.equal(placeClass(p0, chess(SARIA)), 'ranged');
// summons: an owned 缪尔赛思 on the board sends her 流形 to the hand; a stand-in makes none (none of the 17 has one)
clearBoard(p0);
clearBoard(p1);
const t0 = legalTileFor(m, p0, MLYSS);
const t1 = legalTileFor(m, p1, MLYSS);
give(m, p0, MLYSS, 'board', t0);
give(m, p1, MLYSS, 'board', t1);
const tokens = (ps) => [...ps.hand, ...ps.temp].filter((p) => p && p.kind === 'token').map((p) => p.id);
assert.deepEqual(tokens(p1), ['token_10030_mlyss_wtrman']);
assert.deepEqual(tokens(p0), []);
// identity rules still read the chess: sell price, bonds
assert.equal(p0.gd.sellPrice(MLYSS), chess(MLYSS).sellPrice);
for (const b of chess(MLYSS).bonds) assert.ok(p0.bonds[b] && p0.bonds[b].count >= 1, `bond ${b} counts the stand-in`);
h.invariants();
m.dispose();
});
/** The browser's DataSource (battle/runner.js loadBrowserSim): own JSON copies of the data files, backups included. */
function browserSource() {
const copy = (v) => JSON.parse(JSON.stringify(v));
return new DataSource({ chess: copy(DATA.chess), enemies: copy(DATA.enemies), tokens: copy(DATA.tokens), stages: copy(DATA.stages), waves: copy(DATA.waves), backups: copy(DATA.backups) }, null);
}
test('battleInput: the player\'s not-owned chess carries standIn: true (no loadout fields); client-run and server-run battles are identical', REAL, () => {
const h = ownershipMatch().start();
const m = h.m;
h.toPrep(1);
const p0 = h.ps('p_0');
clearBoard(p0);
const sg = chess(SILVER).goldenId;
const a = give(m, p0, sg, 'board', legalTileFor(m, p0, sg));
const b = give(m, p0, INSIDE, 'board', legalTileFor(m, p0, INSIDE));
const c = give(m, p0, SARIA, 'board', legalTileFor(m, p0, SARIA));
const input = p0.battleInput();
const by = new Map(input.units.map((u) => [u.uid, u]));
assert.equal(by.get(a.uid).standIn, true);
assert.ok(!('skillIndex' in by.get(a.uid)) && !('moduleId' in by.get(a.uid)), 'a stand-in carries no loadout fields');
assert.equal(by.get(c.uid).standIn, true);
assert.ok(!('standIn' in by.get(b.uid)));
assert.equal(by.get(b.uid).skillIndex, 0, 'the owned chess keeps its loadout');
// the production spec path keeps the mark; both data sources field the same stand-in and fight the same battle
const wave = m.wave;
const spec = wire(buildBattleSpec({
battleId: 't.1', fieldId: 'n:p_0', kind: 'normal', seed: 77, modeId: m.modeId, round: 1, stageId: m.stageId,
rect: { ...GEO.NORMAL_RECT }, timeLimit: 40, players: [input], spawns: wave.spawns.map((s) => ({ ...s, ownerPlayerId: 'p_0' })),
routes: wave.routes, flags: { layerGainsEnabled: true, ...m.gd.dp }, enemyOverrides: wave.overrides ?? {}, content: 'full',
}));
assert.equal(spec.players[0].units.find((u) => u.uid === a.uid).standIn, true);
const run = (ds) => {
const bt = createBattleFromSpec(wire(spec), ds, { quiet: true, recordEvents: false });
const u = bt.allyUnits.find((x) => x.uid === a.uid);
const def = { charId: u.def.charId, standInFor: u.def.standInFor, skill: u.def.skill?.id, bonds: u.def.bonds, name: u.name };
let n = 0;
while (!bt.finished && n++ < 30 * 45) bt.step();
const res = bt.result();
return { def, time: res.time, killed: res.killed, units: bt.allyUnits.map((x) => [x.uid, x.defId, Math.round(x.hp * 100), x.stats?.dmg | 0, x.skill?.activations ?? null]), rng: bt.rng.state() };
};
const server = run(m.ds);
const client = run(browserSource());
assert.equal(server.def.charId, 'char_609_acguad');
assert.equal(server.def.standInFor, chess(sg).charId);
assert.equal(server.def.skill, standInRecord(chess(sg), DATA.backups).skill.skillId);
assert.deepEqual(server.def.bonds, chess(sg).bonds);
assert.deepEqual(client, server, 'the browser\'s battle equals the server\'s');
m.dispose();
});
test('联防 and the Final Assault field carry each player\'s own marks (p_0 stand-in, p_1 the real operator)', REAL, () => {
const h = ownershipMatch({ fake: true, clientCombat: true, clients: false }).start();
const m = h.m;
// 联防: the helpers' inputs (unite.js uniteBattleOpts → PlayerState.battleInput with carry / reached)
h.toPrep(1);
const p0 = h.ps('p_0');
const p1 = h.ps('p_1');
clearBoard(p0);
clearBoard(p1);
const a0 = give(m, p0, SILVER, 'board', legalTileFor(m, p0, SILVER));
const a1 = give(m, p1, SILVER, 'board', legalTileFor(m, p1, SILVER));
const { players } = uniteBattleOpts(m, { helpers: [p0, p1], leaked: [] }, 60);
const u0 = players.find((p) => p.playerId === 'p_0').units.find((u) => u.uid === a0.uid);
const u1 = players.find((p) => p.playerId === 'p_1').units.find((u) => u.uid === a1.uid);
assert.equal(u0.standIn, true);
assert.ok(!('standIn' in u1) && Number.isInteger(u1.skillIndex));
m.dispose();
// the boss field: jump to the Final Assault prep, deploy, and read the field's BattleSpec
const hb = ownershipMatch({ fake: true, clientCombat: true, clients: false });
const mb = hb.m;
const orig = mb.startRound.bind(mb);
let jumped = false;
mb.startRound = (r) => { if (!jumped) { jumped = true; return orig(mb.gd.bossRound); } return orig(r); };
hb.start();
hb.toPrep(mb.gd.bossRound);
const b0 = hb.ps('p_0');
const b1 = hb.ps('p_1');
clearBoard(b0);
clearBoard(b1);
const s0 = give(mb, b0, SARIA, 'board', legalTileFor(mb, b0, SARIA));
const s1 = give(mb, b1, SARIA, 'board', legalTileFor(mb, b1, SARIA));
hb.drive(() => mb.phase === PHASE.FINAL_ASSAULT);
assert.equal(mb.phase, PHASE.FINAL_ASSAULT);
const units = mb.fields.flatMap((f) => (f.spec ? f.spec.players : [])).flatMap((p) => p.units.map((u) => ({ ...u, owner: p.playerId })));
const f0 = units.find((u) => u.uid === s0.uid);
const f1 = units.find((u) => u.uid === s1.uid);
assert.ok(f0 && f1, 'both deployed on the boss field');
assert.equal(f0.standIn, true);
assert.ok(!('standIn' in f1));
mb.dispose();
});
test('prep scouting (m.field): a teammate\'s stand-in shows the stand-in\'s art on the board, the original in the hand (standInFor on both)', REAL, () => {
const h = ownershipMatch().start();
const m = h.m;
h.toPrep(1);
const p0 = h.ps('p_0');
clearBoard(p0);
const onBoard = give(m, p0, SILVER, 'board', legalTileFor(m, p0, SILVER));
const held = give(m, p0, MLYSS, 'hand');
assert.deepEqual(m.handle('p_1', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
const meta = wire(h.lastTo('p_1', 'm.field'));
const by = new Map(meta.units.map((u) => [u.uid, u]));
const bu = by.get(onBoard.uid);
assert.deepEqual([bu.defId, bu.name, bu.spine, bu.avatar, bu.standInFor, bu.skillIndex], [SILVER, 'Sharp', 'char_609_acguad', 'char_609_acguad', chess(SILVER).charId, 1]);
const hu = by.get(held.uid);
assert.deepEqual([hu.defId, hu.name, hu.spine, hu.standInFor], [MLYSS, chess(MLYSS).name, chess(MLYSS).assets.spine, chess(MLYSS).charId]);
m.dispose();
});