feedback7: merge fb7-g-small — PR #368 (AI teammates draft last, default off), #374 (double-click a teammate's avatar in prep), #321 (copy local diagnostics; whole-token / exact-value redaction) with the review's fixes
- Golden unchanged (union OK); broad node --test 5292 (5291 pass, 0 fail, 1 skipped); lint 0 errors.
This commit is contained in:
commit
fc2e57ebff
28 files changed
+1256
-35
No files matched your search
@@ -0,0 +1,186 @@
|
||||
// test/diag.test.js — the client's error log and bug-report diagnostics (public/js/diag.js; 设置 → 问题反馈): what an
|
||||
// entry keeps (message, a short stack without the page's host), repeats counted on one entry, the cap, the report's
|
||||
// lines, the battle attached as a replayable b.start, and the player names / room code / token never in the text.
|
||||
import { test, describe, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
recordError, errorLog, errorCount, clearErrorLog, messageOf, stackOf, stripHosts, redact, redactValues, secretsOf, buildDiagnostics,
|
||||
setBattleSource, currentBattle, LOG_MAX, STACK_LINES,
|
||||
} from '../public/js/diag.js';
|
||||
import { APP_VERSION } from '../shared/constants.js';
|
||||
|
||||
const v8Error = (msg = 'boom') => {
|
||||
const e = new TypeError(msg);
|
||||
e.stack = [`TypeError: ${msg}`, ...Array.from({ length: 9 }, (_, i) => ` at f${i} (http://192.168.1.5:3000/js/ui/a.js:${i + 1}:7)`)].join('\n');
|
||||
return e;
|
||||
};
|
||||
|
||||
describe('the error log', () => {
|
||||
beforeEach(() => clearErrorLog());
|
||||
|
||||
test('an entry keeps the message and the first frames of the stack, without the page\'s scheme and host', () => {
|
||||
const e = recordError('error', v8Error('x is undefined'), null, 1000);
|
||||
assert.equal(e.message, 'TypeError: x is undefined');
|
||||
const frames = e.stack.split('\n');
|
||||
assert.equal(frames.length, STACK_LINES);
|
||||
assert.equal(frames[0], 'at f0 (/js/ui/a.js:1:7)');
|
||||
assert.ok(!e.stack.includes('192.168.1.5'));
|
||||
// Firefox / Safari stacks list frames only: the first line is a frame and stays
|
||||
assert.equal(stackOf({ stack: 'g@https://sp.example/sim/x.js:3:9\nh@https://sp.example/sim/y.js:1:1' }), 'g@/sim/x.js:3:9\nh@/sim/y.js:1:1');
|
||||
assert.equal(stackOf('a string'), null);
|
||||
});
|
||||
|
||||
test('messages of a NetError, a {code, msg} push, a string and a plain object', () => {
|
||||
assert.equal(messageOf(Object.assign(new Error('请求超时'), { name: 'NetError', code: 'TIMEOUT' })), 'NetError: 请求超时 (TIMEOUT)');
|
||||
assert.equal(messageOf({ code: 'ROOM_FULL', msg: '房间已满' }), '房间已满 (ROOM_FULL)');
|
||||
assert.equal(messageOf({ code: 'OFFLINE' }), 'OFFLINE');
|
||||
assert.equal(messageOf(' plain text '), 'plain text');
|
||||
assert.equal(messageOf({ a: 1 }), '{"a":1}');
|
||||
assert.equal(messageOf(null), 'null');
|
||||
assert.ok(messageOf('x'.repeat(500)).length <= 300);
|
||||
assert.equal(stripHosts('see http://10.0.0.2:3000/x.js and https://a.b/c'), 'see /x.js and /c');
|
||||
});
|
||||
|
||||
test('the same error again raises the count of its entry; another source or message is a new entry', () => {
|
||||
recordError('sim', '[sim] skill failed', null, 1000);
|
||||
recordError('sim', '[sim] skill failed', null, 2000);
|
||||
recordError('sim', '[sim] skill failed', null, 3000);
|
||||
recordError('runner', '[sim] skill failed', null, 4000);
|
||||
recordError('runner', 'other', null, 5000);
|
||||
const log = errorLog();
|
||||
assert.deepEqual(log.map((e) => [e.source, e.count]), [['sim', 3], ['runner', 1], ['runner', 1]]);
|
||||
assert.equal(log[0].lastAt, 3000);
|
||||
assert.equal(errorCount(), 5);
|
||||
});
|
||||
|
||||
test('the log keeps the newest LOG_MAX entries and the report says how many were dropped', () => {
|
||||
for (let i = 0; i < LOG_MAX + 5; i++) recordError('error', `e${i}`, null, i * 1000);
|
||||
const log = errorLog();
|
||||
assert.equal(log.length, LOG_MAX);
|
||||
assert.equal(log[0].message, 'e5');
|
||||
const text = buildDiagnostics({ state: {}, now: 100000, pageStart: 0 });
|
||||
assert.match(text, /errors: 45 recorded, 5 oldest entries dropped/);
|
||||
assert.match(text, /#1 00:00:44 error: e44/, 'newest first');
|
||||
clearErrorLog();
|
||||
assert.equal(errorCount(), 0);
|
||||
assert.equal(errorLog().length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the report', () => {
|
||||
beforeEach(() => clearErrorLog());
|
||||
|
||||
const state = {
|
||||
connection: { status: 'online', ping: 41.6, attempt: 0, lastError: null },
|
||||
me: { playerId: 'p_1', name: '阿米娅', token: 'tok-secret-123' },
|
||||
session: { entered: true },
|
||||
room: { code: 'K7Q2', mode: 'coop', difficulty: 'HARD', seats: [{ name: '阿米娅' }, { name: 'Kal' }, { name: 'Kaltsit' }, null], spectators: [{ name: 'W' }] },
|
||||
match: {
|
||||
public: { phase: 'COMBAT', round: 9, players: [{ playerId: 'p_1', name: '阿米娅' }, { playerId: 'p_2', name: 'Kal' }] },
|
||||
battle: { kind: 'unite', fieldId: 'u', authoritative: true, done: false, speed: 2 },
|
||||
},
|
||||
};
|
||||
const env = { ua: 'Mozilla/5.0 Test', gpu: 'ANGLE (Test GPU)', viewport: '844x390', dpr: 2, touch: true, cores: 8, lang: 'zh-CN', uiLang: 'zh' };
|
||||
|
||||
test('version, time, browser, device, connection, where the player is, the battle, the settings and the errors', () => {
|
||||
recordError('request', { code: 'ROOM_FULL', msg: '房间已满' }, null, Date.UTC(2026, 9, 8, 12, 0, 5));
|
||||
recordError('sim', '[sim] onAttack (char_391_rosmon) failed: TypeError: x', null, Date.UTC(2026, 9, 8, 12, 0, 9));
|
||||
recordError('sim', '[sim] onAttack (char_391_rosmon) failed: TypeError: x', null, Date.UTC(2026, 9, 8, 12, 0, 12));
|
||||
const text = buildDiagnostics({ state, env, settings: { quality: 'low', damageNumbers: true, muted: false },
|
||||
now: Date.UTC(2026, 9, 8, 12, 1, 0), pageStart: Date.UTC(2026, 9, 8, 11, 48, 30) });
|
||||
const lines = text.split('\n');
|
||||
assert.equal(lines[0], '```text');
|
||||
assert.equal(lines[1], 'Stronghold-Protocol diagnostics');
|
||||
assert.equal(lines[2], `version: ${APP_VERSION}`);
|
||||
assert.equal(lines[3], 'time: 2026-10-08T12:01:00.000Z | page open 12m 30s');
|
||||
assert.ok(lines.includes('browser: Mozilla/5.0 Test'));
|
||||
assert.ok(lines.includes('gpu: ANGLE (Test GPU)'));
|
||||
assert.ok(lines.includes('device: 844x390 | @2x | touch | 8 threads | lang zh-CN | ui zh'));
|
||||
assert.ok(lines.includes('connection: online | ping 42 ms'));
|
||||
assert.ok(lines.includes('where: phase COMBAT | round 9 | mode coop | difficulty HARD | players 2'));
|
||||
assert.ok(lines.includes('battle: unite | field u | authoritative | running | 2x'));
|
||||
assert.ok(lines.includes('settings: quality low | damage numbers'));
|
||||
assert.ok(lines.includes('errors: 3 recorded'));
|
||||
assert.ok(lines.includes('#1 12:00:09 (x2, last 12:00:12) sim: [sim] onAttack (char_391_rosmon) failed: TypeError: x'));
|
||||
assert.ok(lines.includes('#2 12:00:05 request: 房间已满 (ROOM_FULL)'));
|
||||
assert.equal(lines.at(-1), '```');
|
||||
assert.ok(!text.includes('```json'), 'no battle attached');
|
||||
});
|
||||
|
||||
test('player names, the room code and the reconnect token never reach the text, wherever they appear', () => {
|
||||
recordError('request', '阿米娅 left room K7Q2 (token tok-secret-123)', 'Kaltsit and Kal and W');
|
||||
const text = buildDiagnostics({ state, env });
|
||||
for (const secret of ['阿米娅', 'Kaltsit', 'Kal ', 'K7Q2', 'tok-secret-123']) assert.ok(!text.includes(secret), secret);
|
||||
assert.match(text, /request: <me> left room <room> \(token <token>\)/);
|
||||
// the longer name is replaced first, so 'Kaltsit' does not become '<player2>tsit'; the one-letter 'W' as a word
|
||||
assert.ok(text.includes('\n <player3> and <player2> and <player4>\n'));
|
||||
assert.deepEqual(secretsOf(state).map(([, p]) => p), ['<me>', '<player2>', '<player3>', '<player4>', '<room>', '<token>']);
|
||||
assert.equal(redact('ab a cab W-W Windows', [['a', 'X'], ['ab', 'Y'], ['W', 'V'], ['', 'Z']]), 'Y X cab V-V Windows',
|
||||
'every value, short or long, is a whole token');
|
||||
assert.equal(redact('player and players', [['player', '<p1>'], ['Kaltsit', '<player2>']]), '<p1> and players',
|
||||
'a longer word is not cut');
|
||||
assert.equal(redact('a.b a+b', [['a.b', 'R']]), 'R a+b', 'regex characters in a value are literal');
|
||||
assert.equal(redact('char chess_char_1013_chen2', [['char', '<me>']]), '<me> chess_char_1013_chen2',
|
||||
'a name does not eat a chess id');
|
||||
});
|
||||
|
||||
test('a name like char replaces a whole token and an exact JSON string, never a longer id', () => {
|
||||
const named = {
|
||||
...state,
|
||||
me: { ...state.me, name: 'char' },
|
||||
room: { ...state.room, seats: [{ name: 'char' }, { name: 'Ship' }], spectators: [] },
|
||||
match: { ...state.match, public: { ...state.match.public, players: [{ playerId: 'p_1', name: 'char' }, { playerId: 'p_2', name: 'Ship' }] } },
|
||||
};
|
||||
recordError('sim', 'char failed on chess_char_1013_chen2', 'yanShip stayed');
|
||||
const spec = {
|
||||
v: 1,
|
||||
units: [{ id: 'chess_char_1013_chen2', key: 'char', note: 'charge the char', ship: 'yanShip' }],
|
||||
name: 'char',
|
||||
tag: 'Ship',
|
||||
};
|
||||
const text = buildDiagnostics({
|
||||
state: named, env, battle: { battleId: 'k1', fieldId: 'u', kind: 'unite', spec, time: 1 },
|
||||
});
|
||||
assert.match(text, /sim: <me> failed on chess_char_1013_chen2/);
|
||||
assert.ok(text.includes('yanShip stayed'), 'Ship does not match inside yanShip');
|
||||
const json = JSON.parse(text.slice(text.indexOf('```json\n') + 8, text.lastIndexOf('\n```')));
|
||||
assert.equal(json.spec.units[0].id, 'chess_char_1013_chen2');
|
||||
assert.equal(json.spec.units[0].key, '<me>');
|
||||
assert.equal(json.spec.units[0].note, 'charge the char');
|
||||
assert.equal(json.spec.units[0].ship, 'yanShip');
|
||||
assert.equal(json.spec.name, '<me>');
|
||||
assert.equal(json.spec.tag, '<player2>');
|
||||
assert.equal(JSON.stringify(json).includes('"char"'), false);
|
||||
assert.deepEqual(redactValues(
|
||||
{ id: 'chess_char_1013_chen2', char: 'keep', name: 'char', note: 'charge the char', list: ['char', 'chess_char_1013_chen2'] },
|
||||
[['char', '<me>']],
|
||||
), { id: 'chess_char_1013_chen2', '<me>': 'keep', name: '<me>', note: 'charge the char', list: ['<me>', 'chess_char_1013_chen2'] });
|
||||
});
|
||||
|
||||
test('the battle on screen is attached as a b.start the dev tools replay (player ids, no names)', () => {
|
||||
const spec = { v: 1, battleId: 'k1-1.9.40.u', fieldId: 'u', kind: 'unite', seed: 7, round: 9, players: [{ playerId: 'p_1' }, { playerId: 'p_2' }] };
|
||||
const text = buildDiagnostics({ state, env, battle: { battleId: spec.battleId, fieldId: 'u', kind: 'unite', spec, time: 12.4 } });
|
||||
assert.ok(text.includes('\n\nbattle k1-1.9.40.u | unite | round 9 | copied at 12.40 s\n```json\n'));
|
||||
const json = JSON.parse(text.slice(text.indexOf('```json\n') + 8, text.lastIndexOf('\n```')));
|
||||
assert.deepEqual(json, { t: 'b.start', battleId: spec.battleId, fieldId: 'u', kind: 'unite', spec });
|
||||
});
|
||||
|
||||
test('an empty page: the title screen, nothing recorded, unknown fields left out', () => {
|
||||
const text = buildDiagnostics({ state: {}, now: 0, pageStart: 0 });
|
||||
assert.match(text, /\nwhere: title\n/);
|
||||
assert.match(text, /\nconnection: unknown\n/);
|
||||
assert.match(text, /\ndevice: unknown\n/);
|
||||
assert.match(text, /\nerrors: none recorded\n```$/);
|
||||
assert.ok(!/browser:|gpu:|battle:|settings:/.test(text));
|
||||
});
|
||||
|
||||
test('the battle source: none, a battle, and a source that throws', () => {
|
||||
setBattleSource(null);
|
||||
assert.equal(currentBattle(), null);
|
||||
setBattleSource(() => ({ battleId: 'b', spec: {} }));
|
||||
assert.deepEqual(currentBattle(), { battleId: 'b', spec: {} });
|
||||
setBattleSource(() => { throw new Error('gone'); });
|
||||
assert.equal(currentBattle(), null);
|
||||
setBattleSource(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
// test/lobby-ai-last.test.js — the co-op room option 「AI 队友最后选择」 (room.setAiPicksLast {on}; GitHub #338): host-only,
|
||||
// before the match only, co-op rooms only, off by default; room.state.aiPicksLast shows it to everybody; the match gets
|
||||
// it as opts.aiPicksLast when it starts (server/match/Match.js orders the drafts with it — test/match/ai-picks-last.test.js).
|
||||
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 } from '../shared/constants.js';
|
||||
import { validateC2S } from '../shared/protocol.js';
|
||||
|
||||
function clientPool(getUrl) {
|
||||
const open = new Set();
|
||||
return {
|
||||
async connect() { const c = await TestClient.connect(getUrl()); open.add(c); return c; },
|
||||
async player(name, token) {
|
||||
const c = await this.connect();
|
||||
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; };
|
||||
const seatOf = (state, id) => state.seats.find((s) => s && s.playerId === id) || null;
|
||||
async function createRoom(c, mode = 'coop') {
|
||||
await ok(c, { t: 'room.create', mode, difficulty: 'NORMAL' });
|
||||
return c.waitFor('room.state', (s) => s.hostId === c.id && s.mode === mode);
|
||||
}
|
||||
async function joinRoom(c, code) {
|
||||
await ok(c, { t: 'room.join', code });
|
||||
return c.waitFor('room.state', (s) => s.code === code && !!seatOf(s, c.id));
|
||||
}
|
||||
|
||||
/** StubMatch that records the options each match was started with. */
|
||||
const started = [];
|
||||
class RecordingMatch extends StubMatch {
|
||||
constructor(opts) {
|
||||
super(opts);
|
||||
started.push(opts);
|
||||
}
|
||||
}
|
||||
|
||||
test('protocol: room.setAiPicksLast takes exactly a boolean `on`', () => {
|
||||
assert.equal(validateC2S({ t: 'room.setAiPicksLast', on: true }), null);
|
||||
assert.equal(validateC2S({ t: 'room.setAiPicksLast', on: false }), null);
|
||||
for (const on of [undefined, null, 1, 0, 'true', 'yes', {}, []]) {
|
||||
assert.notEqual(validateC2S({ t: 'room.setAiPicksLast', on }), null, JSON.stringify(on));
|
||||
}
|
||||
});
|
||||
|
||||
describe('room.setAiPicksLast (lobby)', () => {
|
||||
let srv;
|
||||
let pool;
|
||||
const cap = quietLog();
|
||||
before(async () => {
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, MatchClass: RecordingMatch, lobbyGraceMs: 60_000 });
|
||||
pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`);
|
||||
});
|
||||
afterEach(async () => { await pool.closeAll(); });
|
||||
after(async () => {
|
||||
await srv?.close();
|
||||
assert.deepEqual(cap.errors, [], 'no server errors logged');
|
||||
});
|
||||
|
||||
test('off by default; host-only; every seat sees it in room.state; a change un-readies the guests like the difficulty', async () => {
|
||||
const host = await pool.player('Host');
|
||||
const st = await createRoom(host);
|
||||
assert.equal(st.aiPicksLast, false);
|
||||
const guest = await pool.player('Guest');
|
||||
const js = await joinRoom(guest, st.code);
|
||||
assert.equal(js.aiPicksLast, false);
|
||||
await err(guest, { t: 'room.setAiPicksLast', on: true }, ERR.NOT_HOST);
|
||||
await ok(guest, { t: 'room.ready', ready: true });
|
||||
await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.ready);
|
||||
await ok(host, { t: 'room.setAiPicksLast', on: true });
|
||||
const on = await guest.waitFor('room.state', (s) => s.aiPicksLast === true);
|
||||
assert.equal(seatOf(on, guest.id).ready, false, 'the rules changed: the guest confirms again');
|
||||
await host.waitFor('room.state', (s) => s.aiPicksLast === true);
|
||||
// the same value again changes nothing (no broadcast needed, still ok)
|
||||
await ok(host, { t: 'room.setAiPicksLast', on: true });
|
||||
await ok(host, { t: 'room.setAiPicksLast', on: false });
|
||||
await guest.waitFor('room.state', (s) => s.aiPicksLast === false);
|
||||
// a malformed value never reaches the lobby
|
||||
await err(host, { t: 'room.setAiPicksLast', on: 'yes' }, ERR.BAD_MSG);
|
||||
});
|
||||
|
||||
test('solo rooms have no AI teammates: refused, the state stays off', async () => {
|
||||
const host = await pool.player('Solo');
|
||||
const st = await createRoom(host, 'solo');
|
||||
assert.equal(st.aiPicksLast, false);
|
||||
await err(host, { t: 'room.setAiPicksLast', on: true }, ERR.BAD_TARGET);
|
||||
});
|
||||
|
||||
test('the match gets it at the start; it cannot change during the match; the room keeps it for the next match and a new host', async () => {
|
||||
const host = await pool.player('Host');
|
||||
const st = await createRoom(host);
|
||||
const guest = await pool.player('Guest');
|
||||
await joinRoom(guest, st.code);
|
||||
await ok(host, { t: 'room.addBot' });
|
||||
await ok(host, { t: 'room.setAiPicksLast', on: true });
|
||||
await ok(guest, { t: 'room.ready', ready: true });
|
||||
await host.waitFor('room.state', (s) => s.aiPicksLast && seatOf(s, guest.id)?.ready);
|
||||
const n = started.length;
|
||||
await ok(host, { t: 'room.start' });
|
||||
await guest.waitFor('room.state', (s) => s.inMatch && s.aiPicksLast === true);
|
||||
assert.equal(started.length, n + 1);
|
||||
assert.equal(started[n].aiPicksLast, true, 'opts.aiPicksLast');
|
||||
await err(host, { t: 'room.setAiPicksLast', on: false }, ERR.ROOM_STARTED);
|
||||
await err(guest, { t: 'room.setAiPicksLast', on: false }, ERR.NOT_HOST);
|
||||
// the stub match ends once every human confirmed the briefing → back in the lobby with the option kept
|
||||
guest.clearInbox();
|
||||
await ok(host, { t: 'g.infoReady' });
|
||||
await ok(guest, { t: 'g.infoReady' });
|
||||
const back = await guest.waitFor('room.state', (s) => !s.inMatch);
|
||||
assert.equal(back.aiPicksLast, true);
|
||||
// host migration keeps the room's settings
|
||||
await ok(host, { t: 'room.leave' });
|
||||
const migrated = await guest.waitFor('room.state', (s) => s.hostId === guest.id);
|
||||
assert.equal(migrated.aiPicksLast, true);
|
||||
await ok(guest, { t: 'room.setAiPicksLast', on: false });
|
||||
await guest.waitFor('room.state', (s) => s.aiPicksLast === false);
|
||||
});
|
||||
|
||||
test('without the option the match gets aiPicksLast false', async () => {
|
||||
const host = await pool.player('Host');
|
||||
await createRoom(host);
|
||||
await ok(host, { t: 'room.addBot' });
|
||||
const n = started.length;
|
||||
await ok(host, { t: 'room.start' });
|
||||
await host.waitFor('room.state', (s) => s.inMatch);
|
||||
assert.equal(started[n].aiPicksLast, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
// The co-op room option 「AI 队友最后选择」 (opts.aiPicksLast; GitHub #338): in the strategy draft (BAND_DRAFT) and the
|
||||
// 机变 draft (SP_DRAFT) every human seat picks before every AI seat. The drawn order is kept inside each group (a stable
|
||||
// partition AFTER the usual shuffle): no extra random draw, so the option off is today's order and the option on moves
|
||||
// no random stream. A human under AI 托管, disconnected or departed is still a human (only room.addBot seats are AI).
|
||||
// A human's skip passes the turn to the other humans still to pick, ahead of the AI seats [ASSUMED]; with no other
|
||||
// human left it goes to the end as without the option.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { makeMatch } from './harness.js';
|
||||
|
||||
const isAi = (pid) => pid.startsWith('ai_');
|
||||
/** the stable humans-first partition of a drawn order */
|
||||
const humansFirst = (order) => [...order.filter((p) => !isAi(p)), ...order.filter(isAi)];
|
||||
/** humans first, then AI seats, each in seat order — 1 host + 3 AI by default */
|
||||
const seatsOf = (ids) => ids.map((playerId, seat) => ({ seat, playerId, name: playerId, isBot: isAi(playerId), connected: true }));
|
||||
|
||||
/** A match driven to BAND_DRAFT (every human confirms the briefing); `before(m)` runs while still in INFO_CHECK. */
|
||||
function toBandDraft(o, before) {
|
||||
const h = makeMatch({ mode: 'coop', ...o }).start();
|
||||
if (before) before(h.m);
|
||||
for (const ps of h.m.players.values()) if (!ps.isBot && !ps.left) h.m.handle(ps.playerId, { t: 'g.infoReady' });
|
||||
h.run(() => h.m.phase !== PHASE.INFO_CHECK);
|
||||
assert.equal(h.m.phase, PHASE.BAND_DRAFT);
|
||||
return h;
|
||||
}
|
||||
|
||||
/** The band-draft order (taken at its start, before any skip) and the next draws of the random streams after it. */
|
||||
function bandOrder(o, before) {
|
||||
const h = toBandDraft(o, before);
|
||||
const order = h.m.draft.order.slice();
|
||||
const draws = { draft: h.m.rngDraft(), shop: h.m.rngShop(), bots: h.m.rngBots(), meta: h.m.rngMeta(), waves: h.m.rngWaves() };
|
||||
h.m.dispose();
|
||||
return { order, draws };
|
||||
}
|
||||
|
||||
test('the option: off by default, on only when opts.aiPicksLast === true, never in solo', () => {
|
||||
const mk = (o) => { const h = makeMatch(o); const v = h.m.aiPicksLast; h.m.dispose(); return v; };
|
||||
assert.equal(mk({ mode: 'coop', humans: 1, bots: 3 }), false);
|
||||
assert.equal(mk({ mode: 'coop', humans: 1, bots: 3, aiPicksLast: true }), true);
|
||||
assert.equal(mk({ mode: 'coop', humans: 1, bots: 3, aiPicksLast: 'yes' }), false, 'only a real boolean');
|
||||
assert.equal(mk({ mode: 'solo', humans: 1, aiPicksLast: true }), false, 'solo: nothing to order');
|
||||
});
|
||||
|
||||
test('band draft, option off: the order is exactly today\'s for the same seed (no option = off)', () => {
|
||||
let humanNotFirst = 0;
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
const o = { seats: seatsOf(['p_0', 'ai_0', 'ai_1', 'ai_2']), seed };
|
||||
const def = bandOrder(o);
|
||||
const off = bandOrder({ ...o, aiPicksLast: false });
|
||||
assert.deepEqual(off, def, `seed ${seed}`);
|
||||
if (def.order[0] !== 'p_0') humanNotFirst++;
|
||||
}
|
||||
assert.ok(humanNotFirst > 0, 'the shuffle does put an AI first without the option (so the next test means something)');
|
||||
});
|
||||
|
||||
test('band draft, option on, 1 human + 3 AI: the human always picks first; the same shuffle, every random stream unchanged', () => {
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
const o = { seats: seatsOf(['p_0', 'ai_0', 'ai_1', 'ai_2']), seed };
|
||||
const off = bandOrder(o);
|
||||
const on = bandOrder({ ...o, aiPicksLast: true });
|
||||
assert.equal(on.order[0], 'p_0', `seed ${seed}`);
|
||||
assert.deepEqual(on.order, humansFirst(off.order), `seed ${seed}: the AI seats keep their drawn order`);
|
||||
assert.deepEqual(on.draws, off.draws, `seed ${seed}: no extra random draw`);
|
||||
}
|
||||
});
|
||||
|
||||
test('band draft, option on, 2 humans + 2 AI on interleaved seats: both humans first, in their drawn order', () => {
|
||||
const ids = ['p_0', 'ai_0', 'p_1', 'ai_1'];
|
||||
const seen = new Set();
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
const o = { seats: seatsOf(ids), seed };
|
||||
const off = bandOrder(o);
|
||||
const on = bandOrder({ ...o, aiPicksLast: true });
|
||||
assert.deepEqual(on.order, humansFirst(off.order), `seed ${seed}`);
|
||||
assert.deepEqual(on.order.slice(0, 2).sort(), ['p_0', 'p_1']);
|
||||
seen.add(on.order[0]);
|
||||
}
|
||||
assert.equal(seen.size, 2, 'the humans\' own order is still drawn (either may lead)');
|
||||
});
|
||||
|
||||
test('band draft, option on: all humans, or all AI seats (tools/matchrun), keep today\'s order', () => {
|
||||
for (const ids of [['p_0', 'p_1', 'p_2', 'p_3'], ['ai_0', 'ai_1', 'ai_2', 'ai_3']]) {
|
||||
for (let seed = 1; seed <= 4; seed++) {
|
||||
const o = { seats: seatsOf(ids), seed };
|
||||
if (ids[0] === 'ai_0') {
|
||||
// no human: INFO_CHECK ends by itself
|
||||
const run = (x) => { const h = makeMatch({ mode: 'coop', ...x }).start(); h.run(() => h.m.phase !== PHASE.INFO_CHECK); const r = h.m.draft.order.slice(); h.m.dispose(); return r; };
|
||||
assert.deepEqual(run({ ...o, aiPicksLast: true }), run(o), `all AI, seed ${seed}`);
|
||||
} else {
|
||||
assert.deepEqual(bandOrder({ ...o, aiPicksLast: true }), bandOrder(o), `all humans, seed ${seed}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('band draft, option on: a human under AI 托管, disconnected or departed is still ordered as a human', () => {
|
||||
for (let seed = 1; seed <= 8; seed++) {
|
||||
const o = { seats: seatsOf(['p_0', 'p_1', 'p_2', 'ai_0']), seed, aiPicksLast: true };
|
||||
const before = (m) => {
|
||||
m.handle('p_0', { t: 'g.autoplay', on: true });
|
||||
m.onDisconnect('p_1');
|
||||
m.onLeave('p_2');
|
||||
};
|
||||
const h = toBandDraft(o, before);
|
||||
assert.equal(h.m.draft.order[3], 'ai_0', `seed ${seed}: ${h.m.draft.order}`);
|
||||
h.m.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('band draft, option on: a skip passes the turn to the other humans, ahead of the AI seats; the last human\'s skip goes to the end [ASSUMED]', () => {
|
||||
const h = toBandDraft({ seats: seatsOf(['p_0', 'ai_0', 'p_1', 'ai_1']), seed: 3, aiPicksLast: true });
|
||||
const m = h.m;
|
||||
const [h1, h2, a1, a2] = m.draft.order;
|
||||
assert.ok(!isAi(h1) && !isAi(h2) && isAi(a1) && isAi(a2), String(m.draft.order));
|
||||
// the first human passes: behind the other human, still before the AI seats
|
||||
assert.deepEqual(m.handle(h1, { t: 'g.bandSkip' }), { ok: true });
|
||||
assert.deepEqual(m.draft.order, [h2, h1, a1, a2]);
|
||||
assert.equal(m.draftTurn(), h2);
|
||||
// the second human passes: behind the first again
|
||||
assert.deepEqual(m.handle(h2, { t: 'g.bandSkip' }), { ok: true });
|
||||
assert.deepEqual(m.draft.order, [h1, h2, a1, a2]);
|
||||
assert.deepEqual(m.handle(h1, { t: 'g.band', bandId: 'band_amiya' }), { ok: true });
|
||||
assert.equal(m.draftTurn(), h2);
|
||||
m.dispose();
|
||||
|
||||
// a lone human (host + AI): no other human to pass to — the skip moves it to the end as without the option
|
||||
const h3 = toBandDraft({ seats: seatsOf(['p_0', 'ai_0', 'ai_1', 'ai_2']), seed: 3, aiPicksLast: true });
|
||||
const order = h3.m.draft.order.slice();
|
||||
assert.equal(order[0], 'p_0');
|
||||
assert.deepEqual(h3.m.handle('p_0', { t: 'g.bandSkip' }), { ok: true });
|
||||
assert.deepEqual(h3.m.draft.order, [...order.slice(1), 'p_0']);
|
||||
h3.m.dispose();
|
||||
|
||||
// option off: the skip still goes to the very end (today's rule)
|
||||
const h4 = toBandDraft({ seats: seatsOf(['p_0', 'ai_0', 'p_1', 'ai_1']), seed: 3 });
|
||||
h4.run(() => !isAi(h4.m.draftTurn() || 'ai_'));
|
||||
const o4 = h4.m.draft.order.slice();
|
||||
const at = h4.m.draft.idx;
|
||||
const cur = h4.m.draftTurn();
|
||||
assert.deepEqual(h4.m.handle(cur, { t: 'g.bandSkip' }), { ok: true });
|
||||
assert.deepEqual(h4.m.draft.order, [...o4.slice(0, at), ...o4.slice(at + 1), cur]);
|
||||
h4.m.dispose();
|
||||
});
|
||||
|
||||
/** Drive a fake-battle match into the SP draft of round `r`; `flip(m)` runs just before it is entered. */
|
||||
function toSpDraft(o, r, flip) {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', fake: true, ...o }).start();
|
||||
const m = h.m;
|
||||
h.drive(() => m.phase === PHASE.ROUND_START && m.round === r);
|
||||
assert.equal(m.round, r);
|
||||
if (flip) flip(m);
|
||||
h.run(() => m.phase === PHASE.SP_DRAFT);
|
||||
assert.equal(m.phase, PHASE.SP_DRAFT);
|
||||
return h;
|
||||
}
|
||||
|
||||
test('机变 draft, option off vs on from the same state: on = the humans-first partition of the same shuffle; no random stream moves', () => {
|
||||
for (const [ids, seed] of [[['p_0', 'ai_0', 'ai_1', 'ai_2'], 14], [['p_0', 'ai_0', 'p_1', 'ai_1'], 5], [['p_0', 'ai_0', 'ai_1', 'ai_2'], 2]]) {
|
||||
const o = { seats: seatsOf(ids), seed };
|
||||
const off = toSpDraft(o, 3);
|
||||
const on = toSpDraft(o, 3, (m) => { m.aiPicksLast = true; });
|
||||
assert.deepEqual(on.m.sp.order, humansFirst(off.m.sp.order), `seed ${seed}`);
|
||||
assert.deepEqual(on.m.sp.cards, off.m.sp.cards, 'the same cards');
|
||||
for (const k of ['rngDraft', 'rngShop', 'rngBots', 'rngMeta', 'rngWaves']) assert.equal(on.m[k](), off.m[k](), `${k} unchanged (seed ${seed})`);
|
||||
off.m.dispose();
|
||||
on.m.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('机变 draft, option on, 1 human + 3 AI: the human picks first in every 机变 round of the match', () => {
|
||||
for (const seed of [1, 14]) {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', fake: true, seats: seatsOf(['p_0', 'ai_0', 'ai_1', 'ai_2']), seed, aiPicksLast: true }).start();
|
||||
const m = h.m;
|
||||
const firsts = {};
|
||||
const enter = m.enterSpDraft;
|
||||
m.enterSpDraft = function () {
|
||||
enter.call(this);
|
||||
if (this.sp) firsts[this.round] = this.sp.order[0];
|
||||
};
|
||||
h.drive(() => h.ended != null || m.round > 9);
|
||||
const rounds = m.gd.spRounds().filter((r) => r <= 9);
|
||||
assert.deepEqual(rounds, [3, 6, 9]);
|
||||
for (const r of rounds) if (firsts[r] != null) assert.equal(firsts[r], 'p_0', `seed ${seed} R${r}`);
|
||||
assert.ok(firsts[3] === 'p_0', 'round 3 is always reached');
|
||||
m.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('机变 draft, option on, 2 humans + 2 AI interleaved, one under AI 托管: both humans ahead of the AI seats', () => {
|
||||
const o = { seats: seatsOf(['p_0', 'ai_0', 'p_1', 'ai_1']), seed: 5 };
|
||||
const h = toSpDraft({ ...o, aiPicksLast: true }, 3, (m) => { m.handle('p_1', { t: 'g.autoplay', on: true }); });
|
||||
assert.deepEqual(h.m.sp.order.slice(0, 2).sort(), ['p_0', 'p_1']);
|
||||
assert.ok(h.m.sp.order.slice(2).every(isAi));
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('solo: the option changes nothing (seat order, no skip)', () => {
|
||||
const run = (aiPicksLast) => {
|
||||
const h = makeMatch({ mode: 'solo', humans: 1, seed: 7, aiPicksLast }).start();
|
||||
h.m.handle('p_0', { t: 'g.infoReady' });
|
||||
h.run(() => h.m.phase !== PHASE.INFO_CHECK);
|
||||
const r = { order: h.m.draft.order.slice(), next: h.m.rngDraft() };
|
||||
h.m.dispose();
|
||||
return r;
|
||||
};
|
||||
assert.deepEqual(run(true), run(undefined));
|
||||
});
|
||||
@@ -7,7 +7,8 @@
|
||||
// seed, matchNo (the room's match number: part of the battleId prefix), data (default: real data/*.json),
|
||||
// fake (true → test/match/fakeBattle.js as BattleClass), script (FakeBattle.script), registry, instant (virtual
|
||||
// scheduler runs battles synchronously; default true), timerScale, battleContent, botRehearsal (default 0),
|
||||
// botSliceMs (bot rehearsal slice budget; default: unbounded in virtual time).
|
||||
// botSliceMs (bot rehearsal slice budget; default: unbounded in virtual time), aiPicksLast (the room option
|
||||
// AI 队友最后选择: the drafts order every human seat before every AI seat).
|
||||
// Combat mode: clientCombat (default false here: the legacy server-run mode most suites were written for; production
|
||||
// defaults to client-side combat, DESIGN §14). With clientCombat: true every human gets a scripted browser
|
||||
// (test/match/simClient.js SimClient: h.clients) unless clients: false; pace 'instant' | 'paced', perPlayer
|
||||
@@ -77,6 +78,7 @@ export function makeMatch(o = {}) {
|
||||
clientCombat: o.clientCombat ?? false,
|
||||
verify: o.verify ?? 'off',
|
||||
headlessSliceMs: o.headlessSliceMs,
|
||||
aiPicksLast: o.aiPicksLast,
|
||||
});
|
||||
const m = h.m;
|
||||
if (m.clientCombat && o.clients !== false) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// The room screen's 「AI 队友最后选择」 switch (GitHub #338; server: room.setAiPicksLast, room.state.aiPicksLast): co-op
|
||||
// rooms only, the host toggles it, everybody else sees its state read-only; the texts go through t() with English in
|
||||
// public/i18n/en.json.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const SRC = readFileSync(path.join(ROOT, 'public/js/screens/room.js'), 'utf8');
|
||||
const EN = JSON.parse(readFileSync(path.join(ROOT, 'public/i18n/en.json'), 'utf8'));
|
||||
const mod = () => import(pathToFileURL(path.join(ROOT, 'public/js/screens/room.js')).href);
|
||||
|
||||
const room = (o = {}) => ({
|
||||
code: 'ABCD', hostId: 'h', mode: 'coop', difficulty: 'NORMAL', aiPicksLast: false,
|
||||
seats: [{ seat: 0, playerId: 'h', name: 'Host', isBot: false, ready: false, connected: true }, { seat: 1, playerId: 'g', name: 'G', isBot: false, ready: false, connected: true }, null, null],
|
||||
...o,
|
||||
});
|
||||
|
||||
test('aiLastOption: hidden in solo, editable by the host only, the state from room.state (off when absent)', async () => {
|
||||
const { aiLastOption } = await mod();
|
||||
assert.equal(aiLastOption(room({ mode: 'solo' }), 'h'), null, 'solo: no AI teammates');
|
||||
assert.deepEqual(aiLastOption(room(), 'h'), { on: false, editable: true });
|
||||
assert.deepEqual(aiLastOption(room({ aiPicksLast: true }), 'h'), { on: true, editable: true });
|
||||
assert.deepEqual(aiLastOption(room({ aiPicksLast: true }), 'g'), { on: true, editable: false });
|
||||
assert.deepEqual(aiLastOption(room({ aiPicksLast: undefined }), 'g'), { on: false, editable: false }, 'an older server');
|
||||
assert.equal(aiLastOption(null, 'h'), null);
|
||||
});
|
||||
|
||||
test('room.js sends room.setAiPicksLast { on } and shows the switch through t(); en.json has the English', () => {
|
||||
assert.match(SRC, /net\.request\('room\.setAiPicksLast', \{ on \}\)/);
|
||||
for (const zh of ['AI 队友最后选择', '策略与机变轮选时,所有博士先于 AI 队友选择', '由创建者设置']) {
|
||||
assert.ok(SRC.includes(`t('${zh}')`), zh);
|
||||
assert.equal(typeof EN[zh], 'string', `en.json: ${zh}`);
|
||||
assert.ok(EN[zh].length > 0 && !/[一-鿿]/.test(EN[zh]), `English for ${zh}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// PHASE.PREP double-click on a teammate avatar opens their field (GitHub #131, PR #374).
|
||||
// 「前往查看」 stays. Two guards: a teammate with no field is not forced open, and a click while that
|
||||
// teammate is already on screen does not open the view again (a double-click fires click first).
|
||||
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';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { prepDblClickWatches, teammateClickWatches, teammateFieldShown } from '../../public/js/ui/teamPanel.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const SRC = readFileSync(path.join(ROOT, 'public/js/ui/teamPanel.js'), 'utf8');
|
||||
|
||||
const mate = { playerId: 'p_1', fieldId: 'f1', name: '队友' };
|
||||
const observeOf = (fieldId) => ({ canObserve: () => (fieldId ? { fieldId } : { reason: '该队友当前没有战场' }) });
|
||||
|
||||
test('a prep double-click watches once when the teammate has a field and is not already on screen', () => {
|
||||
const o = { phase: PHASE.PREP, self: false, observe: observeOf('n:p_1'), watching: null, player: mate };
|
||||
assert.equal(prepDblClickWatches(o), true);
|
||||
assert.equal(prepDblClickWatches({ ...o, watching: 'n:p_1' }), false, 'already the prep scout');
|
||||
assert.equal(prepDblClickWatches({ ...o, watching: 'f1' }), false, 'already their live field');
|
||||
assert.equal(prepDblClickWatches({ ...o, askedRecently: true }), false, 'the click of this double-click already opened it');
|
||||
});
|
||||
|
||||
test('no field: the double-click does not watch; the single click still toasts through onWatch', () => {
|
||||
const o = { phase: PHASE.PREP, self: false, observe: observeOf(null), watching: null, player: mate };
|
||||
assert.equal(prepDblClickWatches(o), false);
|
||||
assert.equal(prepDblClickWatches({ ...o, observe: { canObserve: () => null } }), false);
|
||||
assert.equal(prepDblClickWatches({ ...o, observe: {} }), false);
|
||||
});
|
||||
|
||||
test('outside PREP, on your own row, or without client-combat observe, a double-click does not watch', () => {
|
||||
const base = { phase: PHASE.PREP, self: false, observe: observeOf('n:p_1'), watching: null, player: mate };
|
||||
assert.equal(prepDblClickWatches({ ...base, phase: PHASE.COMBAT }), false);
|
||||
assert.equal(prepDblClickWatches({ ...base, phase: PHASE.FINAL_ASSAULT }), false, '最终攻势 prep is not PHASE.PREP');
|
||||
assert.equal(prepDblClickWatches({ ...base, phase: PHASE.HIDDEN_CORE }), false, '隐秘核心 prep is not PHASE.PREP');
|
||||
assert.equal(prepDblClickWatches({ ...base, self: true }), false);
|
||||
assert.equal(prepDblClickWatches({ ...base, observe: null }), false, 'the click already watches in server-run combat');
|
||||
assert.equal(prepDblClickWatches({ ...base, player: null }), false);
|
||||
});
|
||||
|
||||
test('a single click while already watching that teammate does not open the view again', () => {
|
||||
assert.equal(teammateClickWatches({ watching: null, player: mate }), true, 'server-run combat: the first click watches');
|
||||
assert.equal(teammateClickWatches({ watching: 'f1', player: mate }), false);
|
||||
assert.equal(teammateClickWatches({ watching: 'n:p_1', player: mate }), false);
|
||||
assert.equal(teammateClickWatches({ watching: 'other', player: mate }), true);
|
||||
assert.equal(teammateClickWatches({ watching: null, player: mate, askedRecently: true }), false, 'the other click of a double-click');
|
||||
assert.equal(teammateClickWatches({ observe: observeOf('n:p_1'), watching: null, player: mate }), false, 'client combat opens 「前往查看」, it does not watch on click');
|
||||
assert.equal(teammateFieldShown('n:p_1', mate), true);
|
||||
assert.equal(teammateFieldShown(null, mate), false);
|
||||
});
|
||||
|
||||
test('the panel wires both guards and keeps 「前往查看」', () => {
|
||||
assert.ok(SRC.includes('onDblClick=${(e) => dblClick(e, p, self)}'));
|
||||
assert.ok(SRC.includes('prepDblClickWatches('));
|
||||
assert.ok(SRC.includes('teammateClickWatches('));
|
||||
assert.ok(SRC.includes("${t('前往查看')}"));
|
||||
});
|
||||
Reference in new issue
Block a user