The owner's decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜满加成」. Official: the account's potential (调度手册);
the 自持有 bonus to owned operators of the same name, initial and elite alike (activity_table cultivateEffectList).
- shared/protocol.js: room.loadout { entries, ops? } — `ops` = { [charId]: { potential?: 1–6, cultivate?: 0–3 } }
(isLoadoutOps, ≤ 256), checkLoadoutOps (strict like checkLoadout: an operator of the 干员调配 roster or the 自选 owned
pool — cultivationCharIds; defaults dropped, entries stored complete); absent = none set.
- server/lobby.js / net.js: checked with the entries (either refused ⇒ nothing stored), session.ops / seat.ops /
seats[].ops, match.setLoadout(playerId, loadout, ops) in INFO_CHECK only.
- PlayerState: ops re-checked and frozen (bots none); cultivationFor (shared/potential.js cultivationOf: null for a
补位 stand-in or a prototype 自选 pick); loadoutFor carries potential / cultivate; battleInput states `potential` /
`cultivate` for every owned operator (the chess as itself, an owned 自选 pick) — an old seat without ops and every bot
at 6 / 3; the 自选 summons' hand count follows the owner's potential (望's 棋子 6 below 潜能3); m.private.ops; the
scouting UnitInfo carries potential (below 6) / cultivate.
- tools/golden.mjs: the direct-spec scenarios field the default player (owned operators at 潜能 6 / 练度 3; stand-ins
and prototype picks neither), as PlayerState does.
- Golden: every battle with an owned operator moves — ATK / DEF / max HP ×1.1 from the first second (roster-001:
角峰 ends at 3527 HP instead of 3115; the first differing snapshot window is 0–10 s in every moved battle); the
matches part at round 1–8 (battle results, then the bots' boards). Unchanged: the 10 stand-in battles (a stand-in has
no 练度) and diy-135…138 (the four prototype 自选 battles). No engine error.
- Docs: design/match.md §16 (潜能 / 练度), design/network.md (room.loadout ops).
- Tests: test/match/potential.test.js (new, 4): the ops wire / data check, battle input of a human (settings, defaults,
stand-in none, elite shares the charId), an old seat and a bot at the defaults, spec → battle, scouting units; 自选
owned pick vs prototype and 望's hand count; Match.setLoadout in INFO_CHECK only, a refusal changes nothing.
test/lobby-loadout.test.js (+2): strict validation, storage beside the loadout, an older client's message, the real
match's briefing (m.private.ops) and lock. test/match/runner.test.js / diy-shop.test.js expect the new fields.
198 lines
11 KiB
JavaScript
198 lines
11 KiB
JavaScript
// test/lobby-loadout.test.js — room.loadout end to end over WebSocket (DESIGN §16): strict validation, storage on the
|
|
// session (follows the player into rooms, survives a resume) and on the seat, seats[].loadout handed to the Match
|
|
// (bots: none), re-sendable until the match leaves INFO_CHECK (real Match: accepted in the briefing, WRONG_PHASE after),
|
|
// the heavy-intent rate limit; its per-operator 潜能 / 练度 `ops` (0.2.2): validated with it, stored and handed over beside it.
|
|
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 { getData } from '../server/data.js';
|
|
import { TestClient } from './helpers/wsClient.js';
|
|
import { ERR, PHASE } from '../shared/constants.js';
|
|
|
|
const DATA = getData({ log: { warn() {}, error() {}, info() {} } });
|
|
const C = (id) => DATA.chess[id];
|
|
const INSIDE = 'chess_char_1_01_a'; // 隐现: S1 / S2 (default S2), module MAR-X
|
|
const SWIRE = 'chess_char_3_04_a'; // 琳琅诗怀雅: three modules
|
|
const SWIRE_ALT = C(C(SWIRE).goldenId).modules.find((m) => !m.isDefault).uniEquipId;
|
|
|
|
/** Stub match that records its constructor options (the seats) — it has no setLoadout. */
|
|
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.loadout (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: 6, heavyPerSec: 2 });
|
|
pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`);
|
|
});
|
|
afterEach(async () => { RecordingStub.instances = []; await pool.closeAll(); });
|
|
after(async () => { await srv?.close(); });
|
|
|
|
test('strict validation: structure (BAD_MSG) and data (BAD_TARGET); a refusal keeps the stored loadout', async () => {
|
|
const a = await pool.player('A');
|
|
await err(a, { t: 'room.loadout', entries: [] }, ERR.BAD_MSG);
|
|
await err(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0, extra: true } } }, ERR.BAD_MSG);
|
|
await err(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 2 } } }, ERR.BAD_TARGET); // no S3
|
|
await err(a, { t: 'room.loadout', entries: { [C(INSIDE).goldenId]: { skill: 0 } } }, ERR.BAD_TARGET); // elite id
|
|
await err(a, { t: 'room.loadout', entries: { chess_char_9_99_a: { skill: 0 } } }, ERR.BAD_TARGET);
|
|
await err(a, { t: 'room.loadout', entries: { [INSIDE]: { module: SWIRE_ALT } } }, ERR.BAD_TARGET); // another character's module
|
|
// outside a room: accepted, kept on the session
|
|
await ok(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } } });
|
|
await err(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 7 } } }, ERR.BAD_TARGET);
|
|
const st = await createRoom(a, 'solo');
|
|
await ok(a, { t: 'room.start' });
|
|
await a.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
|
const m = RecordingStub.instances.at(-1);
|
|
assert.equal(m.opts.roomCode, st.code);
|
|
assert.deepEqual(m.opts.seats[0].loadout, { [INSIDE]: { skill: 0, module: 'uniequip_002_inside' } }, 'the last valid loadout');
|
|
assert.deepEqual(cap.errors, []);
|
|
});
|
|
|
|
test('stored per session and seat: follows into rooms, latest wins until start, bots get none, resume keeps it', async () => {
|
|
const host = await pool.player('Host');
|
|
await ok(host, { t: 'room.loadout', entries: { [SWIRE]: { module: SWIRE_ALT } } });
|
|
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.loadout', entries: { [INSIDE]: { skill: 0, module: 'none' } } });
|
|
await ok(guest, { t: 'room.loadout', entries: { [INSIDE]: { module: 'none' } } }); // re-sent: the latest wins
|
|
await ok(host, { t: 'room.addBot' });
|
|
// the guest drops and resumes: the session keeps its loadout
|
|
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).loadout, { [SWIRE]: { skill: C(SWIRE).skill.index, module: SWIRE_ALT } });
|
|
assert.deepEqual(by(guest.id).loadout, { [INSIDE]: { skill: 1, module: 'none' } });
|
|
const bot = seats.find((s) => s.isBot);
|
|
assert.equal(bot.loadout, null, 'bots fight with the defaults');
|
|
assert.ok(Object.isFrozen(by(host.id).loadout));
|
|
// during a match without setLoadout: refused (ROOM_STARTED) but stored for the next match
|
|
await err(host, { t: 'room.loadout', entries: {} }, ERR.ROOM_STARTED);
|
|
await back.terminate();
|
|
});
|
|
|
|
test('潜能 / 练度 (ops, 0.2.2): strict validation, defaults dropped, stored per session and seat beside the loadout; a message without ops sets none', async () => {
|
|
const VENDLA = C('chess_char_1_06_a').charId; // a 特许 operator of the roster
|
|
const KALTS = 'char_003_kalts'; // an owned-6★ 自选 pick
|
|
const a = await pool.player('Ops');
|
|
await err(a, { t: 'room.loadout', entries: {}, ops: [] }, ERR.BAD_MSG);
|
|
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: {} } }, ERR.BAD_MSG);
|
|
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 0 } } }, ERR.BAD_MSG);
|
|
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 7 } } }, ERR.BAD_MSG);
|
|
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { cultivate: 4 } } }, ERR.BAD_MSG);
|
|
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 2, elite: 1 } } }, ERR.BAD_MSG);
|
|
await err(a, { t: 'room.loadout', entries: {}, ops: { char_609_acguad: { potential: 1 } } }, ERR.BAD_TARGET); // a prototype
|
|
await err(a, { t: 'room.loadout', entries: {}, ops: { char_999_nobody: { potential: 1 } } }, ERR.BAD_TARGET);
|
|
// a refusal of the ops refuses the entries too (nothing stored)
|
|
await err(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } }, ops: { char_999_nobody: { potential: 1 } } }, ERR.BAD_TARGET);
|
|
await ok(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } }, ops: { [VENDLA]: { potential: 1, cultivate: 0 }, [KALTS]: { cultivate: 2 }, [C(INSIDE).charId]: { potential: 6, cultivate: 3 } } });
|
|
await createRoom(a, 'solo');
|
|
await ok(a, { t: 'room.start' });
|
|
await a.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
|
const seat = RecordingStub.instances.at(-1).opts.seats[0];
|
|
assert.deepEqual(seat.loadout, { [INSIDE]: { skill: 0, module: 'uniequip_002_inside' } });
|
|
assert.deepEqual(seat.ops, { [VENDLA]: { potential: 1, cultivate: 0 }, [KALTS]: { potential: 6, cultivate: 2 } }, 'complete entries, defaults dropped');
|
|
assert.ok(Object.isFrozen(seat.ops) && Object.isFrozen(seat.ops[VENDLA]));
|
|
// an older client's message (no ops): none set — the next match fields every operator at 潜能 6 / 精英2 Lv.60
|
|
await err(a, { t: 'room.loadout', entries: {} }, ERR.ROOM_STARTED);
|
|
const b = await pool.player('Old');
|
|
await ok(b, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } } });
|
|
await createRoom(b, 'solo');
|
|
await ok(b, { t: 'room.start' });
|
|
await b.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
|
assert.deepEqual(RecordingStub.instances.at(-1).opts.seats[0].ops, {});
|
|
assert.deepEqual(cap.errors, []);
|
|
});
|
|
|
|
test('heavy-intent limit: a burst of room.loadout beyond the bucket is rate-limited', async () => {
|
|
const a = await pool.player('Spam');
|
|
const replies = await Promise.all(Array.from({ length: 10 }, () => a.request({ t: 'room.loadout', entries: {} })));
|
|
const codes = replies.map((r) => (r.t === 'ok' ? 'ok' : r.code));
|
|
assert.ok(codes.includes('ok'));
|
|
assert.ok(codes.includes(ERR.RATE), codes.join(','));
|
|
});
|
|
});
|
|
|
|
describe('room.loadout (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('accepted during INFO_CHECK (the briefing entry), locked afterwards; the solo briefing has no deadline', async () => {
|
|
const a = await pool.player('Solo');
|
|
await createRoom(a, 'solo');
|
|
await ok(a, { t: 'room.start' });
|
|
const pub = await a.waitFor('m.public', (p) => p.phase === PHASE.INFO_CHECK, 5000);
|
|
assert.equal(pub.deadline, 0, 'solo INFO_CHECK is untimed');
|
|
await ok(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } } });
|
|
const priv = await a.waitFor('m.private', (p) => p.loadout && p.loadout[INSIDE], 3000);
|
|
assert.deepEqual(priv.loadout, { [INSIDE]: { skill: 0, module: 'uniequip_002_inside' } });
|
|
await ok(a, { t: 'g.infoReady' });
|
|
await a.waitFor('m.public', (p) => p.phase === PHASE.BAND_DRAFT, 3000);
|
|
const r = await err(a, { t: 'room.loadout', entries: {} }, ERR.WRONG_PHASE);
|
|
assert.match(r.detail || '', /locked/);
|
|
assert.deepEqual(cap.errors, []);
|
|
});
|
|
|
|
test('潜能 / 练度 (0.2.2): taken with the loadout in the briefing (m.private.ops), stated in the battle input; locked afterwards', async () => {
|
|
const VENDLA = C('chess_char_1_06_a').charId;
|
|
const a = await pool.player('Solo2');
|
|
await createRoom(a, 'solo');
|
|
await ok(a, { t: 'room.start' });
|
|
await a.waitFor('m.public', (p) => p.phase === PHASE.INFO_CHECK, 5000);
|
|
await ok(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 2, cultivate: 1 } } });
|
|
const priv = await a.waitFor('m.private', (p) => p.ops && p.ops[VENDLA], 3000);
|
|
assert.deepEqual(priv.ops, { [VENDLA]: { potential: 2, cultivate: 1 } });
|
|
await ok(a, { t: 'g.infoReady' });
|
|
await a.waitFor('m.public', (p) => p.phase === PHASE.BAND_DRAFT, 3000);
|
|
await err(a, { t: 'room.loadout', entries: {}, ops: {} }, ERR.WRONG_PHASE);
|
|
assert.deepEqual(cap.errors, []);
|
|
});
|
|
});
|