0.2.3: prepare follow-up fixes and client features (#407, #408, #410, #413, #431, #435, #436, #440, #444)
Add special-target activation without bypassing attack cadence, cooperative card preferences and emotes, skill sound banks, per-operator voice choices, text sizing, install prompts, closed-tab recovery and unanimous setup rerolls. Retain late deployment elapsed time and resolve echo/counter pulses without recursive hook overflow. Independent final review remains pending. Golden: roster-016, roster-035, roster-036, roster-045; boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, boss-boss_7-pair, hidden-boss_9-pair; solo-HARD-9-boosted: Lemuen attack cadence. diy-132: Necras extra activation targets. coop2-NORMAL-14-standins: cooperative purchase preference and simulation fixes. hidden-boss_10-solo: echo pulse FIFO resolution. See DESIGN 28.13-28.24 for assumptions, sources and contributor credit.
This commit is contained in:
1 parent
314564c139
commit
f3658a0dd4
104 files changed
+3674
-741
No files matched your search
@@ -0,0 +1,98 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as BE from '../../server/match/botEmotes.js';
|
||||
import { EMOTE_COOLDOWN_MS } from '../../shared/constants.js';
|
||||
import { makeMatch, give, giveItem, DATA } from './harness.js';
|
||||
const E = (name) => `autochess_battle_${name}`;
|
||||
function fixture() {
|
||||
const human = { playerId: 'human', isBot: false, alive: true };
|
||||
const ai = { playerId: 'ai', isBot: true, alive: true, lastEmoteAt: -Infinity };
|
||||
const other = { playerId: 'other', isBot: true, alive: true, lastEmoteAt: -Infinity };
|
||||
const messages = [];let now = 10000;
|
||||
const m = { round: 1, order: [human, ai, other], lastResults: new Map(), sched: { now: () => now },
|
||||
alivePlayers: () => m.order.filter((p) => p.alive), broadcast: (x) => messages.push(x) };
|
||||
return { m, human, ai, other, messages, advance: () => { now += EMOTE_COOLDOWN_MS + 1; } };
|
||||
}
|
||||
test('AI emotes use the existing whitelist and cooldown, never impersonate a human', () => {
|
||||
const { m, ai, human, messages, advance } = fixture();
|
||||
assert.equal(BE.sendEmote(m, human, E('thanks')), false);
|
||||
assert.equal(BE.sendEmote(m, ai, 'bad'), false);
|
||||
assert.equal(BE.sendEmote(m, ai, E('thanks')), true);
|
||||
assert.equal(BE.sendEmote(m, ai, E('thanks')), false);
|
||||
advance();assert.equal(BE.sendEmote(m, ai, E('thanks')), true);
|
||||
ai.alive = false;advance();assert.equal(BE.sendEmote(m, ai, E('thanks')), false);
|
||||
assert.equal(messages.length, 2);
|
||||
});
|
||||
test('one eligible AI answers a human, cooling seats are skipped; AI messages do not recurse', () => {
|
||||
const { m, human, ai, other, messages } = fixture();ai.lastEmoteAt = m.sched.now();
|
||||
assert.equal(BE.onHumanEmote(m, human.playerId, E('thanks')), true);
|
||||
assert.deepEqual(messages.map((x) => x.playerId), [other.playerId]);
|
||||
assert.equal(BE.onHumanEmote(m, ai.playerId, E('thanks')), false);
|
||||
assert.equal(BE.onHumanEmote(m, 'unknown', E('thanks')), false);
|
||||
});
|
||||
test('pure AI and solo games stay silent at every entry point', () => {
|
||||
for (const pureAI of [true, false]) {
|
||||
const { m, human, ai, messages } = fixture();m.order = pureAI ? [ai] : [human];
|
||||
BE.sendEmote(m, ai, E('thanks'));BE.onHumanEmote(m, human.playerId, E('thanks'));
|
||||
BE.onSettle(m);BE.onPickCard(m, ai, { kind: 'bounty' });BE.onStartUnite(m, { helpers: [ai] });
|
||||
BE.onGiftTicker(m, ai, human.playerId);for (let n=0;n<3;n++) BE.onMerge(m, ai, { kind: 'chess' });
|
||||
assert.deepEqual(messages, []);
|
||||
}
|
||||
});
|
||||
test('settlement uses this round, breaks kill ties by damage and cannot repeat after cooldown', () => {
|
||||
const { m, ai, other, messages, advance } = fixture();
|
||||
ai.stats = { kills: 999 };other.stats = { kills: 0 };
|
||||
m.lastResults.set(ai.playerId, { killed: 2, damageDealt: 10, leaked: [{ counted: true }], perfect: false });
|
||||
m.lastResults.set(other.playerId, { killed: 2, damageDealt: 20, leaked: [], perfect: true });
|
||||
assert.equal(BE.pickSettleEmote(m, ai), E('scared'));assert.equal(BE.pickSettleEmote(m, other), E('call'));
|
||||
assert.equal(BE.onSettle(m), true);advance();assert.equal(BE.onSettle(m), false);
|
||||
assert.equal(messages.length, 2);m.round++;assert.equal(BE.onSettle(m), true);
|
||||
});
|
||||
test('third chess merge reacts once per round, not to items or every later merge', () => {
|
||||
const { m, ai, messages, advance } = fixture();
|
||||
BE.onMerge(m, ai, { kind: 'item' });
|
||||
for(let n=0;n<8;n++) { BE.onMerge(m, ai, { kind: 'chess' });advance(); }
|
||||
assert.equal(messages.length, 1);BE.resetRoundCounters(m);
|
||||
for(let n=0;n<3;n++) { BE.onMerge(m, ai, { kind: 'chess' });advance(); }
|
||||
assert.equal(messages.length, 2);
|
||||
});
|
||||
test('gift reactions identify the source player rather than an operator display name', () => {
|
||||
const { m, ai, human, other, messages } = fixture();
|
||||
assert.equal(BE.onGiftTicker(m, ai, '琳琅诗怀雅'), false);
|
||||
assert.equal(BE.onGiftTicker(m, ai, other.playerId), false);
|
||||
assert.equal(BE.onGiftTicker(m, ai, human.playerId), true);
|
||||
assert.equal(messages.length, 1);
|
||||
});
|
||||
test('environment switch suppresses every emote entry point', () => {
|
||||
const r = spawnSync(process.execPath, ['--input-type=module', '-e', `
|
||||
import {sendEmote} from './server/match/botEmotes.js';
|
||||
const ai={playerId:'ai',isBot:true,alive:true};
|
||||
if(sendEmote({order:[ai,{isBot:false}]},ai,'autochess_battle_thanks')) process.exit(2);
|
||||
`], { env: { ...process.env, SP_BOT_EMOTES: '0' }, encoding: 'utf8' });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
});
|
||||
test('actual match emote and merge hooks broadcast without errors', (t) => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 1, fake: true }).start().toPrep();t.after(() => h.m.dispose());
|
||||
const [human, ai] = h.m.order;human.lastEmoteAt = ai.lastEmoteAt = -Infinity;
|
||||
assert.equal(h.m.emote(human, E('thanks')).ok, true);
|
||||
const c = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 1);
|
||||
for (let n=0;n<3;n++) ai.acquireChess(c.chessId);
|
||||
assert.equal(h.m._botEmotesMerges.get(ai.playerId), 1);
|
||||
});
|
||||
|
||||
test('real beacon delivery from a human makes the receiving bot thank the correct player', (t) => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 31, fake: true }).start().toPrep();
|
||||
t.after(() => h.m.dispose());
|
||||
const [human, ai] = h.m.order;
|
||||
ai.isBot = true; ai.lastEmoteAt = -Infinity;
|
||||
const c = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 2 && c.bonds.length);
|
||||
const carrier = give(h.m, human, c.chessId, 'hand');
|
||||
const item = giveItem(h.m, human, 'chess_item_5_04_e_a');
|
||||
assert.equal(h.m.handle(human.playerId, { t: 'g.equip', itemUid: item.uid, targetUid: carrier.uid }).ok, true);
|
||||
h.m.round = 2;
|
||||
h.m.dispatch(human, 'onRoundStart', { round: 2 });
|
||||
assert.ok(h.bc.filter((e) => e.t === 'm.emote').some((e) => e.playerId === ai.playerId && e.id === E('thanks')));
|
||||
assert.equal(human.effects.filter((e) => e.key === 'effect:builtin_gift').length, 0);
|
||||
assert.equal(h.m.errorCount, 0);
|
||||
});
|
||||
@@ -7,12 +7,74 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, botPrepEnd, bondPlan, rangeTiles, effDps, rangeRec } from '../../server/match/bot.js';
|
||||
import { botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, botPrepEnd, bondPlan, rangeTiles, effDps, rangeRec, buyScoreOf } from '../../server/match/bot.js';
|
||||
import { parseKey, tileKey } from '../../server/match/board.js';
|
||||
import { makeMatch, checkInvariants, give, giveItem, legalTileFor, DATA } from './harness.js';
|
||||
|
||||
const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o });
|
||||
|
||||
test('user: Given a faction strategy teammate, When scoring shared high-tier chess, Then humans and bots receive the same preference even with full stock', () => {
|
||||
const { m } = makeMatch({ humans: 3, seed: 11 }).start().toPrep();
|
||||
const [ps, mate, other] = m.order;
|
||||
try {
|
||||
mate.bandId = other.bandId = null;
|
||||
ps.shop.level = 6;
|
||||
// 贾维 names 叙拉古; 铃兰 (V) and 荒芜拉普兰德 (VI) both belong to it.
|
||||
for (const id of ['chess_char_5_10_a', 'chess_char_6_18_a']) {
|
||||
mate.isBot = false;
|
||||
const ordinary = buyScoreOf(m, ps, id);
|
||||
mate.bandId = 'band_chiave';
|
||||
const cooperative = buyScoreOf(m, ps, id);
|
||||
assert.ok(cooperative < ordinary, `${m.gd.chess(id).name}: leave faction stock for the strategy`);
|
||||
mate.isBot = true;
|
||||
assert.equal(buyScoreOf(m, ps, id), cooperative, 'bot teammates count equally');
|
||||
other.bandId = 'band_chiave';
|
||||
assert.equal(buyScoreOf(m, ps, id), cooperative, 'multiple teammates do not multiply the penalty');
|
||||
mate.alive = other.alive = false;
|
||||
assert.equal(buyScoreOf(m, ps, id), ordinary, 'eliminated players no longer need cards');
|
||||
mate.alive = other.alive = true;
|
||||
other.bandId = null;
|
||||
m.gd.modeInactiveBonds.add('siracusaShip');
|
||||
const inactive = buyScoreOf(m, ps, id);
|
||||
mate.bandId = null;
|
||||
assert.equal(buyScoreOf(m, ps, id), inactive, 'inactive faction does not trigger yielding');
|
||||
m.gd.modeInactiveBonds.delete('siracusaShip');
|
||||
}
|
||||
const unaffected = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden &&
|
||||
(c.tier < 5 || !c.bonds.includes('siracusaShip')));
|
||||
for (const c of unaffected) {
|
||||
mate.bandId = null;
|
||||
const ordinary = buyScoreOf(m, ps, c.chessId);
|
||||
mate.bandId = 'band_chiave';
|
||||
assert.equal(buyScoreOf(m, ps, c.chessId), ordinary, c.name);
|
||||
}
|
||||
} finally { m.dispose(); }
|
||||
});
|
||||
|
||||
test('user: Given a teammate faction strategy, When buying or taking a reward, Then prefer an alternative but retain an immediate own merge', () => {
|
||||
const yu = 'chess_char_6_03_a';
|
||||
const nearl = 'chess_char_6_17_a';
|
||||
for (const { reward = false, ownPair = false, band = 'band_duyaoy', expected } of [
|
||||
{ expected: nearl },
|
||||
{ reward: true, expected: nearl },
|
||||
{ ownPair: true, expected: yu },
|
||||
{ band: null, expected: yu },
|
||||
]) {
|
||||
const { m } = makeMatch({ humans: 2, seed: 11 }).start().toPrep();
|
||||
const [ps, mate] = m.order;
|
||||
try {
|
||||
mate.bandId = band;
|
||||
if (ownPair) for (let i = 0; i < 2; i++) give(m, ps, yu, 'hand');
|
||||
ps.shop.level = 6;
|
||||
ps.funds = reward ? 0 : 5;
|
||||
ps.shop.slots = reward ? [] : [yu, nearl].map((id) => ({ kind: 'chess', id, basePrice: 5, sold: false }));
|
||||
if (reward) ps.pushRewardOffer('merge', { tier: 6, ids: [yu, nearl] });
|
||||
botPrepBegin(m, ps);
|
||||
assert.equal(reward ? ps.allChess()[0]?.id : ps.shop.slots.find((s) => s.sold)?.id, expected);
|
||||
} finally { m.dispose(); }
|
||||
}
|
||||
});
|
||||
|
||||
/** A 悬赏决策 card as the draft builds it (choices.js buildCards), by its enemy's name. */
|
||||
function bountyCard(enemyName) {
|
||||
const c = DATA.choices.cards.bounty.find((x) => x.draft && DATA.enemies[x.enemyKey] && DATA.enemies[x.enemyKey].name === enemyName);
|
||||
|
||||
@@ -24,6 +24,8 @@ function randomIntent(rng, m, ps) {
|
||||
const uid = () => (pieces.length && rng() < 0.8 ? rng.pick(pieces) : 1 + rng.int(5000));
|
||||
const to = () => (rng() < 0.6 ? { area: 'board', row: rng() < 0.8 ? 9 + rng.int(4) : rng.int(19), col: rng() < 0.8 ? 2 + rng.int(9) : rng.int(21) } : { area: 'hand', idx: rng.int(10) });
|
||||
switch (t) {
|
||||
case 'g.infoReady': return { t, setupRevision: m.setupRevision ?? 0 };
|
||||
case 'g.rerollVote': return { t, voteId: m.setupVote?.id ?? 1 + rng.int(10), agree: rng() < 0.5 };
|
||||
case 'g.band': return { t, bandId: rng() < 0.8 ? rng.pick(BANDS) : rng.pick(['constructor', '__proto__', 'band_x', 'a'.repeat(64)]) };
|
||||
case 'g.buy': return { t, slot: rng.int(16) };
|
||||
case 'g.sell': case 'g.destroy': return { t, uid: uid() };
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../../server/index.js';
|
||||
import { TestClient } from '../helpers/wsClient.js';
|
||||
import { ERR } from '../../shared/constants.js';
|
||||
|
||||
const ok = async (c, msg) => {
|
||||
const r = await c.request(msg);
|
||||
assert.equal(r.t, 'ok', JSON.stringify(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));
|
||||
};
|
||||
const setupOf = (pub) => ({ stageId: pub.stageId, bossId: pub.bossId, hiddenBossId: pub.hiddenBossId,
|
||||
factions: pub.factions, disabledBonds: pub.disabledBonds, bannedChess: pub.bannedChess, setupRevision: pub.setupRevision });
|
||||
|
||||
test('real four-player sockets: host-only proposal, unanimous sync, reconnect and stale requests', { timeout: 15000 }, async (t) => {
|
||||
const errors = [];
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', seedFn: () => 123,
|
||||
log: { info() {}, warn() {}, debug() {}, error: (...args) => errors.push(args.map(String).join(' ')) } });
|
||||
const clients = [];
|
||||
t.after(async () => {
|
||||
await Promise.all(clients.map((c) => c.terminate()));
|
||||
await srv.close();
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
const player = async (name, token) => {
|
||||
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
|
||||
clients.push(c);
|
||||
const w = await c.hello(name, token);
|
||||
c.id = w.playerId;
|
||||
c.token = w.token;
|
||||
return c;
|
||||
};
|
||||
const host = await player('Host');
|
||||
await ok(host, { t: 'room.create', mode: 'coop', difficulty: 'HARD' });
|
||||
const room = await host.waitFor('room.state');
|
||||
const guests = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const c = await player(`Guest${i}`);
|
||||
guests.push(c);
|
||||
await ok(c, { t: 'room.join', code: room.code });
|
||||
await ok(c, { t: 'room.ready', ready: true });
|
||||
}
|
||||
const spectator = await player('Viewer');
|
||||
await ok(spectator, { t: 'room.spectate', code: room.code });
|
||||
await ok(host, { t: 'room.start' });
|
||||
const initial = await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
const m = srv.lobby.rooms.get(room.code).match;
|
||||
await err(guests[0], { t: 'room.rerollSetup', setupRevision: 0 }, ERR.NOT_HOST);
|
||||
await err(spectator, { t: 'room.rerollSetup', setupRevision: 0 }, ERR.SPECTATOR);
|
||||
await ok(guests[0], { t: 'g.infoReady', setupRevision: 0 });
|
||||
await ok(host, { t: 'room.rerollSetup', setupRevision: 0 });
|
||||
const voting = await host.waitFor('m.public', (p) => !!p.rerollVote);
|
||||
assert.equal(voting.deadline, 0);
|
||||
assert.equal(voting.rerollVote.voters.length, 4);
|
||||
assert.deepEqual(voting.rerollVote.agreed, [host.id]);
|
||||
const voteId = voting.rerollVote.id;
|
||||
await err(spectator, { t: 'g.rerollVote', voteId, agree: true }, ERR.SPECTATOR);
|
||||
await err(guests[0], { t: 'room.cancelReroll', voteId }, ERR.NOT_HOST);
|
||||
await ok(guests[0], { t: 'g.rerollVote', voteId, agree: true });
|
||||
await ok(guests[1], { t: 'g.rerollVote', voteId, agree: true });
|
||||
assert.equal(m.setupRevision, 0, '3/4 agrees never refreshes');
|
||||
await ok(guests[2], { t: 'g.rerollVote', voteId, agree: true });
|
||||
const all = [host, ...guests, spectator];
|
||||
const fresh = await Promise.all(all.map((c) => c.waitFor('m.public', (p) => p.setupRevision === 1)));
|
||||
for (const p of fresh) {
|
||||
assert.deepEqual(setupOf(p), setupOf(fresh[0]));
|
||||
assert.equal(p.deadline, fresh[0].deadline, 'all players share the same new deadline');
|
||||
assert.equal(Math.ceil((p.deadline - p.serverNow) / 1000), 25, 'each player sees a fresh 25-second countdown');
|
||||
assert.equal(p.rerollVote, null);
|
||||
assert.ok(p.players.every((ps) => !ps.ready));
|
||||
}
|
||||
assert.notDeepEqual(setupOf(fresh[0]), { ...setupOf(initial), setupRevision: 1 });
|
||||
assert.equal(srv.lobby.rooms.get(room.code).match, m, 'room/match instance and sockets retained');
|
||||
assert.ok(all.every((c) => c.isOpen));
|
||||
await err(guests[0], { t: 'g.infoReady', setupRevision: 0 }, ERR.BAD_TARGET);
|
||||
await err(guests[0], { t: 'g.infoReady' }, ERR.BAD_TARGET);
|
||||
await err(host, { t: 'room.rerollSetup', setupRevision: 0 }, ERR.BAD_TARGET);
|
||||
// Disconnect/reconnect retains this room and returns the current full setup.
|
||||
const guest = guests[2];
|
||||
await guest.terminate();
|
||||
await host.waitFor('m.public', (p) => p.setupRevision === 1 && p.players.some((ps) => ps.playerId === guest.id && !ps.connected));
|
||||
const resumed = await player('Guest2', guest.token);
|
||||
assert.equal(resumed.id, guest.id);
|
||||
assert.deepEqual(setupOf(await resumed.waitFor('m.public', (p) => p.setupRevision === 1)), setupOf(fresh[0]));
|
||||
const restoredRoom = await resumed.waitFor('room.state');
|
||||
assert.equal(restoredRoom.code, room.code);
|
||||
for (const c of [host, guests[0], guests[1], resumed]) await ok(c, { t: 'g.infoReady', setupRevision: 1 });
|
||||
await host.waitFor('m.public', (p) => p.phase === 'BAND_DRAFT');
|
||||
await err(host, { t: 'room.rerollSetup', setupRevision: 1 }, ERR.WRONG_PHASE);
|
||||
});
|
||||
|
||||
test('real sockets: reject/cancel/disconnect keep the setup and resume the countdown', { timeout: 15000 }, async (t) => {
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
const clients = [];
|
||||
t.after(async () => { await Promise.all(clients.map((c) => c.terminate())); await srv.close(); });
|
||||
for (const name of ['Host', 'Guest']) {
|
||||
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
|
||||
clients.push(c);
|
||||
await c.hello(name);
|
||||
}
|
||||
const [host, guest] = clients;
|
||||
await ok(host, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
|
||||
const room = await host.waitFor('room.state');
|
||||
await ok(guest, { t: 'room.join', code: room.code });
|
||||
await ok(guest, { t: 'room.ready', ready: true });
|
||||
await ok(host, { t: 'room.start' });
|
||||
const initial = await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
const m = srv.lobby.rooms.get(room.code).match;
|
||||
for (const finish of ['reject', 'cancel', 'disconnect']) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 310));
|
||||
await ok(host, { t: 'room.rerollSetup', setupRevision: 0 });
|
||||
const voting = await host.waitFor('m.public', (p) => !!p.rerollVote);
|
||||
if (finish === 'reject') await ok(guest, { t: 'g.rerollVote', voteId: voting.rerollVote.id, agree: false });
|
||||
if (finish === 'cancel') await ok(host, { t: 'room.cancelReroll', voteId: voting.rerollVote.id });
|
||||
if (finish === 'disconnect') await guest.terminate();
|
||||
await host.waitFor('m.public', (p) => !p.rerollVote && p.deadline > 0);
|
||||
assert.equal(m.setupRevision, 0);
|
||||
assert.deepEqual(setupOf(m.publicView()), setupOf(initial));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeMatch } from './harness.js';
|
||||
import { ERR, PHASE } from '../../shared/constants.js';
|
||||
import { validateC2S } from '../../shared/protocol.js';
|
||||
import { createRng, deriveSeed } from '../../server/sim/rng.js';
|
||||
import { setupMatchWaves } from '../../server/match/waves.js';
|
||||
import { drawDisabledBonds } from '../../server/match/pool.js';
|
||||
|
||||
const snapshot = (m) => ({ stageId: m.stageId, bossId: m.bossId, hiddenBossId: m.hiddenBossId,
|
||||
factions: [...m.factions], schedule: m.factions.schedule, bans: [...m.bannedChess], drawn: [...m.disabledBonds] });
|
||||
const approve = (m, id, voteId = m.setupVote.id) => m.handle(id, { t: 'g.rerollVote', voteId, agree: true });
|
||||
|
||||
test('reroll with one second left gives everyone a fresh full countdown; old timeout cannot advance it', (t) => {
|
||||
const h = makeMatch({ humans: 2 }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
const fullMs = m.scaled(m.gd.timer('infoCheck') * 1000);
|
||||
h.sched.advance(fullMs - 1000);
|
||||
assert.equal(m.deadline - h.sched.now(), 1000);
|
||||
assert.deepEqual(m.requestSetupReroll('p_0', 0), { ok: true });
|
||||
h.sched.advance(5000);
|
||||
assert.deepEqual(approve(m, 'p_1'), { ok: true });
|
||||
const fresh = h.lastBc('m.public');
|
||||
assert.equal(fresh.deadline - fresh.serverNow, fullMs);
|
||||
assert.ok(fresh.players.every((p) => !p.ready));
|
||||
h.sched.advance(fullMs - 1);
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK);
|
||||
h.sched.advance(1);
|
||||
assert.equal(m.phase, PHASE.BAND_DRAFT);
|
||||
});
|
||||
|
||||
test('protocol requires setup revisions and vote identities; old initial ready remains supported', () => {
|
||||
assert.equal(validateC2S({ t: 'room.rerollSetup', setupRevision: 0 }), null);
|
||||
assert.equal(validateC2S({ t: 'room.cancelReroll', voteId: 1 }), null);
|
||||
assert.equal(validateC2S({ t: 'g.rerollVote', voteId: 1, agree: false }), null);
|
||||
assert.equal(validateC2S({ t: 'g.infoReady' }), null);
|
||||
assert.equal(validateC2S({ t: 'g.infoReady', setupRevision: 2 }), null);
|
||||
for (const msg of [{ t: 'room.rerollSetup' }, { t: 'room.rerollSetup', setupRevision: -1 },
|
||||
{ t: 'room.rerollSetup', setupRevision: 0.5 }, { t: 'g.rerollVote', voteId: 0, agree: true },
|
||||
{ t: 'g.rerollVote', voteId: 1 }, { t: 'g.rerollVote', voteId: 1, agree: 'yes' },
|
||||
{ t: 'room.cancelReroll' }, { t: 'g.infoReady', setupRevision: -1 }]) {
|
||||
assert.notEqual(validateC2S(msg), null, JSON.stringify(msg));
|
||||
}
|
||||
});
|
||||
|
||||
test('four humans must agree; rerolls the entire official setup and stock, preserves seats/settings/RNG', (t) => {
|
||||
const h = makeMatch({ humans: 4, difficulty: 'HARD', seed: 123, spectators: ['viewer'] }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
const before = snapshot(m);
|
||||
const pool = m.pool;
|
||||
const players = [...m.order];
|
||||
const rngs = [m.rngSetup, m.rngShop, m.rngDraft, m.rngWaves, m.rngBots, m.rngMeta].map((r) => [r, r.state()]);
|
||||
const p0 = h.ps('p_0');
|
||||
assert.equal(p0.setDiy({ chess_char_5_diy1_a: { charId: 'char_017_huang', skillIndex: 0 } }), true);
|
||||
p0.initDiyStock(new Set([...m.disabledBonds, ...m.staticInactiveBonds]));
|
||||
const settings = [p0.loadout, p0.standIns, p0.diy];
|
||||
const stock = p0.diyStock;
|
||||
m.handle('p_1', { t: 'g.infoReady' });
|
||||
h.sched.advance(500);
|
||||
assert.deepEqual(m.requestSetupReroll('p_0', 0), { ok: true });
|
||||
assert.deepEqual(m.setupVote.voters, ['p_0', 'p_1', 'p_2', 'p_3']);
|
||||
assert.equal(m.deadline, 0);
|
||||
assert.deepEqual(approve(m, 'p_1'), { ok: true });
|
||||
assert.deepEqual(approve(m, 'p_2'), { ok: true });
|
||||
h.sched.advance(30000);
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK);
|
||||
assert.deepEqual(snapshot(m), before, 'nothing rerolled until everyone agrees');
|
||||
assert.equal(m.handle('viewer', { t: 'g.rerollVote', voteId: m.setupVote.id, agree: true }).error, ERR.SPECTATOR);
|
||||
assert.deepEqual(approve(m, 'p_3'), { ok: true });
|
||||
assert.equal(m.setupRevision, 1);
|
||||
assert.equal(m.setupVote, null);
|
||||
const rng = createRng(deriveSeed(m.seed, 'reroll-setup:1'));
|
||||
const setup = setupMatchWaves(m.gd, rng);
|
||||
const bans = drawDisabledBonds(m.gd, rng);
|
||||
assert.deepEqual(snapshot(m), { stageId: setup.stageId, bossId: setup.bossId, hiddenBossId: setup.hiddenBossId,
|
||||
factions: [...setup.factions], schedule: setup.factions.schedule, bans: bans.banned, drawn: bans.drawn });
|
||||
assert.notDeepEqual(snapshot(m), before);
|
||||
assert.equal(m.stage, m.gd.stage(m.stageId));
|
||||
assert.notEqual(m.pool, pool);
|
||||
assert.deepEqual(m.order, players);
|
||||
assert.deepEqual([p0.loadout, p0.standIns, p0.diy], settings);
|
||||
assert.equal(p0.diy, settings[2]);
|
||||
assert.notEqual(p0.diyStock, stock);
|
||||
const off = new Set([...bans.drawn, ...bans.staticOff]);
|
||||
const diy = p0.gd.chess('chess_char_5_diy1_a');
|
||||
assert.equal(p0.diyBanned.includes(diy.chessId), diy.bonds.every((b) => off.has(b)));
|
||||
assert.equal(p0.diyStock.has(diy.chessId), !p0.diyBanned.includes(diy.chessId));
|
||||
for (const [r, state] of rngs) assert.equal(r.state(), state);
|
||||
for (const id of m.gd.visibleChess) assert.equal(m.pool.has(id), !bans.banned.includes(id));
|
||||
assert.ok(m.order.every((p) => !p.infoReady));
|
||||
assert.equal(m.deadline, h.sched.now() + m.scaled(m.gd.timer('infoCheck') * 1000));
|
||||
assert.equal(h.lastBc('m.public').setupRevision, 1);
|
||||
h.invariants();
|
||||
for (const p of m.order) assert.deepEqual(m.handle(p.playerId, { t: 'g.infoReady', setupRevision: 1 }), { ok: true });
|
||||
h.sched.advance(0);
|
||||
assert.equal(m.phase, PHASE.BAND_DRAFT);
|
||||
});
|
||||
|
||||
test('reject/cancel restores exact remaining countdown and keeps setup and ready flags', (t) => {
|
||||
const h = makeMatch({ humans: 2, timerScale: 0.5 }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
const before = snapshot(m);
|
||||
m.handle('p_1', { t: 'g.infoReady' });
|
||||
h.sched.advance(1000);
|
||||
const remaining = m.deadline - h.sched.now();
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
h.sched.advance(10000);
|
||||
assert.deepEqual(m.handle('p_1', { t: 'g.rerollVote', voteId: m.setupVote.id, agree: false }), { ok: true });
|
||||
assert.deepEqual(snapshot(m), before);
|
||||
assert.equal(m.setupRevision, 0);
|
||||
assert.equal(h.ps('p_1').infoReady, true);
|
||||
assert.equal(m.deadline, h.sched.now() + remaining, 'real remaining ms, no extra timer scaling');
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
assert.deepEqual(m.cancelSetupReroll('p_0', m.setupVote.id), { ok: true });
|
||||
assert.equal(m.deadline, h.sched.now() + remaining);
|
||||
h.sched.advance(remaining - 1);
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK);
|
||||
h.sched.advance(1);
|
||||
assert.equal(m.phase, PHASE.BAND_DRAFT);
|
||||
});
|
||||
|
||||
test('reroll cancels queued all-ready transition; stale ready and previous vote cannot affect a new setup', (t) => {
|
||||
const h = makeMatch({ humans: 2 }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
for (const p of m.order) m.handle(p.playerId, { t: 'g.infoReady' });
|
||||
assert.ok(m._infoAdvanceTimer);
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
const oldVote = m.setupVote.id;
|
||||
assert.equal(m.handle('p_1', { t: 'g.infoReady' }).error, ERR.WRONG_PHASE);
|
||||
approve(m, 'p_1');
|
||||
h.sched.advance(0);
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK);
|
||||
for (const msg of [{ t: 'g.infoReady' }, { t: 'g.infoReady', setupRevision: 0 }]) {
|
||||
assert.equal(m.handle('p_1', msg).error, ERR.BAD_TARGET);
|
||||
}
|
||||
assert.equal(m.requestSetupReroll('p_0', 0).error, ERR.BAD_TARGET);
|
||||
assert.equal(m.requestSetupReroll('p_0', 1).error, ERR.RATE);
|
||||
h.sched.advance(300);
|
||||
assert.deepEqual(m.requestSetupReroll('p_0', 1), { ok: true });
|
||||
assert.equal(approve(m, 'p_1', oldVote).error, ERR.BAD_TARGET);
|
||||
assert.equal(m.cancelSetupReroll('p_0', oldVote).error, ERR.BAD_TARGET);
|
||||
assert.equal(m.setupVote.agreed.size, 1);
|
||||
});
|
||||
|
||||
test('solo and one human with bots refresh immediately without a forced timer; fixed rules remain fixed', (t) => {
|
||||
for (const mode of ['solo', 'coop']) {
|
||||
const h = makeMatch({ mode, humans: 1, bots: 2, difficulty: 'FUNNY' }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
const stage = m.stageId;
|
||||
const staticOff = [...m.staticInactiveBonds];
|
||||
assert.deepEqual(m.requestSetupReroll('p_0', 0), { ok: true });
|
||||
assert.equal(m.stageId, stage);
|
||||
assert.deepEqual(m.staticInactiveBonds, staticOff);
|
||||
assert.equal(m.deadline, 0);
|
||||
assert.equal(m.setupRevision, 1);
|
||||
assert.equal(m.setupVote, null);
|
||||
assert.equal(h.ps('p_0').infoReady, false);
|
||||
assert.ok(m.order.filter((p) => p.isBot).every((p) => p.infoReady));
|
||||
m.handle('p_0', { t: 'g.infoReady', setupRevision: 1 });
|
||||
h.sched.advance(0);
|
||||
assert.equal(m.phase, PHASE.BAND_DRAFT);
|
||||
assert.equal(m.requestSetupReroll('p_0', 1).error, ERR.WRONG_PHASE);
|
||||
}
|
||||
});
|
||||
|
||||
test('disconnect and leave cancel rather than count as consent; reconnect sees current vote/setup', (t) => {
|
||||
const h = makeMatch({ humans: 3 }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
const before = snapshot(m);
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
const id = m.setupVote.id;
|
||||
m.onReconnect('p_1');
|
||||
assert.equal(h.lastTo('p_1', 'm.public').rerollVote.id, id);
|
||||
m.onDisconnect('p_1');
|
||||
assert.equal(m.setupVote, null);
|
||||
assert.deepEqual(snapshot(m), before);
|
||||
h.sched.advance(300);
|
||||
assert.equal(m.requestSetupReroll('p_0', 0).error, ERR.NOT_READY);
|
||||
m.onReconnect('p_1');
|
||||
assert.equal(h.lastTo('p_1', 'm.public').rerollVote, null);
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
m.onLeave('p_1');
|
||||
assert.equal(m.setupVote, null);
|
||||
assert.equal(m.setupRevision, 0);
|
||||
h.sched.advance(300);
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
assert.deepEqual(m.setupVote.voters, ['p_0', 'p_2']);
|
||||
approve(m, 'p_2');
|
||||
assert.equal(m.setupRevision, 1);
|
||||
});
|
||||
|
||||
test('autoplay humans still explicitly consent; dispose stops a paused vote and invalid actors are refused', (t) => {
|
||||
const h = makeMatch({ humans: 2, bots: 1 }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
m.handle('p_1', { t: 'g.autoplay', on: true });
|
||||
assert.equal(m.requestSetupReroll('ai_0', 0).error, ERR.NOT_IN_ROOM);
|
||||
assert.equal(m.requestSetupReroll('stranger', 0).error, ERR.NOT_IN_ROOM);
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
assert.equal(m.setupVote.agreed.size, 1);
|
||||
h.sched.advance(30000);
|
||||
assert.equal(m.setupRevision, 0);
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK);
|
||||
m.dispose();
|
||||
assert.equal(m.requestSetupReroll('p_0', 0).error, ERR.WRONG_PHASE);
|
||||
h.sched.advance(30000);
|
||||
assert.equal(m.setupRevision, 0);
|
||||
});
|
||||
|
||||
test('failed DIY stock preparation leaves every opening field and player stock intact', (t) => {
|
||||
const h = makeMatch({ humans: 2, difficulty: 'HARD' }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m, before = snapshot(m), pool = m.pool, stage = m.stage;
|
||||
const stocks = m.order.map((ps) => ps.diyStock), banned = m.order.map((ps) => ps.diyBanned);
|
||||
h.sched.advance(1000);
|
||||
const remaining = m.deadline - h.sched.now();
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
const original = h.ps('p_1').buildDiyStock;
|
||||
h.ps('p_1').buildDiyStock = () => { throw Error('stock preparation failed'); };
|
||||
assert.equal(approve(m, 'p_1').error, ERR.INTERNAL);
|
||||
h.ps('p_1').buildDiyStock = original;
|
||||
assert.deepEqual(snapshot(m), before); assert.equal(m.pool, pool); assert.equal(m.stage, stage);
|
||||
for (const [i, ps] of m.order.entries()) { assert.equal(ps.diyStock, stocks[i]); assert.equal(ps.diyBanned, banned[i]); }
|
||||
assert.equal(m.setupRevision, 0); assert.equal(m.setupVote, null);
|
||||
assert.equal(m.deadline - h.sched.now(), remaining);
|
||||
assert.equal(h.logs.error.length, 1, 'failure is reported once');
|
||||
});
|
||||
Reference in new issue
Block a user