0.2.3: Clementia S2 releases a dead, hidden, uncarriable or pathless rider at once (slot and weight back before the same tick's boarding checks); same-browser match recovery never takes the seat of a window that is reloading or still starting (a tab keeps its own session token; seats are stamped at the server's welcome, the older welcome wins); golden: diy-135 moves (outcome unchanged)

This commit is contained in:
sganggs committed 2026-10-10 01:58:06 +08:00
1 parent 821ab827bd
commit ba5b998a72
19 files changed
+580 -60

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
// Control delivery across two connections while using the real client and server hello handlers.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createIdentity, Net } from '../public/js/net.js';
import { Network, SessionRegistry } from '../server/net.js';
import { validateC2S } from '../shared/protocol.js';
function storage() {
const m = new Map();
return { getItem: (k) => m.get(k) ?? null, setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k) };
}
function hub() {
const members = new Set();
return { create() {
const channel = {
listeners: [],
postMessage(data) {
for (const peer of members) if (peer !== channel) queueMicrotask(() => {
if (members.has(peer)) for (const fn of peer.listeners) fn({ data: structuredClone(data) });
});
},
addEventListener(type, fn) { if (type === 'message') this.listeners.push(fn); },
close() { members.delete(channel); },
};
members.add(channel); return channel;
} };
}
async function harness(t) {
const local = storage(), ownerSession = storage(), guestSession = storage(), channels = hub();
const registry = new SessionRegistry(), server = new Network({ registry, handler: { onMessage() {} } });
const ownerSeat = registry.create('Owner'), guestSeat = registry.create('Guest'), clients = [];
t.after(() => { for (const net of clients) net.close(); clearInterval(server.heartbeatTimer); clearInterval(server.sweepTimer); });
const ownerChannel = channels.create();
const owner = createIdentity({ local, session: ownerSession, channel: ownerChannel, tabId: 'z-owner', queryMs: 1, now: () => 100 });
await owner.init(); owner.saveToken(ownerSeat.token); owner.rememberMatch({ name: 'Owner', code: 'ROOM1' });
const guest = createIdentity({ local, session: guestSession, channel: channels.create(), tabId: 'a-guest', queryMs: 1, now: () => 200 });
await guest.init(); guest.saveToken(guestSeat.token);
const client = (identity) => {
const sockets = [], welcomes = [], frames = [];
class Socket {
constructor() { this.readyState = 0; this.sent = []; sockets.push(this); }
send(raw) { this.sent.push(JSON.parse(raw)); }
// Client close need not reach the server before an already sent hello on this connection.
close(code) { this.readyState = 3; this.onclose?.({ code }); }
open() { this.readyState = 1; this.onopen?.(); }
}
const net = new Net({ WebSocket: Socket, getToken: () => identity.getToken(), getTokenClaim: () => identity.getTokenClaim() });
clients.push(net);
net.on('welcome', (m) => { welcomes.push(m); identity.saveToken(m.token); });
net.setName('Owner');
const ws = sockets[0]; ws.open();
const conn = { session: null, ip: '?', key: null, ws: {
readyState: 1, bufferedAmount: 0,
send(raw) { frames.push(JSON.parse(raw)); ws.onmessage?.({ data: raw }); },
close(code) { this.readyState = 3; ws.close(code); },
} };
return { net, ws, conn, welcomes, frames, sockets,
hello: () => ws.sent.find((m) => m.t === 'hello'),
deliver() {
const msg = ws.sent.find((m) => m.t === 'hello');
assert.equal(validateC2S(msg), null);
server.onHelloMsg(conn, msg, Date.now());
},
};
};
return { local, ownerSession, guestSession, channels, ownerSeat, guestSeat, ownerChannel, owner, guest, client,
async reload() {
const id = createIdentity({ local, session: ownerSession, channel: channels.create(), tabId: 'zz-reloaded', queryMs: 1, now: () => 300 });
assert.equal(await id.init(), ownerSeat.token); return id;
},
};
}
test('a recovery hello already in flight cannot displace the original window after its reload welcome', async (t) => {
const h = await harness(t);
h.ownerChannel.close();
assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true);
const pending = h.client(h.guest);
assert.equal(pending.hello().noReplace, true);
const owner = h.client(await h.reload()); owner.deliver();
assert.equal(h.guest.getToken(), h.guestSeat.token);
pending.deliver();
assert.equal(pending.frames.at(-1).code, 'SESSION_IN_USE');
assert.equal(owner.net.status, 'online'); assert.equal(owner.net.lastError, null);
assert.equal(h.ownerSeat.ws, owner.conn.ws);
assert.equal(pending.welcomes.length, 0);
assert.equal(h.guestSession.getItem('sp.token'), h.guestSeat.token);
});
test('a later welcomed holder also yields; its buffered reconnect cannot override the earlier welcome', async (t) => {
const h = await harness(t);
h.ownerChannel.close();
assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true);
const recovered = h.client(h.guest); recovered.deliver();
assert.equal(recovered.net.status, 'online');
const delayedReconnect = h.client(h.guest);
assert.equal(delayedReconnect.hello().claimAt, 200);
assert.equal(delayedReconnect.hello().noReplace, undefined);
const owner = h.client(await h.reload()); owner.deliver();
assert.equal(h.guest.getToken(), null);
assert.equal(h.guestSession.getItem('sp.token'), null);
delayedReconnect.deliver();
assert.equal(delayedReconnect.frames.at(-1).code, 'SESSION_IN_USE');
assert.equal(owner.net.status, 'online'); assert.equal(owner.net.lastError, null);
assert.equal(h.ownerSeat.ws, owner.conn.ws);
assert.equal(h.ownerSeat.claimAt, 100);
});
test('shared recovery can bind an offline seat and a same-stamp reconnect can replace its own socket', async (t) => {
const h = await harness(t);
h.ownerChannel.close();
assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true);
const first = h.client(h.guest); first.deliver();
assert.equal(first.welcomes[0].playerId, h.ownerSeat.playerId);
const reconnect = h.client(h.guest); reconnect.deliver();
assert.equal(reconnect.welcomes[0].playerId, h.ownerSeat.playerId);
const again = h.client(h.guest); again.deliver();
assert.equal(again.net.status, 'online');
assert.equal(again.welcomes[0].playerId, h.ownerSeat.playerId);
assert.equal(h.ownerSeat.claimAt, 200);
});
test('a stale welcome is discarded before online status, queued actions or welcome listeners', async (t) => {
const h = await harness(t);
h.ownerChannel.close();
assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true);
const pending = h.client(h.guest), published = [], statuses = [];
pending.net.on('*', (m) => published.push(m));
pending.net.on('status', (s) => statuses.push(s.status));
const action = pending.net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
const rejected = assert.rejects(action, (e) => e.code === 'CLOSED');
h.guest.rejectToken();
pending.deliver();
assert.equal(pending.welcomes.length, 0);
assert.equal(published.length, 0);
assert.ok(!statuses.includes('online'));
assert.ok(!pending.ws.sent.some((m) => m.t === 'room.create'));
assert.equal(pending.sockets.length, 2, 'retry uses a new connection, never a repeat hello on the old session');
assert.equal(h.guestSession.getItem('sp.token'), h.guestSeat.token);
pending.net.close(); await rejected;
});
test('hello ownership fields reject malformed inputs but remain optional', () => {
const hello = { t: 'hello', name: 'Owner', token: 'seat' };
for (const fields of [{}, { noReplace: true }, { noReplace: false, claimAt: 100 }]) assert.equal(validateC2S({ ...hello, ...fields }), null);
for (const fields of [{ noReplace: 'yes' }, { claimAt: -1 }, { claimAt: Infinity }, { claimAt: '100' }]) assert.equal(typeof validateC2S({ ...hello, ...fields }), 'string');
});
+77
View File
@@ -1310,6 +1310,83 @@ describe('multi-device & browser compatibility (static)', () => {
});
describe('explicit local match recovery (#431)', () => {
async function recoveryPair() {
const { createIdentity } = await mod('net.js');
const local = memStorage(), hub = channelHub(), ownerSession = memStorage(), guestSession = memStorage();
const ownerChannel = hub.create();
const owner = createIdentity({ local, session: ownerSession, tabId: 'z-owner', channel: ownerChannel, queryMs: 5, now: () => 100 });
const guest = createIdentity({ local, session: guestSession, tabId: 'a-guest', channel: hub.create(), queryMs: 5, now: () => 200 });
await owner.init(); owner.saveToken('seat-owner'); owner.rememberMatch({ name: 'Owner', code: 'ROOM1' });
await guest.init(); guest.saveToken('seat-guest');
return { createIdentity, local, hub, ownerSession, guestSession, ownerChannel, owner, guest, id: guest.recoverable()[0].id };
}
test('live: a smaller tab id cannot reclaim an active seat', async () => {
const { guest, owner, id } = await recoveryPair();
assert.equal(await guest.resume(id), false);
assert.equal(guest.getToken(), 'seat-guest');
assert.equal(owner.getToken(), 'seat-owner');
});
test('reload: the original session wins over a recovery attempt during navigation', async () => {
const p = await recoveryPair();
p.ownerChannel.close();
assert.equal(await p.guest.resume(p.id), true, 'old document is gone');
const reloaded = p.createIdentity({ local: p.local, session: p.ownerSession, tabId: 'zz-reloaded', channel: p.hub.create(), queryMs: 5 });
assert.equal(await reloaded.init(), 'seat-owner');
assert.equal(p.ownerSession.getItem('sp.token'), 'seat-owner');
assert.equal(p.guest.getToken(), 'seat-guest', 'unconfirmed recovery yields before hello');
});
test('midInit: the session token answers claims before init finishes', async () => {
const p = await recoveryPair();
p.ownerChannel.close();
let finish;
const reloaded = p.createIdentity({ local: p.local, session: p.ownerSession, tabId: 'zz-reloaded', channel: p.hub.create(), setTimeout: (fn) => { finish = fn; } });
const pending = reloaded.init();
assert.equal(await p.guest.resume(p.id), false);
finish();
assert.equal(await pending, 'seat-owner');
assert.equal(p.ownerSession.getItem('sp.token'), 'seat-owner');
assert.equal(p.guest.getToken(), 'seat-guest');
});
test('an occupied session token never falls back to a different recent identity', async () => {
const p = await recoveryPair();
p.local.setItem('sp.tokens', JSON.stringify(['seat-free', 'seat-owner']));
const duplicate = p.createIdentity({ local: p.local, session: p.ownerSession.clone(), tabId: '0-copy', channel: p.hub.create(), queryMs: 5 });
assert.equal(await duplicate.init(), null);
assert.equal(duplicate.getToken(), null);
});
test('resume persists neither its token nor a welcome stamp until the server welcomes it', async () => {
const p = await recoveryPair();
p.ownerChannel.close();
const stamp = p.guestSession.getItem('sp.tokenWelcome');
assert.equal(await p.guest.resume(p.id), true);
assert.equal(p.guestSession.getItem('sp.token'), 'seat-guest');
assert.equal(p.guestSession.getItem('sp.tokenWelcome'), stamp);
p.guest.saveToken('seat-owner');
assert.equal(p.guestSession.getItem('sp.token'), 'seat-owner');
assert.ok(p.guestSession.getItem('sp.tokenWelcome'));
});
test('an earlier welcome stamp survives reload and beats a later live welcome', async () => {
const p = await recoveryPair();
p.ownerChannel.close();
assert.equal(await p.guest.resume(p.id), true);
p.guest.saveToken('seat-owner'); // recovery's welcome is later than the original owner's
const originalStamp = p.ownerSession.getItem('sp.tokenWelcome');
const reloaded = p.createIdentity({ local: p.local, session: p.ownerSession, tabId: 'zz-reloaded', channel: p.hub.create(), queryMs: 5, now: () => 300 });
assert.equal(await reloaded.init(), 'seat-owner');
reloaded.saveToken('seat-owner');
assert.equal(p.ownerSession.getItem('sp.tokenWelcome'), originalStamp, 'reconnect does not reset seniority');
});
test('a welcome received during init cannot be overwritten or erased by its pending result', async () => {
const { createIdentity } = await mod('net.js');
const local = memStorage(), session = memStorage(), hub = channelHub();
session.setItem('sp.token', 'old');
let finish;
const id = createIdentity({ local, session, channel: hub.create(), setTimeout: (fn) => { finish = fn; } });
const pending = id.init();
id.saveToken('new'); finish();
assert.equal(await pending, 'new');
assert.equal(session.getItem('sp.token'), 'new');
});
test('an idle tab can reclaim a closed match tab without taking a live one', async () => {
const { createIdentity } = await mod('net.js');
const local = memStorage(), hub = channelHub(), ca = hub.create(), cb = hub.create();
+63 -1
View File
@@ -9,7 +9,7 @@ const C = 'char_4231_clemnt';
const data = Object.fromEntries(['chess', 'backups'].map((f) => [f, JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url)))]));
const approx = (a, b, label = '') => assert.ok(Math.abs(a - b) < 1e-5 * Math.max(1, Math.abs(b)), `${label}: ${a} != ${b}`);
const enemy = (key, opts = {}) => enemyRec({ key, hp: 1e8, atk: 0, speed: 0, ...opts });
const defs = { enemies: { dummy: enemy('dummy'), light: enemy('light', { mass: 3 }), heavy: enemy('heavy', { mass: 9 }),
const defs = { enemies: { dummy: enemy('dummy'), light: enemy('light', { mass: 3 }), mid: enemy('mid', { mass: 4 }), heavy: enemy('heavy', { mass: 9 }),
fly: enemy('fly', { motion: 'FLY' }), zero: enemy('zero', { mass: 0 }), armored: enemy('armored', { def: 100 }) } };
function field({ tier = 6, elite = true, skill = 0, potential = 6, dir = 'RIGHT', row = 10, col = 3 } = {}) {
const h = makeBattle({ seed: 23, autoFinish: false, timeLimit: 180, defs,
@@ -104,6 +104,68 @@ test('风暴潮 caps passengers at fifteen and cleans them on early skill end',
assert.equal(u.mem.clemntCabin, null); done(h);
});
for (const reason of ['death', 'hidden', 'self-bound', 'teleport-immune', 'no-path']) {
test(`风暴潮 releases ${reason} passengers before admitting a replacement in the same tick`, () => {
const { h, u } = field({ skill: 1 });
const victim = h.spawn('light', { pos: [10, 4] });
const survivor = h.spawn('light', { pos: [10, 4] });
start(u); h.run(0.7);
const c = u.mem.clemntCabin, carry = `clemnt:carry:${u.id}`;
assert.deepEqual(c.passengers.map((e) => e.id), [victim.id, survivor.id]);
assert.equal(c.mass, 2);
if (reason === 'death') h.b.dealDamage(u, victim, { amount: 1e12, type: 'true' });
if (reason === 'hidden') victim.hidden = true;
if (reason === 'self-bound') h.b.addBuff(victim, { key: 'test:bound', flags: { selfBound: true } });
if (reason === 'teleport-immune') victim.def = { ...victim.def, immune: new Set(['teleport']) };
if (reason === 'no-path') {
victim.x = 7; victim.y = 12;
for (const [r, col] of [[11, 6], [11, 7], [11, 8], [12, 6], [12, 8]]) h.b.grid.setObstacle(r, col, true);
assert.equal(h.b.grid.findPath(12, 7, Math.round(c.y), Math.round(c.x)), null);
}
const replacement = h.spawn('light', { pos: [c.y, c.x] });
h.step();
assert.deepEqual(c.passengers.map((e) => e.id), [survivor.id, replacement.id]);
assert.equal(c.mass, 2);
assert.ok(!victim.findBuff(carry));
assert.ok(replacement.findBuff(carry));
approx(replacement.x, c.x); approx(survivor.x, c.x);
h.step(); assert.equal(c.mass, 2, 'released weight is returned only once');
done(h);
});
}
for (const [kind, count] of [['zero', 15], ['mid', 1]]) {
test(`风暴潮 frees the ${kind === 'zero' ? 'fifteenth slot' : 'four-weight budget'} after a passenger dies`, () => {
const { h, u } = field({ skill: 1 });
const riders = Array.from({ length: count }, () => h.spawn(kind, { pos: [10, 4] }));
start(u); h.run(0.7);
const c = u.mem.clemntCabin;
assert.equal(c.passengers.length, count);
h.b.dealDamage(u, riders[0], { amount: 1e12, type: 'true' });
const replacement = h.spawn(kind, { pos: [c.y, c.x] });
h.step();
assert.equal(c.passengers.length, count);
assert.ok(!c.passengers.includes(riders[0]));
assert.ok(c.passengers.includes(replacement));
assert.equal(c.mass, kind === 'zero' ? 8 : 4);
done(h);
});
}
test('风暴潮 returns a released passenger’s current weight after a weight modifier', () => {
const { h, u } = field({ skill: 1 });
const victim = h.spawn('light', { pos: [10, 4] });
start(u); h.run(0.7);
const c = u.mem.clemntCabin;
assert.equal(c.mass, 5);
h.b.addBuff(victim, { key: 'test:lighter', mods: { massFlat: -1 }, flags: { selfBound: true } });
assert.equal(victim.weight, 2);
h.step();
assert.equal(c.passengers.length, 0);
assert.equal(c.mass, 7, 'return current weight 2, not boarding weight 3');
done(h);
});
test('风暴潮 stops before a blocked tile; vortex keeps its continuous stop point, hits air and applies normal erosion resistance', () => {
const { h, u } = field({ skill: 1 }); h.b.grid.setObstacle(10, 5, true);
const fly = h.spawn('fly', { pos: [10, 5.55] });
+17 -17
View File
@@ -5850,38 +5850,38 @@
"round": 13,
"seed": 3994643046,
"waveId": "act1autochess_h06",
"time": 194.33,
"ticks": 5830,
"time": 198.03,
"ticks": 5941,
"reason": "cleared",
"killed": 150,
"total": 150,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 1629,
"rngDraws": 1626,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":150,"total":150,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1661420,"bossDmg":0,"heal":55519,"deaths":1,"layerGains":{}}},
"players": {"p1":{"killed":150,"total":150,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1661420,"bossDmg":0,"heal":56685,"deaths":1,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",0,null,2244,1,5,1,6436,260,0,9,2],
["p1",2,"op","chess_char_5_diy2_a",1,null,2244,1,7,28,337078,4202,0,103,5],
["p1",3,"op","chess_char_5_diy1_a",2,null,2244,1,10,41,324348,22963,0,192,2],
["p1",4,"op","chess_char_5_diy2_b",0,"none",2740.1,1,11,0,17661,2190,0,32,8],
["p1",5,"op","chess_char_5_diy1_b",1,"none",2740.1,1,13,56,614536,3652,0,191,6],
["p1",6,"op","chess_char_5_diy2_b",2,"none",1571.84,1,145,24,361361,9590,0,109,2],
["p1",2,"op","chess_char_5_diy2_a",1,null,2244,1,7,29,321186,4202,0,103,5],
["p1",3,"op","chess_char_5_diy1_a",2,null,2244,1,10,42,323043,24114,0,196,2],
["p1",4,"op","chess_char_5_diy2_b",0,"none",2740.1,1,11,0,17637,2190,0,32,8],
["p1",5,"op","chess_char_5_diy1_b",1,"none",2740.1,1,13,54,636268,3391,0,191,6],
["p1",6,"op","chess_char_5_diy2_b",2,"none",1833.1,1,145,24,356851,9329,0,109,2],
["p1",7,"op","chess_char_6_diy1_b",0,"none",1818.3,1,8,0,0,0,0,0,0],
["p1",8,"op","chess_char_6_diy2_b",1,"none",1818.3,1,12,0,0,0,0,0,0],
["p1",9,"op","chess_char_6_diy1_b",2,"none",1818.3,1,14,0,0,0,0,0,0],
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_002_monstr",1818.3,1,4,0,0,0,23798,64,16],
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_002_monstr",1818.3,1,6,0,0,0,21767,55,2],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_monstr",1818.3,1,9,0,0,16570,9955,33,0],
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_002_monstr",1818.3,1,4,0,0,0,24672,66,16],
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_002_monstr",1818.3,1,6,0,0,0,22077,56,2],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_monstr",1818.3,1,9,0,0,16846,9935,34,0],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1249_lysdb_2":[60,60,0,22108,671846],"enemy_1251_lysyta":[51,51,0,170,261740],"enemy_1329_cbshld":[39,39,0,37149,727834]},
"hooks": [1,166,5830,1043,1043,2055,2562,461,151,151,43,39,24,10080,0,66,150,0,1,151,0,46,0],
"events": {"atk":1542,"deploy":13,"die":151,"dmg":2562,"engage":6,"fx":796,"heal":191,"skill":82,"spawn":165,"status":95},
"fx": {"anchor":11,"blast":72,"burst":46,"lock":24,"shieldBreak":60,"teleport":572,"tide":11},
"snaps": "b5491880 3d470ed3 e24470a9 9efdac52 682ba7c7 5053e7b1 372bc538 4f6990b6 78e9810f 2e63af8e c036a7f6 85430b6a f0f306e0 83b41054 a23bd5e0 6b457668 276d43d1 2bfa5ab2 cc45bcda 05a317f3"
"enemies": {"enemy_1249_lysdb_2":[60,60,0,22385,671846],"enemy_1251_lysyta":[51,51,0,170,261740],"enemy_1329_cbshld":[39,39,0,37777,727834]},
"hooks": [1,166,5941,1054,1054,2053,2565,473,151,151,43,39,24,10083,0,64,150,0,1,151,0,47,0],
"events": {"atk":1558,"deploy":13,"die":151,"dmg":2565,"engage":6,"fx":855,"heal":194,"skill":82,"spawn":165,"status":97},
"fx": {"anchor":11,"blast":72,"burst":47,"lock":24,"shieldBreak":60,"teleport":630,"tide":11},
"snaps": "b5491880 3d470ed3 e24470a9 9efdac52 682ba7c7 5053e7b1 372bc538 4f6990b6 78e9810f 2e63af8e c036a7f6 85430b6a f0f306e0 83b41054 a23bd5e0 216b4871 ad7483be 0e1ae0c5 b71814eb 25d90322"
},
"diy-136": {
"about": "5_diy1_a=cdfend 5_diy2_b=cdfend 6_diy1_a=clemnt/S1 6_diy2_a=clemnt/S2 6_diy1_a=clemnt/S3 6_diy2_b=clemnt/S1 6_diy1_b=clemnt/S2 6_diy2_b=clemnt/S3 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe",
+62 -2
View File
@@ -14,9 +14,10 @@ describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => {
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
});
after(async () => { await browser?.close(); await server?.close(); });
async function pageIn(ctx, phone = false) {
async function pageIn(ctx, phone = false, random = null) {
const page = await ctx.newPage();
await page.setViewport({ width: phone ? 844 : 1440, height: phone ? 390 : 900 });
if (random != null) await page.evaluateOnNewDocument((value) => { Math.random = () => value; }, random);
await page.evaluateOnNewDocument(() => { localStorage.setItem('sp.name', 'Recovery Test'); sessionStorage.setItem('sp.entered', '1'); });
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online' && document.querySelector('.lobby-screen'));
@@ -40,10 +41,69 @@ describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => {
await b.close();
await a.click('[data-testid="resume-local-match"]');
await a.waitForSelector('.modal .btn--block');
await Promise.all([a.waitForNavigation({ waitUntil: 'domcontentloaded' }), a.click('.modal .btn--block')]);
await a.evaluate(() => { globalThis.recoveryDocument = true; });
await a.click('.modal .btn--block');
await a.waitForFunction(() => globalThis.__SP__?.store.get().match.public?.phase === 'INFO_CHECK');
const after = await a.evaluate(() => { const s = globalThis.__SP__.store.get(); return { id: s.me.playerId, code: s.room.code }; });
assert.deepEqual(after, before);
assert.equal(await a.evaluate(() => globalThis.recoveryDocument), true, 'welcome arrives in the same document');
} finally { await ctx.close(); }
});
test('a refreshing match window keeps its seat while its init is waiting and another window clicks recovery', async () => {
const ctx = await browser.createBrowserContext();
try {
const guest = await pageIn(ctx, false, 0.01), owner = await pageIn(ctx, false, 0.99);
const before = await owner.evaluate(async () => {
const { net, store } = globalThis.__SP__;
await net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
await net.request('room.start', {});
return { id: store.get().me.playerId, code: store.get().room.code, token: sessionStorage.getItem('sp.token') };
});
const guestId = await guest.evaluate(() => globalThis.__SP__.store.get().me.playerId);
await owner.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
await guest.waitForSelector('[data-testid="resume-local-match"]');
await owner.evaluateOnNewDocument(() => {
// Hold precisely the timeout scheduled immediately after the real identity query.
// Messages still use Chrome's BroadcastChannel; only init's completion is controlled.
const Channel = globalThis.BroadcastChannel, timer = globalThis.setTimeout;
let hold = false;
globalThis.BroadcastChannel = class extends Channel {
constructor(name) { super(name); this.identityChannel = name === 'sp.identity'; }
postMessage(message) {
super.postMessage(message);
if (this.identityChannel && message.type === 'who') hold = true;
}
};
globalThis.setTimeout = (fn, ms, ...args) => {
if (hold) {
hold = false;
globalThis.finishIdentityInit = () => fn(...args);
return 0;
}
return timer(fn, ms, ...args);
};
});
await owner.reload({ waitUntil: 'domcontentloaded' });
await owner.waitForFunction(() => typeof globalThis.finishIdentityInit === 'function');
await guest.bringToFront();
await guest.click('[data-testid="resume-local-match"]');
await guest.waitForSelector('.modal .btn--block');
await guest.click('.modal .btn--block');
await guest.waitForFunction(() => document.body.textContent.includes('此对局仍在其他窗口中'));
assert.equal(await owner.evaluate(() => sessionStorage.getItem('sp.token')), before.token);
await owner.evaluate(() => globalThis.finishIdentityInit());
await owner.waitForFunction(() => globalThis.__SP__?.store.get().match.public?.phase === 'INFO_CHECK');
const after = await owner.evaluate(() => {
const s = globalThis.__SP__.store.get();
return { id: s.me.playerId, code: s.room.code, token: sessionStorage.getItem('sp.token') };
});
assert.deepEqual(after, before);
assert.equal(await guest.evaluate(() => globalThis.__SP__.store.get().me.playerId), guestId);
for (const page of [guest, owner]) {
const pong = await page.evaluate(() => globalThis.__SP__.net.request('ping', { c: Date.now() }));
assert.equal(pong.t, 'pong');
assert.equal(await page.evaluate(() => globalThis.__SP__.store.get().connection.status), 'online');
}
} finally { await ctx.close(); }
});
test('co-op opening reroll shows a unanimous vote, clears stale readiness and can be rejected', { timeout: 45000 }, async () => {