Files
ark-stronghold-protocol/test/ui/bandDraft.test.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

198 lines
10 KiB
JavaScript

// Strategy draft 队友已选 (research 09 §5 / §7, DESIGN §14 corrections): a strategy a teammate already picked cannot be
// chosen again — the server refuses it (server/match/Match.js pickBand → BAD_TARGET '队友已选'), bots re-draw, and the
// UI marks it (screens/bandDraft.js teammateBands). Automatic assignments (12 s turn timeout, 50 s cap, a departing
// seat) give the official default 「华法琳」 only while no teammate holds it, else the first free strategy
// (Match.js defaultBand; the UI names it: bandDraft.js timeoutBand).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { makeMatch, DATA } from '../match/harness.js';
import { teammateBands, timeoutBand, allowedBands } from '../../public/js/screens/bandDraft.js';
function draftOf(o) {
const h = makeMatch({ mode: 'coop', ...o }).start();
for (const ps of h.m.players.values()) if (!ps.isBot) h.m.handle(ps.playerId, { t: 'g.infoReady' });
h.sched.advance(1);
assert.equal(h.m.phase, PHASE.BAND_DRAFT);
return h;
}
describe('server: no duplicate strategies in the co-op draft', () => {
test('a band picked by a teammate is refused with 队友已选; a free one is accepted', () => {
const h = draftOf({ humans: 3, seed: 5 });
const m = h.m;
const [first, second, third] = m.draft.order;
assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_sarkazb' }), { ok: true });
const dup = m.handle(second, { t: 'g.band', bandId: 'band_sarkazb' });
assert.equal(dup.error, ERR.BAD_TARGET, 'duplicate refused');
assert.equal(m.draftTurn(), second, 'still their turn');
assert.equal(m.bandTaken('band_sarkazb', second), true);
assert.equal(m.bandTaken('band_sarkazb', first), false, 'your own pick is not "taken by a teammate"');
assert.deepEqual(m.handle(second, { t: 'g.band', bandId: 'band_lisa' }), { ok: true });
assert.equal(m.handle(third, { t: 'g.band', bandId: 'band_lisa' }).error, ERR.BAD_TARGET);
assert.deepEqual(m.handle(third, { t: 'g.band', bandId: 'band_amiya' }), { ok: true });
const picks = Object.values(m.publicView().draft?.picks || m.draft.picks);
assert.equal(new Set(picks).size, picks.length, 'all different');
h.sched.advance(1);
assert.equal(m.phase, PHASE.BATTLE_CHECK);
m.dispose();
});
test('bots never take a strategy a teammate already holds', () => {
for (const seed of [1, 2, 3, 4, 6, 9, 11]) {
const h = draftOf({ humans: 1, bots: 3, seed });
const m = h.m;
// the human picks first when it is their turn; bots pick by themselves
h.run(() => m.phase !== PHASE.BAND_DRAFT || m.draftTurn() === 'p_0', { maxTime: 20000 });
if (m.phase === PHASE.BAND_DRAFT) assert.deepEqual(m.handle('p_0', { t: 'g.band', bandId: 'band_bldsk' }), { ok: true });
h.run(() => m.phase !== PHASE.BAND_DRAFT, { maxTime: 60000 });
const ids = [...m.players.values()].map((p) => p.bandId);
assert.equal(new Set(ids).size, ids.length, `seed ${seed}: distinct strategies ${ids}`);
for (const id of ids) assert.ok(DATA.bands[id], id);
m.dispose();
}
});
test('solo: the rule has nothing to compare against', () => {
const h = makeMatch({ mode: 'solo', seed: 2 }).start();
h.m.handle('p_0', { t: 'g.infoReady' });
h.sched.advance(1);
assert.equal(h.m.bandTaken('band_orchid', 'p_0'), false);
assert.deepEqual(h.m.handle('p_0', { t: 'g.band', bandId: 'band_orchid' }), { ok: true });
h.m.dispose();
});
});
describe('UI: teammateBands', () => {
test('maps each band a teammate picked to its pickers; never the viewer', () => {
const picks = new Map([['a', 'band_x'], ['b', 'band_y'], ['c', 'band_x'], ['me', 'band_z']]);
const t = teammateBands(picks, 'me');
assert.deepEqual([...t.keys()].sort(), ['band_x', 'band_y']);
assert.deepEqual(t.get('band_x'), ['a', 'c']);
assert.equal(t.has('band_z'), false);
assert.equal(teammateBands(null, 'me').size, 0);
});
});
describe('UI: draftSelection (review regression)', () => {
test('on my turn a selection a teammate took meanwhile moves to the first free band; otherwise it is kept', async () => {
const { draftSelection } = await import('../../public/js/screens/bandDraft.js');
const bands = [{ bandId: 'a' }, { bandId: 'b' }, { bandId: 'c' }];
const taken = new Map([['a', ['p1']]]);
assert.equal(draftSelection(null, { bands, taken, myPick: null, myTurn: false }), 'b', 'default: first free');
assert.equal(draftSelection(null, { bands, taken, myPick: 'c', myTurn: false }), 'c', 'default: my pick');
assert.equal(draftSelection('a', { bands, taken, myPick: null, myTurn: false }), 'a', 'browsing a taken band is fine while waiting');
assert.equal(draftSelection('a', { bands, taken, myPick: null, myTurn: true }), 'b', 'my turn: off the taken band');
assert.equal(draftSelection('c', { bands, taken, myPick: null, myTurn: true }), 'c', 'a free selection is kept');
assert.equal(draftSelection('a', { bands, taken, myPick: 'c', myTurn: false }), 'a', 'after my pick nothing moves');
assert.equal(draftSelection('x', { bands: [], taken, myPick: null, myTurn: true }), 'x', 'no bands: unchanged');
});
});
describe('server: automatic assignments never duplicate a teammate\'s strategy', () => {
const distinct = (m) => {
const ids = [...m.players.values()].map((p) => p.bandId);
assert.equal(new Set(ids).size, ids.length, `distinct strategies ${ids}`);
for (const id of ids) assert.ok(DATA.bands[id], id);
return ids;
};
test('12 s turn timeout: 华法琳 when free, else the first free strategy (sortId order)', () => {
const h = draftOf({ humans: 3, seed: 5 });
const m = h.m;
const [first, second, third] = m.draft.order;
assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_bldsk' }), { ok: true });
assert.equal(m.defaultBand(second), 'band_amiya', 'the next free strategy by sortId');
h.sched.advance(12001);
assert.equal(m.draft.picks[second], 'band_amiya', 'timeout never assigns 队友已选 华法琳');
assert.equal(h.ps(second).lp, DATA.bands.band_amiya.totalHp, 'the LP of the assigned strategy');
assert.equal(m.draftTurn(), third);
h.sched.advance(12001);
assert.equal(m.draft.picks[third], 'band_duyaoy');
distinct(m);
m.dispose();
});
test('the default stays 华法琳 while nobody holds it (also for a player whose teammates picked others)', () => {
const h = draftOf({ humans: 2, seed: 5 });
const m = h.m;
const [first, second] = m.draft.order;
assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_amiya' }), { ok: true });
assert.equal(m.defaultBand(second), 'band_bldsk');
h.sched.advance(12001);
assert.equal(m.draft.picks[second], 'band_bldsk');
m.dispose();
});
test('every human idle: the turn timeouts and the 50 s step cap assign distinct strategies', () => {
const h = draftOf({ humans: 4, seed: 9 });
const m = h.m;
h.run(() => m.phase !== PHASE.BAND_DRAFT, { maxTime: 120000 });
assert.equal(m.phase, PHASE.BATTLE_CHECK);
const ids = distinct(m);
assert.ok(ids.includes('band_bldsk'), 'the first idle player still gets the official default');
for (const ps of m.players.values()) assert.equal(ps.lp, DATA.bands[ps.bandId].totalHp, `${ps.playerId} LP follows its strategy`);
m.dispose();
});
test('the step cap (finishBandDraft) assigns the remaining seats one after another without duplicates', () => {
const h = draftOf({ humans: 3, seed: 5 });
const m = h.m;
const [first] = m.draft.order;
assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_bldsk' }), { ok: true });
m.finishBandDraft(true);
distinct(m);
assert.equal(m.phase, PHASE.BATTLE_CHECK);
m.dispose();
});
test('a departing seat passes with a free strategy; mixed with bots nobody shares one', () => {
const h = draftOf({ humans: 3, seed: 5 });
const m = h.m;
const [first, second] = m.draft.order;
assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_bldsk' }), { ok: true });
m.onLeave(second);
assert.notEqual(m.draft.picks[second], 'band_bldsk', 'the leaver does not duplicate 华法琳');
assert.ok(DATA.bands[m.draft.picks[second]]);
m.dispose();
for (const seed of [1, 2, 3, 4]) {
const h2 = draftOf({ humans: 2, bots: 2, seed });
h2.run(() => h2.m.phase !== PHASE.BAND_DRAFT, { maxTime: 120000 });
distinct(h2.m);
h2.m.dispose();
}
});
});
describe('UI: timeoutBand (the tip names what a timeout gives me)', () => {
const bands = allowedBands([
{ bandId: 'band_amiya', sortId: 2 }, { bandId: 'band_bldsk', sortId: 1 }, { bandId: 'band_duyaoy', sortId: 3 },
{ bandId: 'band_solo', sortId: 0, modeTypeList: ['SINGLE'] },
], 'MULTI');
test('allowedBands keeps the server order (sortId, then id) and the mode filter', () => {
assert.deepEqual(bands.map((b) => b.bandId), ['band_bldsk', 'band_amiya', 'band_duyaoy']);
});
test('华法琳 while free; else the first free one; never a taken one', () => {
assert.equal(timeoutBand(bands, new Map()), 'band_bldsk');
assert.equal(timeoutBand(bands, new Map([['band_amiya', ['a']]])), 'band_bldsk');
assert.equal(timeoutBand(bands, new Map([['band_bldsk', ['a']]])), 'band_amiya');
assert.equal(timeoutBand(bands, new Map([['band_bldsk', ['a']], ['band_amiya', ['b']]])), 'band_duyaoy');
assert.equal(timeoutBand([], new Map()), 'band_bldsk', 'no data yet: the official default');
assert.equal(timeoutBand(bands, null), 'band_bldsk');
});
test('matches the server for every combination of taken strategies', () => {
const h = draftOf({ humans: 4, seed: 3 });
const m = h.m;
const all = allowedBands(Object.values(DATA.bands), 'MULTI');
assert.deepEqual(all.map((b) => b.bandId), m.gd.bandIds(), 'same order as gd.bandIds');
const [a, b, c, d] = m.draft.order;
for (const picks of [[], ['band_bldsk'], ['band_bldsk', 'band_amiya'], ['band_amiya', 'band_duyaoy'], ['band_bldsk', 'band_amiya', 'band_duyaoy']]) {
m.draft.picks = Object.fromEntries(picks.map((id, i) => [[a, b, c][i], id]));
const taken = teammateBands(new Map(Object.entries(m.draft.picks)), d);
assert.equal(timeoutBand(all, taken), m.defaultBand(d), `picks ${picks}`);
}
m.dispose();
});
});