From 433406b6a92d20d89283de35d3b435c986a11f1f Mon Sep 17 00:00:00 2001 From: Henrik Kurelid Date: Fri, 30 Oct 2026 15:36:31 +0100 Subject: [PATCH 1/4] feat(nr-z4nw): room codes and a client for the liaison meeting service Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WCDKexiF6SkDodmyKWmppK --- web/src/netplay/liaison.test.ts | 154 ++++++++++++++++++++++++++++++ web/src/netplay/liaison.ts | 150 +++++++++++++++++++++++++++++ web/src/netplay/room_code.test.ts | 32 +++++++ web/src/netplay/room_code.ts | 21 ++++ 4 files changed, 357 insertions(+) create mode 100644 web/src/netplay/liaison.test.ts create mode 100644 web/src/netplay/liaison.ts create mode 100644 web/src/netplay/room_code.test.ts create mode 100644 web/src/netplay/room_code.ts diff --git a/web/src/netplay/liaison.test.ts b/web/src/netplay/liaison.test.ts new file mode 100644 index 000000000..d96670a35 --- /dev/null +++ b/web/src/netplay/liaison.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LIAISON_URL, OPEN_LIMIT_MS, liaisonSignaling, parseServerMessage, type SignalingSocket } from "./liaison"; + +/** A WebSocket the test opens, closes and talks through by hand. */ +class FakeSocket extends EventTarget { + readyState = 0; + sent: string[] = []; + closed = false; + constructor(readonly url: string) { + super(); + } + send(text: string) { + this.sent.push(text); + } + close() { + this.closed = true; + this.readyState = 3; + } + opens() { + this.readyState = 1; + this.dispatchEvent(new Event("open")); + } + fails() { + this.dispatchEvent(new Event("error")); + this.readyState = 3; + this.dispatchEvent(new Event("close")); + } + says(text: string) { + this.dispatchEvent(new MessageEvent("message", { data: text })); + } + ends() { + this.readyState = 3; + this.dispatchEvent(new Event("close")); + } +} + +let sockets: FakeSocket[]; +const signaling = () => + liaisonSignaling(LIAISON_URL, (url) => { + const socket = new FakeSocket(url); + sockets.push(socket); + return socket as unknown as WebSocket; + }); + +beforeEach(() => { + sockets = []; + vi.useFakeTimers(); +}); +afterEach(() => vi.useRealTimers()); + +describe("reading what liaison says", () => { + it("reads every message liaison sends", () => { + expect(parseServerMessage('{"type":"created","room":"K7Q-M4X"}')).toEqual({ type: "created", room: "K7Q-M4X" }); + expect(parseServerMessage('{"type":"joined"}')).toEqual({ type: "joined" }); + expect(parseServerMessage('{"type":"peer-joined"}')).toEqual({ type: "peer-joined" }); + expect(parseServerMessage('{"type":"peer-left"}')).toEqual({ type: "peer-left" }); + expect(parseServerMessage('{"type":"signal","data":{"offer":"sdp"}}')).toEqual({ type: "signal", data: { offer: "sdp" } }); + expect(parseServerMessage('{"type":"error","reason":"room-not-found"}')).toEqual({ type: "error", reason: "room-not-found" }); + }); + + it("reads the host's build from an other-build error", () => { + expect(parseServerMessage('{"type":"error","reason":"other-build","theirs":{"version":"1.3.0","commit":"b81e07d"}}')).toEqual({ + type: "error", + reason: "other-build", + theirs: { version: "1.3.0", commit: "b81e07d" }, + }); + }); + + it("ignores the relay liaison may offer, which this build does not use", () => { + expect(parseServerMessage('{"type":"created","room":"K7Q-M4X","iceServers":[{"urls":["turn:x"]}]}')).toEqual({ + type: "created", + room: "K7Q-M4X", + }); + }); + + it("takes nothing that is not exactly a message", () => { + for (const text of [ + "not json", + "null", + "[]", + '{"type":"created"}', + '{"type":"created","room":7}', + '{"type":"error"}', + '{"type":"error","reason":5}', + '{"type":"error","reason":"other-build","theirs":{"version":1}}', + '{"type":"signal"}', + '{"type":"unknown"}', + ]) { + expect(parseServerMessage(text), text).toBeNull(); + } + }); +}); + +describe("the connection to liaison", () => { + it("connects to liaison's address", async () => { + const opening = signaling().open(); + expect(sockets[0].url).toBe("wss://liaison.rmstdope.eu/ws"); + sockets[0].opens(); + await expect(opening).resolves.toBeDefined(); + }); + + it("cannot be opened when liaison cannot be reached", async () => { + const opening = signaling().open(); + sockets[0].fails(); + await expect(opening).rejects.toThrow(); + }); + + it("gives up when liaison does not answer within its limit", async () => { + const opening = signaling().open(); + const failed = expect(opening).rejects.toThrow(); + vi.advanceTimersByTime(OPEN_LIMIT_MS); + await failed; + expect(sockets[0].closed).toBe(true); + }); + + async function opened(): Promise { + const opening = signaling().open(); + sockets[0].opens(); + return opening; + } + + it("sends messages as JSON", async () => { + const socket = await opened(); + socket.send({ type: "join", room: "K7Q-M4X", build: { version: "1.3.0", commit: "4f2a9c1" } }); + expect(JSON.parse(sockets[0].sent[0])).toEqual({ type: "join", room: "K7Q-M4X", build: { version: "1.3.0", commit: "4f2a9c1" } }); + }); + + it("hands on what liaison says, and drops what it cannot read", async () => { + const socket = await opened(); + const heard: unknown[] = []; + socket.onMessage((message) => heard.push(message)); + sockets[0].says("garbage"); + sockets[0].says('{"type":"peer-joined"}'); + expect(heard).toEqual([{ type: "peer-joined" }]); + }); + + it("says when liaison ends the connection, but not when this side closes it", async () => { + const first = await opened(); + const ended = vi.fn(); + first.onClosed(ended); + sockets[0].ends(); + expect(ended).toHaveBeenCalledOnce(); + + const opening = signaling().open(); + sockets[1].opens(); + const second = await opening; + const notTold = vi.fn(); + second.onClosed(notTold); + second.close(); + sockets[1].ends(); + expect(sockets[1].closed).toBe(true); + expect(notTold).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/netplay/liaison.ts b/web/src/netplay/liaison.ts new file mode 100644 index 000000000..3d837631f --- /dev/null +++ b/web/src/netplay/liaison.ts @@ -0,0 +1,150 @@ +import type { BuildId } from "./build_id"; + +/** + * The meeting service for network play (nr-z4nw): liaison, github.com/rmstdope/liaison. Two + * browsers meet in a room named by a short code and swap, through it, the offer and answer that + * open their direct connection; liaison never sees the game. This is the only file that touches + * `WebSocket`; the session drives it through `Signaling`. + */ + +export const LIAISON_URL = "wss://liaison.rmstdope.eu/ws"; +/** How long reaching liaison may take before network play says it can't start. */ +export const OPEN_LIMIT_MS = 10_000; + +export type ClientMessage = + | { type: "create"; build: BuildId } + | { type: "join"; room: string; build: BuildId } + | { type: "signal"; data: unknown }; + +export type ErrorReason = + | "bad-message" + | "already-in-room" + | "not-in-room" + | "no-peer" + | "room-not-found" + | "room-full" + | "other-build" + | "server-busy" + | "too-many-attempts" + | "too-many-signals" + | "room-expired"; + +/** + * What liaison says. The TURN relay it may offer (`iceServers`) is left out: this build connects + * the two browsers directly only. + */ +export type ServerMessage = + | { type: "created"; room: string } + | { type: "joined" } + | { type: "peer-joined" } + | { type: "peer-left" } + | { type: "signal"; data: unknown } + | { type: "error"; reason: string; theirs?: BuildId }; + +/** An open connection to liaison. `onClosed` is told once if liaison or the network ends it. */ +export interface SignalingSocket { + send(message: ClientMessage): void; + onMessage(listener: (message: ServerMessage) => void): void; + onClosed(listener: () => void): void; + /** Closes it from this side; `onClosed` is not told. */ + close(): void; +} + +export interface Signaling { + /** Connects to liaison; fails when it cannot be reached within `OPEN_LIMIT_MS`. */ + open(): Promise; +} + +const isObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); + +function isBuild(value: unknown): value is BuildId { + return isObject(value) && typeof value.version === "string" && typeof value.commit === "string"; +} + +/** A message from liaison, read as untrusted: anything that is not exactly one is null. */ +export function parseServerMessage(text: string): ServerMessage | null { + let message: unknown; + try { + message = JSON.parse(text); + } catch { + return null; + } + if (!isObject(message)) return null; + switch (message.type) { + case "created": + return typeof message.room === "string" ? { type: "created", room: message.room } : null; + case "joined": + case "peer-joined": + case "peer-left": + return { type: message.type }; + case "signal": + return "data" in message ? { type: "signal", data: message.data } : null; + case "error": { + if (typeof message.reason !== "string") return null; + if (message.theirs === undefined) return { type: "error", reason: message.reason }; + return isBuild(message.theirs) + ? { type: "error", reason: message.reason, theirs: { version: message.theirs.version, commit: message.theirs.commit } } + : null; + } + default: + return null; + } +} + +function wrap(ws: WebSocket): SignalingSocket { + let closedHere = false; + let closedListener: () => void = () => {}; + ws.addEventListener("close", () => { + if (!closedHere) closedListener(); + }); + return { + send: (message) => ws.send(JSON.stringify(message)), + onMessage: (listener) => + ws.addEventListener("message", (event) => { + if (typeof event.data !== "string") return; + const message = parseServerMessage(event.data); + if (message) listener(message); + }), + onClosed: (listener) => { + closedListener = listener; + }, + close: () => { + closedHere = true; + ws.close(); + }, + }; +} + +export function liaisonSignaling(url = LIAISON_URL, makeSocket: (url: string) => WebSocket = (u) => new WebSocket(u)): Signaling { + return { + open: () => + new Promise((resolve, reject) => { + let ws: WebSocket; + try { + ws = makeSocket(url); + } catch (error) { + return reject(error); + } + const timer = setTimeout(() => { + ws.close(); + reject(new Error("liaison did not answer")); + }, OPEN_LIMIT_MS); + ws.addEventListener( + "open", + () => { + clearTimeout(timer); + resolve(wrap(ws)); + }, + { once: true } + ); + ws.addEventListener( + "close", + () => { + clearTimeout(timer); + reject(new Error("liaison could not be reached")); + }, + { once: true } + ); + }), + }; +} diff --git a/web/src/netplay/room_code.test.ts b/web/src/netplay/room_code.test.ts new file mode 100644 index 000000000..9e7fef893 --- /dev/null +++ b/web/src/netplay/room_code.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { ROOM_CODE_LENGTH, showRoomCode, tidyRoomCode } from "./room_code"; + +describe("tidying what is typed into a room code", () => { + it("takes letters in capitals whatever is typed", () => { + expect(tidyRoomCode("k7q")).toBe("K7Q"); + }); + + it("drops the characters a code never uses: I, O, 0, 1 and symbols", () => { + expect(tidyRoomCode("KI7O0Q1!?é")).toBe("K7Q"); + }); + + it("tidies away spaces and dashes, so a pasted code with or without its dash works", () => { + expect(tidyRoomCode(" k7q - m4x ")).toBe("K7QM4X"); + expect(tidyRoomCode("K7QM4X")).toBe("K7QM4X"); + }); + + it("holds at most six code characters", () => { + expect(tidyRoomCode("K7QM4XAB")).toBe("K7QM4X"); + expect(ROOM_CODE_LENGTH).toBe(6); + }); +}); + +describe("showing a room code", () => { + it("puts the dash in by itself after the third character", () => { + expect(showRoomCode("")).toBe(""); + expect(showRoomCode("K7")).toBe("K7"); + expect(showRoomCode("K7Q")).toBe("K7Q"); + expect(showRoomCode("K7QM")).toBe("K7Q-M"); + expect(showRoomCode("K7QM4X")).toBe("K7Q-M4X"); + }); +}); diff --git a/web/src/netplay/room_code.ts b/web/src/netplay/room_code.ts new file mode 100644 index 000000000..5e17926cd --- /dev/null +++ b/web/src/netplay/room_code.ts @@ -0,0 +1,21 @@ +/** + * Room codes (nr-z4nw): the six characters liaison names a room by, shown as `ABC-DEF`. The + * alphabet leaves out I, O, 0 and 1, which read alike, so a code read out loud or typed from a + * chat message cannot be mistaken. + */ + +export const ROOM_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; +export const ROOM_CODE_LENGTH = 6; + +/** What is typed or pasted, as code characters: in capitals, anything else dropped, at most six. */ +export function tidyRoomCode(text: string): string { + return [...text.toUpperCase()] + .filter((char) => ROOM_ALPHABET.includes(char)) + .join("") + .slice(0, ROOM_CODE_LENGTH); +} + +/** Tidied code characters as a player reads them: a dash after the third. */ +export function showRoomCode(chars: string): string { + return chars.length > 3 ? `${chars.slice(0, 3)}-${chars.slice(3)}` : chars; +} From 0a9504a9247437096cc25640fe91cf205f3f3ed7 Mon Sep 17 00:00:00 2001 From: Henrik Kurelid Date: Fri, 30 Oct 2026 15:40:53 +0100 Subject: [PATCH 2/4] feat(nr-z4nw): the two browsers meet through liaison by one room code instead of swapping codes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WCDKexiF6SkDodmyKWmppK --- web/src/netplay/code.test.ts | 52 --- web/src/netplay/code.ts | 67 ---- web/src/netplay/peer_link.ts | 5 +- web/src/netplay/session.test.ts | 468 ++++++++++++++++-------- web/src/netplay/session.ts | 326 +++++++++++------ web/src/netplay/session_sending.test.ts | 45 ++- 6 files changed, 577 insertions(+), 386 deletions(-) delete mode 100644 web/src/netplay/code.test.ts delete mode 100644 web/src/netplay/code.ts diff --git a/web/src/netplay/code.test.ts b/web/src/netplay/code.test.ts deleted file mode 100644 index 491ee9471..000000000 --- a/web/src/netplay/code.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { encodeCode, readCode } from "./code"; - -const OURS = { version: "1.3.0", commit: "4f2a9c1" }; -const THEIRS = { version: "1.3.0", commit: "b81e07d" }; -const SDP = "v=0\r\no=- 1 2 IN IP4 127.0.0.1\r\ns=-\r\na=candidate:1 1 udp 2113937151 abc.local 55146 typ host\r\n".repeat(4); - -describe("a network play code", () => { - it("starts with NSR1- and is one unbroken line", async () => { - const code = await encodeCode("host", OURS, SDP); - expect(code.startsWith("NSR1-")).toBe(true); - expect(code).toMatch(/^[A-Za-z0-9_\-]+$/); - }); - - it("gives back the host's connection details to a joiner on the same build", async () => { - const code = await encodeCode("host", OURS, SDP); - expect(await readCode(code, "host", OURS)).toEqual({ ok: true, sdp: SDP }); - }); - - it("gives back the reply's connection details to the host", async () => { - const code = await encodeCode("reply", OURS, SDP); - expect(await readCode(code, "reply", OURS)).toEqual({ ok: true, sdp: SDP }); - }); - - it("is read even when a chat app broke it over lines or padded it with spaces", async () => { - const code = await encodeCode("host", OURS, SDP); - const wrapped = ` ${code.slice(0, 40)}\n${code.slice(40, 90)}\r\n ${code.slice(90)}\n`; - expect(await readCode(wrapped, "host", OURS)).toEqual({ ok: true, sdp: SDP }); - }); - - it("is not a NESER code when it is ordinary text", async () => { - expect(await readCode("hello can u paste the thing", "host", OURS)).toEqual({ ok: false, problem: "not-neser" }); - expect(await readCode("", "host", OURS)).toEqual({ ok: false, problem: "not-neser" }); - }); - - it("is not a NESER code when only part of it was copied", async () => { - const code = await encodeCode("host", OURS, SDP); - expect(await readCode(code.slice(0, code.length - 30), "host", OURS)).toEqual({ ok: false, problem: "not-neser" }); - }); - - it("is the wrong kind when a reply is pasted where the host's code belongs, and the other way round", async () => { - const reply = await encodeCode("reply", OURS, SDP); - const host = await encodeCode("host", OURS, SDP); - expect(await readCode(reply, "host", OURS)).toEqual({ ok: false, problem: "wrong-kind" }); - expect(await readCode(host, "reply", OURS)).toEqual({ ok: false, problem: "wrong-kind" }); - }); - - it("names the other player's build when it differs from ours", async () => { - const code = await encodeCode("host", THEIRS, SDP); - expect(await readCode(code, "host", OURS)).toEqual({ ok: false, problem: "other-build", theirs: THEIRS }); - }); -}); diff --git a/web/src/netplay/code.ts b/web/src/netplay/code.ts deleted file mode 100644 index 42c6be457..000000000 --- a/web/src/netplay/code.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { sameBuild, type BuildId } from "./build_id"; - -/** - * The codes two players copy to each other to connect (nr-ayk5.3). The host's code carries its - * WebRTC offer and the joiner's reply code its answer, each with every network address the - * browser found, so no server is needed. A code is `NSR1-` and the base64url of the deflated JSON - * `{k, v, c, s}`: its kind, NESER version, commit and SDP. - */ -export type CodeKind = "host" | "reply"; - -export type CodeCheck = - | { ok: true; sdp: string } - | { ok: false; problem: "not-neser" } - | { ok: false; problem: "wrong-kind" } - | { ok: false; problem: "other-build"; theirs: BuildId }; - -const PREFIX = "NSR1-"; - -type Payload = { k: CodeKind; v: string; c: string; s: string }; - -async function pipe(bytes: Uint8Array, stream: CompressionStream | DecompressionStream): Promise { - const out = new Blob([bytes as BlobPart]).stream().pipeThrough(stream); - return new Uint8Array(await new Response(out).arrayBuffer()); -} - -function toBase64Url(bytes: Uint8Array): string { - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -function fromBase64Url(text: string): Uint8Array { - const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/")); - return Uint8Array.from(binary, (char) => char.charCodeAt(0)); -} - -export async function encodeCode(kind: CodeKind, build: BuildId, sdp: string): Promise { - const payload: Payload = { k: kind, v: build.version, c: build.commit, s: sdp }; - const packed = await pipe(new TextEncoder().encode(JSON.stringify(payload)), new CompressionStream("deflate-raw")); - return PREFIX + toBase64Url(packed); -} - -async function decode(text: string): Promise { - const code = text.replace(/\s+/g, ""); - if (!code.startsWith(PREFIX)) return null; - try { - const json = new TextDecoder().decode(await pipe(fromBase64Url(code.slice(PREFIX.length)), new DecompressionStream("deflate-raw"))); - const payload = JSON.parse(json) as Partial; - const fields = [payload.v, payload.c, payload.s].every((field) => typeof field === "string"); - return fields && (payload.k === "host" || payload.k === "reply") ? (payload as Payload) : null; - } catch { - return null; - } -} - -/** - * What a pasted code is, checked in the order a player fixes things: is it a NESER code at all, - * is it the kind this step wants, and was it made by the same NESER build as ours. - */ -export async function readCode(text: string, expected: CodeKind, own: BuildId): Promise { - const payload = await decode(text); - if (!payload) return { ok: false, problem: "not-neser" }; - if (payload.k !== expected) return { ok: false, problem: "wrong-kind" }; - const theirs = { version: payload.v, commit: payload.c }; - if (!sameBuild(own, theirs)) return { ok: false, problem: "other-build", theirs }; - return { ok: true, sdp: payload.s }; -} diff --git a/web/src/netplay/peer_link.ts b/web/src/netplay/peer_link.ts index d9aebeae6..28c4aa28b 100644 --- a/web/src/netplay/peer_link.ts +++ b/web/src/netplay/peer_link.ts @@ -1,6 +1,7 @@ /** * The direct connection between two browsers (nr-ayk5.3): one WebRTC data channel, set up from - * the offer and answer the two players copy to each other. No STUN or TURN server is asked, so + * an offer and an answer the two browsers swap through liaison (nr-z4nw). No STUN or TURN server + * is asked (liaison's relay is not used either), so * only the browsers' own addresses are offered and the two must reach each other directly. This * is the only file that touches `RTCPeerConnection`; the session drives it through `PeerLinks`. */ @@ -35,7 +36,7 @@ export interface PeerLinks { startJoin(hostSdp: string): Promise; } -/** A code carries every address at once, so the offer and answer wait for the browser to find them. */ +/** The offer and answer each carry every address at once, so they wait for the browser to find them. */ const GATHER_LIMIT_MS = 3000; /** How long a closing channel may take to send what is queued before the connection is ended anyway. */ const CLOSE_LIMIT_MS = 2000; diff --git a/web/src/netplay/session.test.ts b/web/src/netplay/session.test.ts index b9c48c768..92dc768ea 100644 --- a/web/src/netplay/session.test.ts +++ b/web/src/netplay/session.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { encodeCode } from "./code"; +import type { ClientMessage, ServerMessage, Signaling, SignalingSocket } from "./liaison"; import type { HostStart, JoinStart, PeerLink, PeerLinks } from "./peer_link"; -import { createNetplaySession, type NetplaySession } from "./session"; +import { CONNECT_LIMIT_MS, createNetplaySession, type NetplaySession } from "./session"; const OURS = { version: "1.3.0", commit: "4f2a9c1" }; const THEIRS = { version: "1.3.0", commit: "b81e07d" }; @@ -71,119 +71,153 @@ function fakeLinks() { return { links, accepted, joined, hostCancel, joinCancel }; } -/** - * Lets promises, compression streams and zero-delay work finish without moving the clock. A code - * on its way (a step that shows one, not yet failed) is waited for, however many turns the - * compression stream takes on a slow machine: a fixed count of turns was enough here and not on CI. - */ -async function settle() { - const turn = () => new Promise((r) => setImmediate(r)); - for (let i = 0; i < 20; i++) await turn(); - const codeDue = () => { - const view = session?.view(); - if (!view || view.phase === "failed" || view.ownCode !== null) return false; - return (view.role === "host" && view.step === 1) || (view.role === "join" && view.step === 2); +/** This browser's connection to liaison, which the test speaks for. */ +class FakeSocket implements SignalingSocket { + sent: ClientMessage[] = []; + closed = false; + private messageListener: (m: ServerMessage) => void = () => {}; + private closedListener: () => void = () => {}; + send(message: ClientMessage) { + this.sent.push(message); + } + onMessage(listener: (m: ServerMessage) => void) { + this.messageListener = listener; + } + onClosed(listener: () => void) { + this.closedListener = listener; + } + close() { + this.closed = true; + } + says(message: ServerMessage) { + this.messageListener(message); + } + /** Liaison, or the network, ended the connection. */ + ends() { + this.closedListener(); + } + sentOfType(type: T) { + return this.sent.filter((m): m is Extract => m.type === type); + } +} + +/** Liaison: every connection the session opens, or a refusal while `reachable` is false. */ +function fakeLiaison() { + const sockets: FakeSocket[] = []; + const liaison = { + reachable: true, + sockets, + last: () => sockets[sockets.length - 1], + signaling: { + open: async (): Promise => { + if (!liaison.reachable) throw new Error("unreachable"); + const socket = new FakeSocket(); + sockets.push(socket); + return socket; + }, + } as Signaling, }; - for (let i = 0; i < 10_000 && codeDue(); i++) await turn(); + return liaison; +} + +/** Lets promises and zero-delay work finish without moving the clock. */ +async function settle() { + for (let i = 0; i < 20; i++) await new Promise((r) => setImmediate(r)); } let session: NetplaySession; +let liaison: ReturnType; +let fake: ReturnType; beforeEach(() => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date", "performance"] }); + liaison = fakeLiaison(); + fake = fakeLinks(); + session = createNetplaySession({ signaling: liaison.signaling, links: fake.links, build: OURS }); }); afterEach(() => { session?.cancel(); vi.useRealTimers(); }); -async function hostToConnect(rtt: number) { - const fake = fakeLinks(); - session = createNetplaySession({ links: fake.links, build: OURS }); +/** The host's room is open, showing K7Q-M4X. */ +async function hostWaiting() { session.host(); await settle(); - session.next(); - await session.paste(await encodeCode("reply", OURS, "answer-sdp")); - session.connect(); + liaison.last().says({ type: "created", room: "K7Q-M4X" }); + return liaison.last(); +} + +/** Someone typed the host's code and the two swap offer and answer; the link is not open yet. */ +async function hostSwapped() { + const socket = await hostWaiting(); + socket.says({ type: "peer-joined" }); + await settle(); + socket.says({ type: "signal", data: { answer: "answer-sdp" } }); + await settle(); + return socket; +} + +async function hostToConnect(rtt: number) { + const socket = await hostSwapped(); const link = new FakeLink(rtt); fake.accepted.resolve(link); await settle(); - return { fake, link }; + return { socket, link }; } -async function joinToWaiting() { - const fake = fakeLinks(); - session = createNetplaySession({ links: fake.links, build: OURS }); +/** The joiner typed `code` and pressed Join; liaison has the request. */ +async function joinTried(code = "k7q-m4x") { session.join(); - await session.paste(await encodeCode("host", OURS, "offer-sdp")); - session.next(); + session.joinRoom(code); await settle(); - return fake; + return liaison.last(); +} + +/** The joiner is in the room, has the host's offer, and has sent its answer back. */ +async function joinToWaiting() { + const socket = await joinTried(); + socket.says({ type: "joined" }); + socket.says({ type: "signal", data: { offer: "offer-sdp" } }); + await settle(); + return socket; } describe("hosting", () => { - it("starts at step 1 with the host's code to send", async () => { - const fake = fakeLinks(); - session = createNetplaySession({ links: fake.links, build: OURS }); + it("asks liaison for a room for this build, and shows its code", async () => { session.host(); + expect(session.view()).toMatchObject({ role: "host", step: 1, phase: "opening", room: null }); await settle(); - const view = session.view(); - expect(view).toMatchObject({ role: "host", step: 1, phase: "idle" }); - expect(view.ownCode).toMatch(/^NSR1-/); + expect(liaison.last().sent).toEqual([{ type: "create", build: OURS }]); + liaison.last().says({ type: "created", room: "K7Q-M4X" }); + expect(session.view()).toMatchObject({ phase: "waiting", room: "K7Q-M4X", roomNote: null }); }); - it("keeps the same code when going Back from step 2", async () => { - const fake = fakeLinks(); - session = createNetplaySession({ links: fake.links, build: OURS }); - session.host(); - await settle(); - const code = session.view().ownCode; - session.next(); - expect(session.view().step).toBe(2); - session.back(); - expect(session.view()).toMatchObject({ step: 1, ownCode: code }); + it("waits for the other player without a time limit of its own", async () => { + await hostWaiting(); + await vi.advanceTimersByTimeAsync(14 * 60_000); + expect(session.view()).toMatchObject({ phase: "waiting", room: "K7Q-M4X" }); }); - it("can connect only once a valid reply code is pasted", async () => { - const fake = fakeLinks(); - session = createNetplaySession({ links: fake.links, build: OURS }); - session.host(); - await settle(); - session.next(); - expect(session.view().pasteReady).toBe(false); - await session.paste("hello can u paste the thing"); - expect(session.view()).toMatchObject({ pasteReady: false, pasteProblem: "not-neser" }); - await session.paste(await encodeCode("host", OURS, "x")); - expect(session.view()).toMatchObject({ pasteReady: false, pasteProblem: "wrong-kind" }); - await session.paste(await encodeCode("reply", OURS, "x")); - expect(session.view()).toMatchObject({ pasteReady: true, pasteProblem: null }); - }); - - it("stops at a reply from another NESER build, naming it", async () => { - const fake = fakeLinks(); - session = createNetplaySession({ links: fake.links, build: OURS }); - session.host(); + it("sends its offer through the room once someone has typed the code, and shows Connecting", async () => { + const socket = await hostWaiting(); + socket.says({ type: "peer-joined" }); + expect(session.view().phase).toBe("connecting"); await settle(); - session.next(); - await session.paste(await encodeCode("reply", THEIRS, "x")); - expect(session.view()).toMatchObject({ phase: "failed", failure: "other-build", theirs: THEIRS, pasteReady: false }); + expect(socket.sentOfType("signal")).toEqual([{ type: "signal", data: { offer: "offer-sdp-1" } }]); }); - it("hands the pasted reply to the connection and shows Connecting", async () => { - const fake = fakeLinks(); - session = createNetplaySession({ links: fake.links, build: OURS }); - session.host(); - await settle(); - session.next(); - await session.paste(await encodeCode("reply", OURS, "answer-sdp")); - session.connect(); - expect(session.view().phase).toBe("connecting"); + it("hands the joiner's answer to the connection, and leaves liaison once the link is open", async () => { + const { socket } = await hostToConnect(20); + expect(socket.closed).toBe(true); + await vi.advanceTimersByTimeAsync(2000); + expect(session.view()).toMatchObject({ step: 2, phase: "connected", rtt: 20 }); }); it("connects with a Great response time when round trips are short", async () => { const { link } = await hostToConnect(20); await vi.advanceTimersByTimeAsync(2000); - expect(session.view()).toMatchObject({ step: 3, phase: "connected", rtt: 20 }); + expect(session.view()).toMatchObject({ step: 2, phase: "connected", rtt: 20 }); expect(link.sentOfType("ready")).toEqual([{ t: "ready", rtt: 20 }]); }); @@ -202,88 +236,144 @@ describe("hosting", () => { }); it("lets an open link close itself, so the refusal is delivered before the connection ends", async () => { - const { fake, link } = await hostToConnect(200); + const { link } = await hostToConnect(200); await vi.advanceTimersByTimeAsync(3000); expect(link.closed).toBe(true); expect(fake.hostCancel).not.toHaveBeenCalled(); }); - it("gives up after 15 seconds when the other player cannot be reached", async () => { - const fake = fakeLinks(); - session = createNetplaySession({ links: fake.links, build: OURS }); - session.host(); + it("gives up 15 seconds after someone typed the code when the other player cannot be reached", async () => { + const socket = await hostWaiting(); + socket.says({ type: "peer-joined" }); await settle(); - session.next(); - await session.paste(await encodeCode("reply", OURS, "answer-sdp")); - session.connect(); - await vi.advanceTimersByTimeAsync(14_900); + socket.says({ type: "signal", data: { answer: "answer-sdp" } }); + await vi.advanceTimersByTimeAsync(CONNECT_LIMIT_MS - 100); expect(session.view().phase).toBe("connecting"); await vi.advanceTimersByTimeAsync(200); expect(session.view()).toMatchObject({ phase: "failed", failure: "unreachable" }); expect(fake.hostCancel).toHaveBeenCalled(); + expect(socket.closed).toBe(true); + }); + + it("gets a new code by itself when the old one runs out, saying so", async () => { + const old = await hostWaiting(); + old.says({ type: "error", reason: "room-expired" }); + expect(old.closed).toBe(true); + expect(session.view()).toMatchObject({ phase: "opening", room: null, roomNote: "expired" }); + await settle(); + expect(liaison.sockets).toHaveLength(2); + liaison.last().says({ type: "created", room: "T9W-2HB" }); + expect(session.view()).toMatchObject({ phase: "waiting", room: "T9W-2HB", roomNote: "expired" }); }); - it("starts again from step 1 with a fresh code", async () => { - const fake = fakeLinks(); - session = createNetplaySession({ links: fake.links, build: OURS }); + it("gets a new code by itself when the joiner leaves before the two swapped, saying so", async () => { + const old = await hostWaiting(); + old.says({ type: "peer-joined" }); + await settle(); + old.says({ type: "peer-left" }); + expect(fake.hostCancel).toHaveBeenCalled(); + await settle(); + liaison.last().says({ type: "created", room: "T9W-2HB" }); + expect(session.view()).toMatchObject({ phase: "waiting", room: "T9W-2HB", roomNote: "left", failure: null }); + }); + + it("does not take the joiner leaving the room once the two have swapped as leaving the game", async () => { + const socket = await hostSwapped(); + socket.says({ type: "peer-left" }); + socket.ends(); + expect(session.view()).toMatchObject({ phase: "connecting", failure: null }); + expect(liaison.sockets).toHaveLength(1); + }); + + it("cannot start when liaison cannot be reached", async () => { + liaison.reachable = false; session.host(); await settle(); - const first = session.view().ownCode; - session.next(); - await session.paste(await encodeCode("reply", OURS, "answer-sdp")); - session.connect(); - await vi.advanceTimersByTimeAsync(15_100); + expect(session.view()).toMatchObject({ role: "host", phase: "failed", failure: "service" }); + }); + + it("cannot go on when its connection to liaison drops while it waits", async () => { + const socket = await hostWaiting(); + socket.ends(); + expect(session.view()).toMatchObject({ phase: "failed", failure: "service" }); + }); + + it("says liaison is busy when it has too many rooms open", async () => { + session.host(); + await settle(); + liaison.last().says({ type: "error", reason: "server-busy" }); + expect(session.view()).toMatchObject({ phase: "failed", failure: "busy" }); + expect(liaison.last().closed).toBe(true); + }); + + it("tries again with a fresh connection to liaison", async () => { + liaison.reachable = false; + session.host(); + await settle(); + liaison.reachable = true; session.startAgain(); await settle(); - expect(session.view()).toMatchObject({ role: "host", step: 1, phase: "idle", failure: null }); - expect(session.view().ownCode).not.toBe(first); - expect(fake.hostCancel).toHaveBeenCalled(); + liaison.last().says({ type: "created", room: "K7Q-M4X" }); + expect(session.view()).toMatchObject({ role: "host", phase: "waiting", failure: null, room: "K7Q-M4X" }); + }); + + it("starts again with a fresh room after failing to reach the other player", async () => { + await hostSwapped(); + await vi.advanceTimersByTimeAsync(CONNECT_LIMIT_MS + 100); + session.startAgain(); + await settle(); + expect(liaison.sockets).toHaveLength(2); + liaison.last().says({ type: "created", room: "T9W-2HB" }); + expect(session.view()).toMatchObject({ role: "host", step: 1, phase: "waiting", failure: null, roomNote: null, room: "T9W-2HB" }); }); }); describe("joining", () => { - it("moves on only from a valid host code", async () => { - const fake = fakeLinks(); - session = createNetplaySession({ links: fake.links, build: OURS }); + it("starts with nothing tried", () => { session.join(); - expect(session.view()).toMatchObject({ role: "join", step: 1, pasteReady: false }); - await session.paste(await encodeCode("reply", OURS, "x")); - expect(session.view()).toMatchObject({ pasteReady: false, pasteProblem: "wrong-kind" }); - await session.paste(await encodeCode("host", OURS, "offer-sdp")); - expect(session.view()).toMatchObject({ pasteReady: true, pasteProblem: null }); + expect(session.view()).toMatchObject({ role: "join", step: 1, phase: "idle", joinProblem: null }); }); - it("finds out about another NESER build at step 1, before sending anything back", async () => { - const fake = fakeLinks(); - const startJoin = vi.spyOn(fake.links, "startJoin"); - session = createNetplaySession({ links: fake.links, build: OURS }); + it("tries nothing until the code has all six characters", async () => { session.join(); - await session.paste(await encodeCode("host", THEIRS, "offer-sdp")); - expect(session.view()).toMatchObject({ step: 1, phase: "failed", failure: "other-build", theirs: THEIRS }); - expect(startJoin).not.toHaveBeenCalled(); + session.joinRoom("K7Q-M4"); + await settle(); + expect(liaison.sockets).toHaveLength(0); + expect(session.view().phase).toBe("idle"); }); - it("shows its reply code at step 2 and waits for the host without a time limit", async () => { - await joinToWaiting(); - expect(session.view()).toMatchObject({ step: 2, phase: "waiting" }); - expect(session.view().ownCode).toMatch(/^NSR1-/); - await vi.advanceTimersByTimeAsync(10 * 60_000); - expect(session.view()).toMatchObject({ step: 2, phase: "waiting", failure: null }); + it("asks liaison for the room by its code, tidied, with this build", async () => { + const socket = await joinTried(" k7q m4x "); + expect(session.view().phase).toBe("connecting"); + expect(socket.sent).toEqual([{ type: "join", room: "K7Q-M4X", build: OURS }]); + }); + + it("answers the host's offer through the room", async () => { + const socket = await joinToWaiting(); + expect(socket.sentOfType("signal")).toEqual([{ type: "signal", data: { answer: "answer-to-offer-sdp" } }]); + }); + + it("leaves liaison once the link is open, and waits for the host's check", async () => { + const socket = await joinToWaiting(); + fake.joined.resolve(new FakeLink(20)); + await settle(); + expect(socket.closed).toBe(true); + expect(session.view()).toMatchObject({ step: 1, phase: "waiting" }); }); it("is connected once the host says the connection is good, with the host's response time", async () => { - const fake = await joinToWaiting(); + await joinToWaiting(); const link = new FakeLink(20); fake.joined.resolve(link); await settle(); link.deliver({ t: "ping", id: 1, at: 5 }); expect(link.sentOfType("pong")).toEqual([{ t: "pong", id: 1, at: 5 }]); link.deliver({ t: "ready", rtt: 31 }); - expect(session.view()).toMatchObject({ step: 3, phase: "connected", rtt: 31 }); + expect(session.view()).toMatchObject({ step: 2, phase: "connected", rtt: 31 }); }); it("is refused when the host found the connection too slow", async () => { - const fake = await joinToWaiting(); + await joinToWaiting(); const link = new FakeLink(200); fake.joined.resolve(link); await settle(); @@ -291,6 +381,92 @@ describe("joining", () => { expect(session.view()).toMatchObject({ phase: "failed", failure: "too-slow", rtt: 210 }); expect(link.closed).toBe(true); }); + + it("gives up 15 seconds after getting into the room when the host cannot be reached", async () => { + await joinToWaiting(); + await vi.advanceTimersByTimeAsync(CONNECT_LIMIT_MS + 100); + expect(session.view()).toMatchObject({ phase: "failed", failure: "unreachable" }); + expect(fake.joinCancel).toHaveBeenCalled(); + }); + + it("does not take the host leaving the room once the two have swapped as leaving", async () => { + const socket = await joinToWaiting(); + socket.says({ type: "peer-left" }); + socket.ends(); + expect(session.view()).toMatchObject({ phase: "connecting", failure: null, joinProblem: null }); + }); + + for (const [reason, problem] of [ + ["room-not-found", "room-not-found"], + ["room-full", "room-full"], + ["server-busy", "busy"], + ] as const) { + it(`goes back to the code, saying ${problem}, when liaison answers ${reason}, and tries again on the same connection`, async () => { + const socket = await joinTried(); + socket.says({ type: "error", reason }); + expect(session.view()).toMatchObject({ phase: "idle", joinProblem: problem, failure: null }); + session.joinRoom("K7QM4Y"); + expect(session.view()).toMatchObject({ phase: "connecting", joinProblem: null }); + await settle(); + expect(liaison.sockets).toHaveLength(1); + expect(socket.sentOfType("join").map((m) => m.room)).toEqual(["K7Q-M4X", "K7Q-M4Y"]); + }); + } + + it("stops at a host running another NESER build, naming it", async () => { + const socket = await joinTried(); + socket.says({ type: "error", reason: "other-build", theirs: THEIRS }); + expect(session.view()).toMatchObject({ phase: "failed", failure: "other-build", theirs: THEIRS }); + expect(socket.closed).toBe(true); + }); + + it("stops after too many wrong codes, and Start again begins with a fresh connection", async () => { + const socket = await joinTried(); + socket.says({ type: "error", reason: "too-many-attempts" }); + expect(session.view()).toMatchObject({ phase: "failed", failure: "too-many-attempts" }); + session.startAgain(); + expect(session.view()).toMatchObject({ role: "join", phase: "idle", failure: null }); + session.joinRoom("K7QM4X"); + await settle(); + expect(liaison.sockets).toHaveLength(2); + }); + + it("says the host closed the game when the host leaves before the two swapped", async () => { + const socket = await joinTried(); + socket.says({ type: "joined" }); + socket.says({ type: "peer-left" }); + expect(session.view()).toMatchObject({ phase: "idle", joinProblem: "host-left", failure: null }); + expect(socket.closed).toBe(true); + session.joinRoom("K7QM4X"); + await settle(); + expect(liaison.sockets).toHaveLength(2); + }); + + it("says liaison cannot be reached, keeping the code to try again", async () => { + liaison.reachable = false; + await joinTried(); + expect(session.view()).toMatchObject({ role: "join", phase: "idle", joinProblem: "service", failure: null }); + liaison.reachable = true; + session.joinRoom("K7QM4X"); + await settle(); + expect(session.view().phase).toBe("connecting"); + }); + + it("says liaison cannot be reached when its connection drops during a try", async () => { + const socket = await joinTried(); + socket.ends(); + expect(session.view()).toMatchObject({ phase: "idle", joinProblem: "service" }); + }); + + it("opens a fresh connection for the next try when liaison dropped it while the joiner was typing", async () => { + const socket = await joinTried(); + socket.says({ type: "error", reason: "room-not-found" }); + socket.ends(); + expect(session.view()).toMatchObject({ phase: "idle", joinProblem: "room-not-found" }); + session.joinRoom("K7QM4Y"); + await settle(); + expect(liaison.sockets).toHaveLength(2); + }); }); describe("once connected", () => { @@ -314,7 +490,7 @@ describe("once connected", () => { it("reports the other player leaving when the link closes", async () => { const { link } = await connected(20); link.drop(); - expect(session.view()).toMatchObject({ step: 3, phase: "failed", failure: "peer-left" }); + expect(session.view()).toMatchObject({ step: 2, phase: "failed", failure: "peer-left" }); }); it("reports the other player leaving when they say goodbye", async () => { @@ -339,20 +515,21 @@ describe("once connected", () => { }); describe("cancelling", () => { - it("cancels a host's pending connection and returns to the choice", async () => { - const fake = fakeLinks(); - session = createNetplaySession({ links: fake.links, build: OURS }); - session.host(); + it("closes a host's room and pending connection and returns to the choice", async () => { + const socket = await hostWaiting(); + socket.says({ type: "peer-joined" }); await settle(); session.cancel(); expect(fake.hostCancel).toHaveBeenCalled(); - expect(session.view()).toMatchObject({ role: null }); + expect(socket.closed).toBe(true); + expect(session.view()).toMatchObject({ role: null, phase: "idle" }); }); it("cancels a joiner who is waiting", async () => { - const fake = await joinToWaiting(); + const socket = await joinToWaiting(); session.cancel(); expect(fake.joinCancel).toHaveBeenCalled(); + expect(socket.closed).toBe(true); const link = new FakeLink(20); fake.joined.resolve(link); await settle(); @@ -360,38 +537,39 @@ describe("cancelling", () => { expect(session.view()).toMatchObject({ role: null, phase: "idle" }); }); - it("tells every change to its listener", async () => { - const fake = fakeLinks(); - session = createNetplaySession({ links: fake.links, build: OURS }); - const seen: number[] = []; - session.subscribe((view) => seen.push(view.step)); + it("closes a connection to liaison that opens only after Cancel", async () => { session.host(); + session.cancel(); await settle(); - session.next(); - expect(seen).toContain(2); + expect(liaison.last().closed).toBe(true); + expect(session.view()).toMatchObject({ role: null, phase: "idle" }); }); }); describe("a connection that cannot start", () => { it("tells a host whose browser cannot make a connection", async () => { - const fake = fakeLinks(); fake.links.startHost = () => Promise.reject(new Error("no WebRTC")); - session = createNetplaySession({ links: fake.links, build: OURS }); - session.host(); + const socket = await hostWaiting(); + socket.says({ type: "peer-joined" }); await settle(); expect(session.view()).toMatchObject({ phase: "failed", failure: "unreachable" }); }); - it("tells a joiner whose pasted code cannot be answered", async () => { - const fake = fakeLinks(); + it("tells a joiner whose browser cannot answer the host's offer", async () => { fake.links.startJoin = () => Promise.reject(new Error("bad SDP")); - session = createNetplaySession({ links: fake.links, build: OURS }); - session.join(); - await session.paste(await encodeCode("host", OURS, "offer-sdp")); - session.next(); - await settle(); + await joinToWaiting(); expect(session.view()).toMatchObject({ phase: "failed", failure: "unreachable" }); }); + + it("ignores signals that carry no offer or answer", async () => { + const socket = await joinTried(); + socket.says({ type: "joined" }); + socket.says({ type: "signal", data: { answer: "x" } }); + socket.says({ type: "signal", data: "nonsense" }); + await settle(); + expect(socket.sentOfType("signal")).toEqual([]); + expect(session.view().phase).toBe("connecting"); + }); }); describe("a link that goes silent", () => { @@ -407,8 +585,6 @@ describe("a link that goes silent", () => { describe("subscribers", () => { it("tells every subscriber of each change", async () => { - const fake = fakeLinks(); - session = createNetplaySession({ links: fake.links, build: OURS }); const first: string[] = []; const second: string[] = []; session.subscribe((v) => first.push(v.phase)); diff --git a/web/src/netplay/session.ts b/web/src/netplay/session.ts index 5bd3151e6..79bde3a52 100644 --- a/web/src/netplay/session.ts +++ b/web/src/netplay/session.ts @@ -1,14 +1,25 @@ import { THIS_BUILD, type BuildId } from "./build_id"; -import { encodeCode, readCode, type CodeKind } from "./code"; import { crc32, MAX_PACKAGE_BYTES, packGame, unpackGame, type GamePackage } from "./game_package"; +import type { Signaling, SignalingSocket, ServerMessage } from "./liaison"; import type { HostStart, JoinStart, PeerLink, PeerLinks } from "./peer_link"; import { isTooSlow, median } from "./response_time"; +import { ROOM_CODE_LENGTH, showRoomCode, tidyRoomCode } from "./room_code"; /** * Connecting two browsers for a network game (nr-ayk5.3), without the page: the host's and the - * joiner's steps, the codes they swap, the response-time check that decides whether the - * connection is good enough, and what happens when it ends. The Play together window draws - * `view()`; this module owns every bit of state behind it, and the open link once connected. + * joiner's steps, the response-time check that decides whether the connection is good enough, and + * what happens when it ends. The Play together window draws `view()`; this module owns every bit + * of state behind it, and the open link once connected. + * + * The two meet through liaison (nr-z4nw, `liaison.ts`): the host opens a room and shows its code; + * the joiner joins it by that code, and liaison checks the two run the same build. Once someone + * has joined, the host sends its offer through the room as `signal {offer}` and the joiner its + * answer as `signal {answer}`; once the direct link is open each side closes its connection to + * liaison. From the swap on, the other side leaving the room is no longer news: it is what the + * other side does once its link is open, and the link itself says whether the two connected. A + * host whose room ran out, or whose joiner left before the swap, gets a fresh room by itself. The + * joiner keeps one connection to liaison across its tries at a code, so liaison's limit on wrong + * codes holds; Start again begins with a fresh one. * * The two browsers talk in JSON text over the link: each answers `ping` with `pong`; the host * measures `CHECK_SAMPLES` round trips on connecting and sends `ready` or `refuse` with their @@ -42,8 +53,18 @@ import { isTooSlow, median } from "./response_time"; */ export type Role = "host" | "join"; -export type PasteProblem = "not-neser" | "wrong-kind"; -export type Failure = "other-build" | "unreachable" | "too-slow" | "peer-left"; +/** + * Why the joiner's step failed in a way the code field can fix: no room by that code, a full one, + * liaison out of reach or busy, or the host leaving before the two connected. + */ +export type JoinProblem = "room-not-found" | "room-full" | "service" | "busy" | "host-left"; +/** Why the host has a new room: the old one ran out, or the joiner left before the two connected. */ +export type RoomNote = "expired" | "left"; +/** + * Why an attempt ended for good: another build, no direct path, too slow, the other side leaving + * once connected, liaison out of reach or busy (host), or too many wrong codes (joiner). + */ +export type Failure = "other-build" | "unreachable" | "too-slow" | "peer-left" | "service" | "busy" | "too-many-attempts"; /** The line on the connected step about the last attempt to send the game. */ export type SendingNotice = "host-stopped" | "joiner-stopped" | "not-arrived"; @@ -92,14 +113,19 @@ export type Leaving = "ended" | "left" | "unsynced"; export interface NetplayView { /** Who this browser is; null on the first screen, where the player chooses. */ role: Role | null; - step: 1 | 2 | 3; - /** The code this browser shows (host step 1, joiner step 2); null while it is being made. */ - ownCode: string | null; - /** What is wrong with the pasted text, if anything; null when it is fine or empty. */ - pasteProblem: PasteProblem | null; - /** Whether the pasted text is a code this step can use. */ - pasteReady: boolean; - phase: "idle" | "connecting" | "waiting" | "connected" | "failed"; + step: 1 | 2; + /** The host's room code, as shown (`K7Q-M4X`); null until liaison has opened the room. */ + room: string | null; + /** Why the host's room is a new one, until the attempt ends. */ + roomNote: RoomNote | null; + /** What went wrong with the joiner's last try at a code; null before one, and while trying. */ + joinProblem: JoinProblem | null; + /** + * Host: `opening` (asking liaison for a room), `waiting` (the code shown), `connecting` (someone + * joined). Joiner: `idle` (typing a code), `connecting` (after Join), `waiting` (linked, + * waiting for the host's check). Both: `connected`, `failed`. + */ + phase: "idle" | "opening" | "waiting" | "connecting" | "connected" | "failed"; failure: Failure | null; /** The other player's build, when it is not ours. */ theirs: BuildId | null; @@ -117,10 +143,8 @@ export interface NetplaySession { subscribe(listener: (view: NetplayView) => void): void; host(): void; join(): void; - next(): void; - back(): void; - paste(text: string): Promise; - connect(): void; + /** The joiner's Join: tries `code`, as typed, once it has all six characters. */ + joinRoom(code: string): void; disconnect(): void; /** Ends whatever is under way and goes back to the choice: Cancel, or the window closing before connecting. */ cancel(): void; @@ -161,7 +185,7 @@ export function inputDelayFor(rttMs: number): number { return Math.min(MAX_INPUT_DELAY, Math.max(MIN_INPUT_DELAY, frames)); } -/** How long the host waits, from Connect, for the connection and its check. */ +/** How long the two have, from the joiner being in the room, for the connection and its check. */ export const CONNECT_LIMIT_MS = 15_000; const CHECK_SAMPLES = 5; const CHECK_INTERVAL_MS = 200; @@ -213,9 +237,9 @@ type Incoming = { id: number; total: number; crc: number; buffer: Uint8Array; re const IDLE: NetplayView = { role: null, step: 1, - ownCode: null, - pasteProblem: null, - pasteReady: false, + room: null, + roomNote: null, + joinProblem: null, phase: "idle", failure: null, theirs: null, @@ -239,12 +263,14 @@ function isPlayMessage(message: { t: string } & Record): messag } export function createNetplaySession({ + signaling, links, build = THIS_BUILD, checkGame = async () => 0, confirmStart = () => true, onStart = () => {}, }: { + signaling: Signaling; links: PeerLinks; build?: BuildId; /** @@ -261,8 +287,10 @@ export function createNetplaySession({ const listeners: ((view: NetplayView) => void)[] = []; /** Bumped whenever an attempt ends, so the late results of an abandoned one are dropped. */ let attempt = 0; - let pastedSdp: string | null = null; - let pasteCheck = 0; + /** The connection to liaison of the attempt under way, until the direct link is open. */ + let socket: SignalingSocket | null = null; + /** The offer and answer have been swapped: the other side leaving the room is no longer news. */ + let swapped = false; let hostStart: HostStart | null = null; let joinStart: JoinStart | null = null; let live: PeerLink | null = null; @@ -310,7 +338,8 @@ export function createNetplaySession({ hostStart = null; joinStart = null; live = null; - pastedSdp = null; + closeSocket(); + swapped = false; samples = []; unanswered = new Map(); } @@ -485,7 +514,7 @@ export function createNetplaySession({ function fail(failure: Failure, extra: Partial = {}) { teardown(); - set({ phase: "failed", failure, pasteReady: false, sending: null, notice: null, ...extra }); + set({ phase: "failed", failure, sending: null, notice: null, ...extra }); } function measureLive(link: PeerLink) { @@ -514,7 +543,7 @@ export function createNetplaySession({ function connected(link: PeerLink, rtt: number) { stopClocks(); - set({ step: 3, phase: "connected", rtt }); + set({ step: 2, phase: "connected", rtt }); measureLive(link); } @@ -567,25 +596,171 @@ export function createNetplaySession({ showLive(); } - function beginHost() { + function closeSocket() { + socket?.close(); + socket = null; + } + + /** Opens a connection to liaison for this attempt; `hear` and `lost` are told only while it is current. */ + function openSocket(mine: number, hear: (m: ServerMessage) => void, lost: () => void, then: (s: SignalingSocket) => void, failed: () => void) { + signaling.open().then( + (opened) => { + if (mine !== attempt) return opened.close(); + socket = opened; + opened.onMessage((m) => { + if (mine === attempt && socket === opened) hear(m); + }); + opened.onClosed(() => { + if (mine !== attempt || socket !== opened) return; + socket = null; + lost(); + }); + then(opened); + }, + () => { + if (mine === attempt) failed(); + } + ); + } + + /** No direct link within the limit, from the moment the joiner is in the room. */ + function startConnectLimit(mine: number) { + timers.add(setTimeout(() => mine === attempt && fail("unreachable"), CONNECT_LIMIT_MS)); + } + + const sdpIn = (data: unknown, key: "offer" | "answer"): string | null => { + if (typeof data !== "object" || data === null) return null; + const sdp = (data as Record)[key]; + return typeof sdp === "string" ? sdp : null; + }; + + function beginHost(note: RoomNote | null = null) { const mine = attempt; - set({ ...IDLE, role: "host" }); - (async () => { - const start = await links.startHost(); - if (mine !== attempt) return start.cancel(); - hostStart = start; - const code = await encodeCode("host", build, start.sdp); - if (mine === attempt) set({ ownCode: code }); - })().catch(() => { - // No working WebRTC, or no deflate: the nearest agreed message is that it could not connect. - if (mine === attempt) fail("unreachable"); - }); + set({ ...IDLE, role: "host", phase: "opening", roomNote: note }); + openSocket( + mine, + (m) => hostHears(m, mine), + // Once the two have swapped, liaison is no longer needed; before, its loss ends the attempt. + () => swapped || fail("service"), + (s) => s.send({ type: "create", build }), + () => fail("service") + ); + } + + /** A fresh room, the old one gone: it ran out, or its joiner left before the two connected. */ + function renewRoom(note: RoomNote) { + teardown(); + beginHost(note); + } + + function hostHears(m: ServerMessage, mine: number) { + if (m.type === "created" && state.phase === "opening") { + set({ phase: "waiting", room: showRoomCode(tidyRoomCode(m.room)) }); + } else if (m.type === "peer-joined" && state.phase === "waiting") { + set({ phase: "connecting" }); + startConnectLimit(mine); + links.startHost().then( + (start) => { + if (mine !== attempt) return start.cancel(); + hostStart = start; + socket?.send({ type: "signal", data: { offer: start.sdp } }); + }, + // No working WebRTC: the nearest agreed message is that it could not connect. + () => mine === attempt && fail("unreachable") + ); + } else if (m.type === "signal" && state.phase === "connecting" && hostStart && !swapped) { + const answer = sdpIn(m.data, "answer"); + if (answer === null) return; + swapped = true; + hostStart.accept(answer).then( + (link) => { + if (mine !== attempt) return link.close(); + closeSocket(); + hostCheck(link, mine); + }, + () => mine === attempt && fail("unreachable") + ); + } else if (m.type === "peer-left" && !swapped) { + renewRoom("left"); + } else if (m.type === "error" && !swapped) { + if (m.reason === "room-expired") renewRoom("expired"); + else if (m.reason === "server-busy") fail("busy"); + else fail("unreachable"); + } } function beginJoin() { set({ ...IDLE, role: "join" }); } + /** A try at a code that the field can put right: back to typing, saying why. */ + function joinTryFailed(problem: JoinProblem) { + stopClocks(); + set({ phase: "idle", joinProblem: problem }); + } + + function joinerHears(m: ServerMessage, mine: number) { + if (state.phase !== "connecting") return; + if (m.type === "joined") { + startConnectLimit(mine); + } else if (m.type === "signal" && !joinStart && !swapped) { + const offer = sdpIn(m.data, "offer"); + if (offer === null) return; + swapped = true; + links.startJoin(offer).then( + (start) => { + if (mine !== attempt) return start.cancel(); + joinStart = start; + socket?.send({ type: "signal", data: { answer: start.sdp } }); + start.link.then( + (link) => { + if (mine !== attempt) return link.close(); + closeSocket(); + set({ phase: "waiting" }); + joinerListen(link, mine); + }, + () => mine === attempt && fail("unreachable") + ); + }, + // An offer the browser refuses, or no working WebRTC. + () => mine === attempt && fail("unreachable") + ); + } else if (m.type === "peer-left" && !swapped) { + teardown(); + joinTryFailed("host-left"); + } else if (m.type === "error" && !swapped) { + if (m.reason === "room-not-found") joinTryFailed("room-not-found"); + else if (m.reason === "room-full") joinTryFailed("room-full"); + else if (m.reason === "server-busy") joinTryFailed("busy"); + else if (m.reason === "other-build" && m.theirs) fail("other-build", { theirs: m.theirs }); + else if (m.reason === "too-many-attempts") fail("too-many-attempts"); + else if (m.reason === "room-expired") { + teardown(); + joinTryFailed("host-left"); + } else fail("unreachable"); + } + } + + function joinRoom(code: string) { + const chars = tidyRoomCode(code); + if (state.role !== "join" || state.phase !== "idle" || chars.length !== ROOM_CODE_LENGTH) return; + const mine = attempt; + set({ phase: "connecting", joinProblem: null }); + const ask = (s: SignalingSocket) => s.send({ type: "join", room: showRoomCode(chars), build }); + if (socket) return ask(socket); + openSocket( + mine, + (m) => joinerHears(m, mine), + () => { + if (swapped || state.phase !== "connecting") return; + teardown(); + joinTryFailed("service"); + }, + ask, + () => joinTryFailed("service") + ); + } + function hostCheck(link: PeerLink, mine: number) { const check: number[] = []; const sampler = setInterval(() => ping(link), CHECK_INTERVAL_MS); @@ -619,27 +794,6 @@ export function createNetplaySession({ }); } - async function paste(text: string) { - const expected: CodeKind = state.role === "host" ? "reply" : "host"; - const mine = ++pasteCheck; - if (text.trim() === "") { - pastedSdp = null; - set({ pasteProblem: null, pasteReady: false }); - return; - } - const check = await readCode(text, expected, build); - if (mine !== pasteCheck) return; - if (check.ok) { - pastedSdp = check.sdp; - set({ pasteProblem: null, pasteReady: true }); - } else if (check.problem === "other-build") { - fail("other-build", { theirs: check.theirs, pasteProblem: null }); - } else { - pastedSdp = null; - set({ pasteProblem: check.problem, pasteReady: false }); - } - } - return { view: () => state, subscribe: (next) => { @@ -653,57 +807,7 @@ export function createNetplaySession({ teardown(); beginJoin(); }, - next: () => { - if (state.role === "host" && state.step === 1) { - set({ step: 2, pasteProblem: null, pasteReady: false }); - return; - } - if (state.role !== "join" || state.step !== 1 || !pastedSdp) return; - const mine = attempt; - const hostSdp = pastedSdp; - set({ step: 2, phase: "waiting", ownCode: null, pasteReady: false }); - (async () => { - const start = await links.startJoin(hostSdp); - if (mine !== attempt) return start.cancel(); - joinStart = start; - start.link.then( - (link) => { - if (mine !== attempt) return link.close(); - joinerListen(link, mine); - }, - () => { - if (mine === attempt) fail("unreachable"); - } - ); - const code = await encodeCode("reply", build, start.sdp); - if (mine === attempt) set({ ownCode: code }); - })().catch(() => { - // A code whose connection details the browser refuses, or no working WebRTC. - if (mine === attempt) fail("unreachable"); - }); - }, - back: () => { - if (state.role === "host" && state.step === 2 && state.phase === "idle") { - pastedSdp = null; - set({ step: 1, pasteProblem: null, pasteReady: false }); - } - }, - paste, - connect: () => { - if (state.role !== "host" || !hostStart || !pastedSdp || state.phase !== "idle") return; - const mine = attempt; - set({ phase: "connecting", pasteReady: false }); - timers.add(setTimeout(() => mine === attempt && fail("unreachable"), CONNECT_LIMIT_MS)); - hostStart.accept(pastedSdp).then( - (link) => { - if (mine !== attempt) return link.close(); - hostCheck(link, mine); - }, - () => { - if (mine === attempt) fail("unreachable"); - } - ); - }, + joinRoom, disconnect: () => { if (live) send(live, { t: "bye" }); teardown(); diff --git a/web/src/netplay/session_sending.test.ts b/web/src/netplay/session_sending.test.ts index af1a6bbc1..f3993f472 100644 --- a/web/src/netplay/session_sending.test.ts +++ b/web/src/netplay/session_sending.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { encodeCode } from "./code"; +import type { ClientMessage, ServerMessage, Signaling, SignalingSocket } from "./liaison"; import { MAX_PACKAGE_BYTES, type GamePackage } from "./game_package"; import type { HostStart, JoinStart, PeerLink, PeerLinks } from "./peer_link"; import { @@ -68,6 +68,32 @@ function linksTo(end: End): PeerLinks { }; } +/** Liaison as one room: the host's `create` opens it, the joiner's `join` puts both in it, and signals cross. */ +function liaisonRoom(): Signaling { + const listeners: ((m: ServerMessage) => void)[] = []; + const tell = (to: number, m: ServerMessage) => setTimeout(() => listeners[to]?.(m), 0); + return { + open: async (): Promise => { + const me = listeners.length; + listeners.push(() => {}); + return { + send: (m: ClientMessage) => { + if (m.type === "create") tell(me, { type: "created", room: "K7Q-M4X" }); + else if (m.type === "join") { + tell(me, { type: "joined" }); + tell(0, { type: "peer-joined" }); + } else tell(1 - me, { type: "signal", data: m.data }); + }, + onMessage: (listener) => { + listeners[me] = listener; + }, + onClosed: () => {}, + close: () => {}, + }; + }, + }; +} + async function settle() { for (let i = 0; i < 50; i++) await new Promise((r) => setImmediate(r)); } @@ -95,27 +121,30 @@ async function connected() { starts = []; checkGame = async () => 0; confirmStart = () => true; + const signaling = liaisonRoom(); host = createNetplaySession({ + signaling, links: linksTo(hostEnd), build: BUILD, confirmStart: (g) => confirmStart(g), onStart: (start) => starts.push({ side: "host", at: performance.now(), start }), }); join = createNetplaySession({ + signaling, links: linksTo(joinEnd), build: BUILD, checkGame: (g) => checkGame(g), onStart: (start) => starts.push({ side: "join", at: performance.now(), start }), }); - join.join(); - await join.paste(await encodeCode("host", BUILD, "offer")); - join.next(); host.host(); - for (let i = 0; i < 10_000 && host.view().ownCode === null; i++) await new Promise((r) => setImmediate(r)); - host.next(); - await host.paste(await encodeCode("reply", BUILD, "answer")); - host.connect(); await settle(); + await vi.advanceTimersByTimeAsync(0); + join.join(); + join.joinRoom(host.view().room ?? ""); + for (let i = 0; i < 10; i++) { + await settle(); + await vi.advanceTimersByTimeAsync(0); + } await vi.advanceTimersByTimeAsync(2000); expect(host.view().phase).toBe("connected"); expect(join.view().phase).toBe("connected"); From cacd144cf551d510a2132acbd53c926d0c492cbf Mon Sep 17 00:00:00 2001 From: Henrik Kurelid Date: Fri, 30 Oct 2026 15:48:08 +0100 Subject: [PATCH 3/4] feat(nr-z4nw): the Play together window shows and takes one short room code Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WCDKexiF6SkDodmyKWmppK --- README.md | 4 +- architecture.md | 2 +- web/index.html | 2 +- .../specs/play-together.integration.spec.ts | 112 +++-- web/main.css | 43 +- web/src/app.ts | 5 +- web/src/ui/play_together.test.ts | 383 +++++++++++------- web/src/ui/play_together.ts | 268 ++++++------ 8 files changed, 517 insertions(+), 302 deletions(-) diff --git a/README.md b/README.md index 03f6550ad..9b2a47abe 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,9 @@ desktop put `dsp1b.rom`, `dsp2.rom`, `dsp3.rom` or `dsp4.rom` in `~/.neser/firmw asks for each chip's file once. See [README-SNES.md](README-SNES.md). **Play together** (top bar) connects two browsers for a NES or Super Nintendo network game: -the host copies a code to the other player, who pastes it and sends one back. There is no server, +the host gets a short room code (like `K7Q-M4X`) to read out or copy, and the other player types it. +The two browsers meet through NESER's meeting service, liaison (`wss://liaison.rmstdope.eu/ws`), which +only passes the connection setup between them and never sees the game; they then connect directly, so both computers must be on the same network (or VPN). The two must run the same NESER build, and a connection slower than 120 ms round trip is refused. Once connected the host presses **Start game**: the game (with any SNES firmware it needs) and the host's current moment are sent to the other diff --git a/architecture.md b/architecture.md index de423f6aa..8d438abbd 100644 --- a/architecture.md +++ b/architecture.md @@ -567,7 +567,7 @@ The web frontend is bundled with **Vite** (config at `vite.config.ts`, root: `we | `web/src/save-state/` | Save state persistence using IndexedDB (`save_state_storage.ts`, `save_state_controller.ts`, `save_state_context.ts`), offered where the console table says so: NES, Game Boy and SNES games (`save_state_support.ts`), and the Load button's preview of the saved state, a picture of its frame and when it was saved, kept in the IndexedDB database `neser-save-previews` (`save_preview.ts`). | | `web/src/debugger/` | Browser-based debugger panels — disassembly, OAM viewer, watch expressions, PPU viewer layout/scroll — docked beside the picture, and whether the top bar shows its button (`debugger_dock.ts`, kept in `localStorage` under `neser.debuggerButton` and set from the settings panel's Developer tab; F5 opens it either way). | | `web/src/shortcuts/` | Keyboard shortcut actions, and which key targets they leave alone (`keyboard_target.ts`: editable fields and open dialogs). | -| `web/src/netplay/` | Connecting two browsers for a network game (nr-ayk5.3) and sending the host's game (nr-ayk5.4): `game_package.ts` (the ROM, any SNES firmware and the host's save state as one `NSRG` byte string, read back as untrusted: anything that does not describe itself exactly is refused, never thrown over; `crc32`, since `crypto.subtle` is missing on a plain-http page), `build_id.ts` (the NESER version and short commit Vite stamps in as `__NESER_VERSION__`/`__NESER_COMMIT__`; two browsers connect only on the same build), `code.ts` (the host and reply codes: `NSR1-` and the base64url of a deflated `{kind, version, commit, sdp}`, read back as ok, not a NESER code, the wrong kind, or another build), `response_time.ts` (Great under 50 ms, Playable to 120 ms, Too slow above), `peer_link.ts` (the only `RTCPeerConnection` user: one data channel from a non-trickle offer and answer with no STUN/TURN, so the two must reach each other directly) and `session.ts` (the host's and joiner's steps as a DOM-free state machine over injectable links: the host's 15 s connect limit and five-ping check that sends `ready` or `refuse`, live round trips once a second, `bye`; then sending the game: `game` announces it, 16 KiB binary chunks led by the transfer id follow, the joiner acknowledges each with `got` so at most 256 KiB is in flight, checks what came through an injected `checkGame` and answers `arrived` or `broken`; `stop` is either side's Cancel, ten silent seconds are a broken send, `arrived` carrying how long the joiner's test load took; then the host confirms it still runs the game (`confirmStart`) and says `start`, on which the joiner closes its own game and sets the received one up, while the host waits half the round trip measured before the send plus that time, so both counts begin together). `peer_link.ts` carries bytes as well as text. `app.ts` test-loads a received game on a scratch core before the joiner's own game is touched, and never keeps one (no library entry, picture or save state; it goes when the joiner leaves the ended game for the library). Playing together (nr-ayk5.5): `start` carries the input delay the host chose from its response time (`inputDelayFor`, 2-6 frames); lockstep packets ride the same link as bytes led by transfer id 0, beside the play messages `resume` and `reset`; `end` says the other player ended it, where `bye` says they left. `play.ts` is the DOM-free play controller each side owns over `WasmLockstep`, an injected core, link, clock and count: every local controller's buttons drive this side's player; a frame runs only once both pads are known, on ports 1 and 2; pause (with which window paused it) and reset travel in the pad's bits 12-15 so both games act after the same frame, and once both are stopped there, resuming and resetting are messages; it reports waiting (held half a second) and catching up (behind half a second, running two frames a tick). Resyncing (nr-ayk5.6): after every 60th frame each side hands the rules a CRC of its whole save state; a side whose rules find the two differ puts a resync command in its next pad, so both stop after the same frame; the host then sends its save state (`state`, then 16 KiB pieces led by transfer id `0xFFFFFFFF`, unacknowledged), the joiner loads it and says `resynced`, and both count "Resynced"; a failed load, a state that does not add up, or 15 s without getting back in step ends playing together on both sides with `unsynced`. Keeping full speed (nr-ayk5.7): `speed.ts` measures, per animation frame, how many frames a second this computer manages (what a frame costs is the wait until the next animation frame), and wants 95 % of it after three seconds below 90 % of the speed played at (a second whose frames took under 250 ms was mostly held and counts for neither), or full speed again after three at 97 %; `play.ts` sends what it wants as `speed`, both games run at the slower of the two sides' (the page's frame limiter follows `PlayView.speed`, and the sound is then never pitched down), and the first slowdown of a play together sets `slowNote` once, while playing, for the note worded for each side; a `speed` at or above full speed, or not a whole number of at least 1, asks nothing. Small clock differences need nothing more: lockstep evens them out one frame at a time, which the sound's lead absorbs. | +| `web/src/netplay/` | Connecting two browsers for a network game (nr-ayk5.3) and sending the host's game (nr-ayk5.4): `game_package.ts` (the ROM, any SNES firmware and the host's save state as one `NSRG` byte string, read back as untrusted: anything that does not describe itself exactly is refused, never thrown over; `crc32`, since `crypto.subtle` is missing on a plain-http page), `build_id.ts` (the NESER version and short commit Vite stamps in as `__NESER_VERSION__`/`__NESER_COMMIT__`; two browsers connect only on the same build), `room_code.ts` (the six-character room codes from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, shown as `ABC-DEF`, and tidying what is typed into one), `liaison.ts` (nr-z4nw: the only `WebSocket` user, a client for the liaison meeting service at `wss://liaison.rmstdope.eu/ws`, github.com/rmstdope/liaison: `create`, `join`, `signal`, and its answers read back as untrusted; liaison's TURN relay is not used), `response_time.ts` (Great under 50 ms, Playable to 120 ms, Too slow above), `peer_link.ts` (the only `RTCPeerConnection` user: one data channel from a non-trickle offer and answer with no STUN/TURN, so the two must reach each other directly) and `session.ts` (the host's and joiner's steps as a DOM-free state machine over injectable signaling and links: the host opens a liaison room and shows its code, the joiner joins by it, and the offer and answer cross as `signal {offer}` / `signal {answer}`, after which each side leaves liaison; a room that ran out, or whose joiner left before the swap, is replaced by itself; the joiner keeps one liaison connection across its tries so liaison's wrong-code limit holds; a 15 s connect limit from the joiner being in the room and five-ping check that sends `ready` or `refuse`, live round trips once a second, `bye`; then sending the game: `game` announces it, 16 KiB binary chunks led by the transfer id follow, the joiner acknowledges each with `got` so at most 256 KiB is in flight, checks what came through an injected `checkGame` and answers `arrived` or `broken`; `stop` is either side's Cancel, ten silent seconds are a broken send, `arrived` carrying how long the joiner's test load took; then the host confirms it still runs the game (`confirmStart`) and says `start`, on which the joiner closes its own game and sets the received one up, while the host waits half the round trip measured before the send plus that time, so both counts begin together). `peer_link.ts` carries bytes as well as text. `app.ts` test-loads a received game on a scratch core before the joiner's own game is touched, and never keeps one (no library entry, picture or save state; it goes when the joiner leaves the ended game for the library). Playing together (nr-ayk5.5): `start` carries the input delay the host chose from its response time (`inputDelayFor`, 2-6 frames); lockstep packets ride the same link as bytes led by transfer id 0, beside the play messages `resume` and `reset`; `end` says the other player ended it, where `bye` says they left. `play.ts` is the DOM-free play controller each side owns over `WasmLockstep`, an injected core, link, clock and count: every local controller's buttons drive this side's player; a frame runs only once both pads are known, on ports 1 and 2; pause (with which window paused it) and reset travel in the pad's bits 12-15 so both games act after the same frame, and once both are stopped there, resuming and resetting are messages; it reports waiting (held half a second) and catching up (behind half a second, running two frames a tick). Resyncing (nr-ayk5.6): after every 60th frame each side hands the rules a CRC of its whole save state; a side whose rules find the two differ puts a resync command in its next pad, so both stop after the same frame; the host then sends its save state (`state`, then 16 KiB pieces led by transfer id `0xFFFFFFFF`, unacknowledged), the joiner loads it and says `resynced`, and both count "Resynced"; a failed load, a state that does not add up, or 15 s without getting back in step ends playing together on both sides with `unsynced`. Keeping full speed (nr-ayk5.7): `speed.ts` measures, per animation frame, how many frames a second this computer manages (what a frame costs is the wait until the next animation frame), and wants 95 % of it after three seconds below 90 % of the speed played at (a second whose frames took under 250 ms was mostly held and counts for neither), or full speed again after three at 97 %; `play.ts` sends what it wants as `speed`, both games run at the slower of the two sides' (the page's frame limiter follows `PlayView.speed`, and the sound is then never pitched down), and the first slowdown of a play together sets `slowNote` once, while playing, for the note worded for each side; a `speed` at or above full speed, or not a whole number of at least 1, asks nothing. Small clock differences need nothing more: lockstep evens them out one frame at a time, which the sound's lead absorbs. | | `web/src/ui/` | Toast overlays (`toast_overlay.ts`), gamepad init toast, the control bar's volume (`volume.ts`, key `neser.volume`) and game title (`game_title.ts`) helpers, the settings panel (`settings_panel.ts`), the short lists Filter, Palette, Colours and Reset open (`choice_menu.ts`), the Play together window and its top-bar status chip (`play_together.ts`, drawing `netplay/session.ts`'s view one step at a time, Start game and the send's progress included), the 3-2-1 counts before and while playing together (`countdown.ts`), the note once both games run slower because one computer can't keep full speed (`slow_note.ts`, nr-ayk5.7: five seconds, once per play together, gone as soon as the game stops), the paused, waiting, catching-up, resyncing and ended screens over the shared game and the "End playing together?" question (`play_screen.ts`), the Keys and controls panel H and the top bar's ? open (`help_panel.ts`: the shortcut rows and a controller drawn for the running console's keys), where an error shows and its library title (`errors.ts`: a failed pick in the library, a stopped game over the picture), and the library (`library.ts`: grouping, search, system filter, and the release-news wave). | | `web/integration/` | Playwright-based end-to-end integration tests for the web frontend. | diff --git a/web/index.html b/web/index.html index ce520b92a..89ee16b1f 100644 --- a/web/index.html +++ b/web/index.html @@ -190,7 +190,7 @@

Bundled test ROMs

- +

Play together

diff --git a/web/integration/specs/play-together.integration.spec.ts b/web/integration/specs/play-together.integration.spec.ts index d08f1461c..ab745a314 100644 --- a/web/integration/specs/play-together.integration.spec.ts +++ b/web/integration/specs/play-together.integration.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type Browser, type BrowserContext, type Page } from "@playwright/test"; +import { test, expect, type Browser, type BrowserContext, type Page, type WebSocketRoute } from "@playwright/test"; import config from "../../../playwright.config"; import { loadGbaRomFromFileInput, @@ -22,6 +22,63 @@ test.use({ const CONNECT_TIMEOUT_MS = 20_000; +/** + * Liaison, the meeting service (nr-z4nw), played by the test so CI never reaches the real one: it + * opens rooms named K7Q-M4X, T9W-2HB, …, puts a joiner in by its code, passes signals between the + * two, and tells the one left when the other's connection closes, as liaison's README describes. + */ +type Room = { code: string; host: WebSocketRoute; joiner: WebSocketRoute | null }; +const ROOM_CODES = ["K7Q-M4X", "T9W-2HB", "P3R-8ZD", "H6N-5LQ"]; + +class FakeLiaison { + private rooms = new Map(); + private opened = 0; + + async serve(page: Page) { + await page.routeWebSocket(/liaison\.rmstdope\.eu/, (ws) => this.accept(ws)); + } + + private accept(ws: WebSocketRoute) { + let room: Room | null = null; + const tell = (to: WebSocketRoute, message: object) => to.send(JSON.stringify(message)); + ws.onMessage((text) => { + const message = JSON.parse(String(text)); + if (message.type === "create") { + room = { code: ROOM_CODES[this.opened++ % ROOM_CODES.length], host: ws, joiner: null }; + this.rooms.set(room.code, room); + tell(ws, { type: "created", room: room.code }); + } else if (message.type === "join") { + const wanted = this.rooms.get(message.room); + if (!wanted) return tell(ws, { type: "error", reason: "room-not-found" }); + if (wanted.joiner) return tell(ws, { type: "error", reason: "room-full" }); + wanted.joiner = ws; + room = wanted; + tell(ws, { type: "joined" }); + tell(wanted.host, { type: "peer-joined" }); + } else if (message.type === "signal" && room) { + const other = ws === room.host ? room.joiner : room.host; + if (other) tell(other, { type: "signal", data: message.data }); + } + }); + ws.onClose(() => { + if (!room) return; + this.rooms.delete(room.code); + const other = ws === room.host ? room.joiner : room.host; + room = null; + if (!other) return; + tell(other, { type: "peer-left" }); + void other.close(); + }); + } +} + +let liaison: FakeLiaison; + +test.beforeEach(async ({ page }) => { + liaison = new FakeLiaison(); + await liaison.serve(page); +}); + function panel(page: Page) { return page.locator("#netplay-panel"); } @@ -32,8 +89,8 @@ async function openPlayTogether(page: Page) { } async function shownCode(page: Page): Promise { - const box = panel(page).locator(".netplay-code"); - await expect(box).toHaveText(/^NSR1-/, { timeout: CONNECT_TIMEOUT_MS }); + const box = panel(page).locator(".netplay-room"); + await expect(box).toHaveText(/^[A-Z2-9]{3}-[A-Z2-9]{3}$/, { timeout: CONNECT_TIMEOUT_MS }); return (await box.textContent())!; } @@ -42,7 +99,9 @@ let joinerContext: BrowserContext | null = null; async function secondBrowser(browser: Browser): Promise { joinerContext = await browser.newContext({ baseURL: test.info().project.use.baseURL }); - return joinerContext.newPage(); + const page = await joinerContext.newPage(); + await liaison.serve(page); + return page; } test.afterEach(async () => { @@ -50,21 +109,16 @@ test.afterEach(async () => { joinerContext = null; }); -/** The two players swap codes until both windows say they are connected. */ +/** The host shows its room code and the joiner types it, until both windows say they are connected. */ async function connect(host: Page, joiner: Page) { await openPlayTogether(host); await panel(host).getByRole("button", { name: /Host this game/ }).click(); - const hostCode = await shownCode(host); - await panel(host).getByRole("button", { name: "Next" }).click(); + const code = await shownCode(host); await openPlayTogether(joiner); await panel(joiner).getByRole("button", { name: /Join a game/ }).click(); - await panel(joiner).getByPlaceholder("Paste the host's code here").fill(hostCode); - await panel(joiner).getByRole("button", { name: "Next" }).click(); - const reply = await shownCode(joiner); - - await panel(host).getByPlaceholder("Paste their code here").fill(reply); - await panel(host).getByRole("button", { name: "Connect" }).click(); + await panel(joiner).getByPlaceholder("ABC-DEF").fill(code); + await panel(joiner).getByRole("button", { name: "Join", exact: true }).click(); await expect(panel(host)).toContainText("Connected to the other player.", { timeout: CONNECT_TIMEOUT_MS }); await expect(panel(joiner)).toContainText("Connected to the host.", { timeout: CONNECT_TIMEOUT_MS }); } @@ -172,7 +226,7 @@ function resyncScreen(page: Page) { } test.describe("Play together", () => { - test("Given a host running a NES game and a joiner in the library, when they swap codes, then both are connected with a Great response time, and the host's chip shows when the joiner leaves", async ({ + test("Given a host running a NES game and a joiner in the library, when the joiner types the host's room code, then both are connected with a Great response time, and the host's chip shows when the joiner leaves", async ({ page: host, browser }) => { @@ -185,18 +239,17 @@ test.describe("Play together", () => { await openPlayTogether(host); await waitForPausedState(host); await panel(host).getByRole("button", { name: /Host this game/ }).click(); - const hostCode = await shownCode(host); - await panel(host).getByRole("button", { name: "Next" }).click(); + const code = await shownCode(host); + await expect(panel(host)).toContainText("⟳ Waiting for the other player to type it…"); + await expect(panel(host).getByRole("button", { name: "Copy code" })).toBeFocused(); await openPlayTogether(joiner); await panel(joiner).getByRole("button", { name: /Join a game/ }).click(); - await panel(joiner).getByPlaceholder("Paste the host's code here").fill(hostCode); - await panel(joiner).getByRole("button", { name: "Next" }).click(); - const reply = await shownCode(joiner); - await expect(panel(joiner)).toContainText("⟳ Waiting for the host to paste your code…"); - - await panel(host).getByPlaceholder("Paste their code here").fill(reply); - await panel(host).getByRole("button", { name: "Connect" }).click(); + const field = panel(joiner).getByPlaceholder("ABC-DEF"); + await expect(field).toBeFocused(); + await field.pressSequentially(code.replace("-", "").toLowerCase()); + await expect(field).toHaveValue(code); + await field.press("Enter"); await expect(panel(host)).toContainText("Connected to the other player.", { timeout: CONNECT_TIMEOUT_MS }); await expect(panel(host).locator(".netplay-pill")).toHaveText(/^Response time \d+ ms · Great$/); await expect(panel(joiner)).toContainText("Connected to the host.", { timeout: CONNECT_TIMEOUT_MS }); @@ -423,14 +476,19 @@ test.describe("Play together", () => { await expect(panel(page).locator("[data-choice=join]")).toBeFocused(); }); - test("Given a joiner, when the pasted text is not a code, then it says so and Next stays disabled", async ({ page }) => { + test("Given a joiner, when the code typed is no game's, then it says so beneath the field and keeps the code to fix", async ({ page }) => { await openApp(page); await openPlayTogether(page); await expect(panel(page).locator("[data-choice=host]")).toContainText("Host a game"); await panel(page).getByRole("button", { name: /Join a game/ }).click(); - await panel(page).getByPlaceholder("Paste the host's code here").fill("hello can u paste the thing"); - await expect(panel(page)).toContainText("That isn't a NESER code."); - await expect(panel(page).getByRole("button", { name: "Next" })).toBeDisabled(); + const field = panel(page).getByPlaceholder("ABC-DEF"); + await field.fill("abc-de"); + await expect(panel(page).getByRole("button", { name: "Join", exact: true })).toBeDisabled(); + await field.pressSequentially("f"); + await panel(page).getByRole("button", { name: "Join", exact: true }).click(); + await expect(panel(page)).toContainText("There's no game with that code. Check the code with the host and try again."); + await expect(field).toHaveValue("ABC-DEF"); + await expect(field).toBeFocused(); await page.keyboard.press("Escape"); await expect(panel(page)).toBeHidden(); await expect(page.locator("#netplay-open")).toBeFocused(); diff --git a/web/main.css b/web/main.css index b10618048..fb3e04ad7 100644 --- a/web/main.css +++ b/web/main.css @@ -2434,34 +2434,47 @@ body.handheld:not(.handheld-portrait).touch-running .screen-wrap canvas { border-color: var(--np-ok); } -.netplay-code, -.netplay-paste { +/* The room code (nr-z4nw): large monospace letters on one line, shrinking on a narrow window. */ +.netplay-room, +.netplay-room-field { display: block; width: 100%; - padding: 10px; - font: 11.5px/1.45 ui-monospace, Menlo, monospace; + font-family: ui-monospace, Menlo, monospace; + letter-spacing: 0.14em; color: #b9dcff; background: #08101b; - border-radius: 8px; + white-space: nowrap; } -/* The code scrolls inside its box; about five lines show. */ -.netplay-code { - max-height: 92px; - overflow-y: auto; - word-break: break-all; +.netplay-room { + margin: 6px 0; + padding: 14px 8px; + font-size: clamp(28px, 10vw, 40px); + text-align: center; border: 1px solid var(--line); + border-radius: 12px; } -.netplay-paste { - resize: vertical; - border: 1px dashed var(--line-strong); +.netplay-room-field { + padding: 10px 12px; + font-size: 24px; + border: 1px solid var(--line-strong); + border-radius: 10px; +} + +.netplay-room-field::placeholder { + color: #3d4f66; } -.netplay-paste:focus { +.netplay-room-field:focus { outline: none; - border-style: solid; border-color: var(--steel-hi); + box-shadow: 0 0 0 2px rgba(127, 176, 227, 0.35); +} + +.netplay-copied { + margin-top: 8px; + overflow-wrap: anywhere; } .netplay-actions { diff --git a/web/src/app.ts b/web/src/app.ts index 0b5318b1a..b3fe0281a 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -90,6 +90,7 @@ import { initTouchControls, isTouchDevice, isHandheldDevice } from "./input/touc import { dispatchWebShortcutAction } from "./shortcuts/shortcut_actions"; import { applyHelpPadFit, helpChips, helpPadKeys, helpPadZoom, renderHelpShortcuts, renderPad, renderPadKeyList } from "./ui/help_panel"; import { THIS_BUILD } from "./netplay/build_id"; +import { liaisonSignaling } from "./netplay/liaison"; import { webRtcLinks } from "./netplay/peer_link"; import { NETPLAY_KINDS, type GamePackage } from "./netplay/game_package"; import { createNetplaySession, type NetplayStart, type Role } from "./netplay/session"; @@ -3580,8 +3581,10 @@ function toggleShortcutHelp() { } // ── Play together: two browsers connect for a network game (nr-ayk5.3); the game pauses while it is open ── -// The host's game is sent to the joiner and both start together (nr-ayk5.4). +// The host's game is sent to the joiner and both start together (nr-ayk5.4). The two meet +// through liaison by one short room code (nr-z4nw). const netplaySession = createNetplaySession({ + signaling: liaisonSignaling(), links: webRtcLinks, build: THIS_BUILD, checkGame: checkReceivedGame, diff --git a/web/src/ui/play_together.test.ts b/web/src/ui/play_together.test.ts index 1af2409f7..f311a6d71 100644 --- a/web/src/ui/play_together.test.ts +++ b/web/src/ui/play_together.test.ts @@ -9,9 +9,9 @@ import { createPlayTogether, type CurrentGame } from "./play_together"; const IDLE: NetplayView = { role: null, step: 1, - ownCode: null, - pasteProblem: null, - pasteReady: false, + room: null, + roomNote: null, + joinProblem: null, phase: "idle", failure: null, theirs: null, @@ -24,12 +24,9 @@ const IDLE: NetplayView = { class FakeSession implements NetplaySession { state: NetplayView = { ...IDLE }; private listener: (view: NetplayView) => void = () => {}; - host = vi.fn(() => this.emit({ role: "host", step: 1, ownCode: "NSR1-hostcode" })); + host = vi.fn(() => this.emit({ role: "host", step: 1, phase: "waiting", room: "K7Q-M4X" })); join = vi.fn(() => this.emit({ role: "join", step: 1 })); - next = vi.fn(); - back = vi.fn(); - paste = vi.fn(async () => {}); - connect = vi.fn(); + joinRoom = vi.fn(); disconnect = vi.fn(() => this.emit({ ...IDLE })); cancel = vi.fn(() => this.emit({ ...IDLE })); startAgain = vi.fn(); @@ -89,6 +86,9 @@ function title() { function open() { chip().click(); } +function alerts() { + return [...body().querySelectorAll(".netplay-alert")].map((a) => [a.dataset.tone, a.textContent]); +} beforeEach(() => { document.body.innerHTML = ` @@ -172,90 +172,124 @@ describe("hosting", () => { open(); body().querySelector("[data-choice=host]")!.click(); } + function room() { + return body().querySelector(".netplay-room")?.textContent; + } + const INVITE = + "Play Super Mario Bros. 3 with me in NESER! Open https://neser.rmstdope.eu, press Play together, then Join a game, and type the code K7Q-M4X"; - it("shows step 1 with the code to send", () => { + it("shows the room code straight away, with Copy code in focus", () => { hostStep1(); expect(session.host).toHaveBeenCalled(); expect(title()).toBe("Host this game"); const steps = [...body().querySelectorAll(".netplay-steps span")].map((s) => [s.textContent, s.className]); expect(steps).toEqual([ - ["1. Send your code", "on"], - ["2. Paste their code", ""], - ["3. Connected", ""], + ["1. Share the code", "on"], + ["2. Connected", ""], ]); - expect(text()).toContain("Send this code to the other player — by chat, email, anything."); - expect(body().querySelector(".netplay-code")!.textContent).toBe("NSR1-hostcode"); - expect(buttonNames()).toEqual(["Cancel", "Copy code", "Next"]); + expect(text()).toContain("Tell the other player this code:"); + expect(room()).toBe("K7Q-M4X"); + expect(text()).toContain("⟳ Waiting for the other player to type it…"); + expect(buttonNames()).toEqual(["Cancel", "Copy invite message", "Copy code"]); + expect(document.activeElement).toBe(button("Copy code")); + }); + + it("cannot copy while the code is on its way, and gives Copy code focus once it is there", () => { + open(); + session.host.mockImplementationOnce(() => session.emit({ role: "host", step: 1, phase: "opening", room: null })); + body().querySelector("[data-choice=host]")!.click(); + expect(room()).toBe("…"); + expect(button("Copy code")!.disabled).toBe(true); + expect(button("Copy invite message")!.disabled).toBe(true); + session.emit({ phase: "waiting", room: "K7Q-M4X" }); + expect(room()).toBe("K7Q-M4X"); + expect(button("Copy code")!.disabled).toBe(false); + expect(document.activeElement).toBe(button("Copy code")); }); - it("copies the code, says so, and moves on only with Next", async () => { + it("copies only the code, and says Copied for a moment", async () => { vi.useFakeTimers(); hostStep1(); button("Copy code")!.click(); await vi.advanceTimersByTimeAsync(0); - expect(writeText).toHaveBeenCalledWith("NSR1-hostcode"); - expect(buttonNames()).toContain("✓ Copied"); - expect(session.next).not.toHaveBeenCalled(); + expect(writeText).toHaveBeenCalledWith("K7Q-M4X"); + expect(buttonNames()).toEqual(["Cancel", "Copy invite message", "✓ Copied"]); await vi.advanceTimersByTimeAsync(1600); - expect(buttonNames()).toContain("Copy code"); - button("Next")!.click(); - expect(session.next).toHaveBeenCalled(); + expect(buttonNames()).toEqual(["Cancel", "Copy invite message", "Copy code"]); }); - it("waits for its code before it can be copied", () => { + it("copies the invite message in one press, and shows what was copied", async () => { + vi.useFakeTimers(); hostStep1(); - session.emit({ ownCode: null }); - expect(button("Copy code")!.disabled).toBe(true); - expect(button("Next")!.disabled).toBe(true); + button("Copy invite message")!.click(); + await vi.advanceTimersByTimeAsync(0); + expect(writeText).toHaveBeenCalledWith(INVITE); + expect(buttonNames()).toEqual(["Cancel", "✓ Invite copied", "Copy code"]); + expect(body().querySelector(".netplay-copied")!.textContent).toBe(`Copied: “${INVITE}”`); + await vi.advanceTimersByTimeAsync(1600); + expect(buttonNames()).toEqual(["Cancel", "Copy invite message", "Copy code"]); + expect(body().querySelector(".netplay-copied")!.textContent).toBe(`Copied: “${INVITE}”`); }); - it("asks for the reply at step 2, with Connect waiting for a valid one", () => { - hostStep1(); - session.emit({ step: 2 }); - expect(text()).toContain("Paste the code the other player sends back."); - const box = body().querySelector("textarea")!; - expect(box.placeholder).toBe("Paste their code here"); - expect(document.activeElement).toBe(box); - expect(buttonNames()).toEqual(["Back", "Connect"]); - expect(button("Connect")!.disabled).toBe(true); - box.value = "NSR1-reply"; - box.dispatchEvent(new Event("input")); - expect(session.paste).toHaveBeenCalledWith("NSR1-reply"); - session.emit({ pasteReady: true }); - expect(button("Connect")!.disabled).toBe(false); - expect(document.activeElement).toBe(box); - expect(box.value).toBe("NSR1-reply"); - button("Connect")!.click(); - expect(session.connect).toHaveBeenCalled(); - }); - - it("says what is wrong with a pasted reply, in the host's words", () => { + it("shows a new code by itself when the old one ran out, clearing what was copied", async () => { hostStep1(); - session.emit({ step: 2, pasteProblem: "not-neser" }); - expect(text()).toContain("That isn't a NESER code. Copy the whole code from the other player's window and paste it again."); - session.emit({ pasteProblem: "wrong-kind" }); - expect(text()).toContain("This is a host's code, not the other player's reply. Ask the other player for the code from their window."); + button("Copy invite message")!.click(); + await new Promise((r) => setTimeout(r, 0)); + session.emit({ phase: "opening", room: null, roomNote: "expired" }); + session.emit({ phase: "waiting", room: "T9W-2HB" }); + expect(room()).toBe("T9W-2HB"); + expect(alerts()).toEqual([["warn", "The old code ran out, so here is a new one."]]); + expect(text()).toContain("⟳ Waiting for the other player to type it…"); + expect(body().querySelector(".netplay-copied")).toBeNull(); + button("Copy code")!.click(); + await new Promise((r) => setTimeout(r, 0)); + expect(writeText).toHaveBeenLastCalledWith("T9W-2HB"); }); - it("names both builds when the other player's NESER differs", () => { + it("shows a new code by itself when the other player left before connecting", () => { hostStep1(); - session.emit({ step: 2, phase: "failed", failure: "other-build", theirs: { version: "1.3.0", commit: "b81e07d" } }); - expect(text()).toContain( - "Different versions of NESER. You have NESER 1.3.0 (4f2a9c1), the other player has 1.3.0 (b81e07d). You both need the same version to play together — reload the page on both computers." - ); - expect(buttonNames()).toEqual(["Close"]); + session.emit({ phase: "connecting" }); + session.emit({ phase: "opening", room: null, roomNote: "left" }); + session.emit({ phase: "waiting", room: "T9W-2HB" }); + expect(room()).toBe("T9W-2HB"); + expect(alerts()).toEqual([["warn", "The other player left before you were connected. Here is a new code."]]); + expect(buttonNames()).toEqual(["Cancel", "Copy invite message", "Copy code"]); }); - it("shows Connecting with Cancel", () => { + it("moves on by itself once the other player typed the code, offering only Cancel", () => { hostStep1(); - session.emit({ step: 2, phase: "connecting" }); - expect(text()).toContain("⟳ Connecting…"); + session.emit({ phase: "connecting" }); + expect(room()).toBe("K7Q-M4X"); + expect(text()).toContain("⟳ The other player is here — connecting…"); + expect(text()).not.toContain("Waiting for the other player"); expect(buttonNames()).toEqual(["Cancel"]); }); + it("says when the meeting service cannot be reached, offering to try again", () => { + hostStep1(); + session.emit({ phase: "failed", failure: "service", room: null }); + expect(alerts()).toEqual([ + [ + "err", + "Network play can't start right now. NESER couldn't reach its meeting service on the internet. Check that this computer is online, then try again.", + ], + ]); + expect(room()).toBeUndefined(); + expect(buttonNames()).toEqual(["Close", "Try again"]); + button("Try again")!.click(); + expect(session.startAgain).toHaveBeenCalled(); + }); + + it("says when too many games are being started, offering to try again", () => { + hostStep1(); + session.emit({ phase: "failed", failure: "busy", room: null }); + expect(alerts()).toEqual([["err", "Too many games are being started right now. Wait a minute, then try again."]]); + expect(buttonNames()).toEqual(["Close", "Try again"]); + }); + it("says when the other player could not be reached, offering to start again", () => { hostStep1(); - session.emit({ step: 2, phase: "failed", failure: "unreachable" }); + session.emit({ phase: "failed", failure: "unreachable" }); expect(text()).toContain( "Couldn't reach the other player. Network play needs both computers on the same network (or the same VPN). Check that, then start again." ); @@ -264,9 +298,14 @@ describe("hosting", () => { expect(session.startAgain).toHaveBeenCalled(); }); - it("is connected at step 3 with a Great response time", () => { + it("is connected at step 2 with a Great response time", () => { hostStep1(); - session.emit({ step: 3, phase: "connected", rtt: 18 }); + session.emit({ step: 2, phase: "connected", rtt: 18 }); + const steps = [...body().querySelectorAll(".netplay-steps span")].map((s) => [s.textContent, s.className]); + expect(steps).toEqual([ + ["1. Share the code", "done"], + ["2. Connected", "on"], + ]); expect(text()).toContain("Connected to the other player."); expect(body().querySelector(".netplay-pill")!.textContent).toBe("Response time 18 ms · Great"); expect(body().querySelector(".netplay-pill")!.getAttribute("data-tone")).toBe("ok"); @@ -276,14 +315,14 @@ describe("hosting", () => { it("warns when the response time is only Playable", () => { hostStep1(); - session.emit({ step: 3, phase: "connected", rtt: 84 }); + session.emit({ step: 2, phase: "connected", rtt: 84 }); expect(body().querySelector(".netplay-pill")!.textContent).toBe("Response time 84 ms · Playable"); expect(text()).toContain("Your button presses will take a moment longer to show up. Fine for most games, harder for fast ones."); }); it("turns red, still connected, when it becomes too slow, and back when it recovers", () => { hostStep1(); - session.emit({ step: 3, phase: "connected", rtt: 18 }); + session.emit({ step: 2, phase: "connected", rtt: 18 }); session.emit({ rtt: 190 }); expect(body().querySelector(".netplay-pill")!.textContent).toBe("Response time 190 ms · Too slow"); expect(text()).toContain( @@ -296,7 +335,7 @@ describe("hosting", () => { it("refuses a connection too slow at connect time", () => { hostStep1(); - session.emit({ step: 2, phase: "failed", failure: "too-slow", rtt: 210 }); + session.emit({ phase: "failed", failure: "too-slow", rtt: 210 }); expect(text()).toContain("Too slow to play"); expect(body().querySelector(".netplay-pill")!.textContent).toBe("Response time 210 ms · Too slow"); expect(text()).toContain( @@ -309,14 +348,14 @@ describe("hosting", () => { it("says when the other player leaves", () => { hostStep1(); - session.emit({ step: 3, phase: "failed", failure: "peer-left" }); + session.emit({ step: 2, phase: "failed", failure: "peer-left" }); expect(text()).toContain("The other player disconnected."); expect(buttonNames()).toEqual(["Close", "Start again"]); }); - it("disconnects from step 3", () => { + it("disconnects from step 2", () => { hostStep1(); - session.emit({ step: 3, phase: "connected", rtt: 18 }); + session.emit({ step: 2, phase: "connected", rtt: 18 }); button("Disconnect")!.click(); expect(session.disconnect).toHaveBeenCalled(); expect(title()).toBe("Play together"); @@ -325,56 +364,164 @@ describe("hosting", () => { describe("joining", () => { function joinStep1() { + game = null; open(); body().querySelector("[data-choice=join]")!.click(); } + function field() { + return body().querySelector("input.netplay-room-field")!; + } + function type(value: string) { + field().value = value; + field().dispatchEvent(new Event("input")); + } + function enter() { + field().dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + } - it("asks for the host's code at step 1", () => { + it("asks for the host's code in one field, with the field in focus", () => { joinStep1(); expect(title()).toBe("Join a game"); - expect([...body().querySelectorAll(".netplay-steps span")].map((s) => s.textContent)).toEqual([ - "1. Paste the host's code", - "2. Send your code", - "3. Connected", + expect([...body().querySelectorAll(".netplay-steps span")].map((s) => [s.textContent, s.className])).toEqual([ + ["1. Type the host's code", "on"], + ["2. Connected", ""], ]); - expect(text()).toContain("Paste the code the host sent you."); - const box = body().querySelector("textarea")!; - expect(box.placeholder).toBe("Paste the host's code here"); - expect(document.activeElement).toBe(box); - expect(buttonNames()).toEqual(["Cancel", "Next"]); - expect(button("Next")!.disabled).toBe(true); + expect(text()).toContain("Type the code the host tells you."); + expect(field().placeholder).toBe("ABC-DEF"); + expect(field().getAttribute("autocapitalize")).toBe("characters"); + expect(text()).toContain("Codes never use I, O, 0 or 1."); + expect(document.activeElement).toBe(field()); + expect(buttonNames()).toEqual(["Cancel", "Join"]); + expect(button("Join")!.disabled).toBe(true); }); - it("says what is wrong with a pasted code, in the joiner's words", () => { + it("shows capitals, puts in the dash, and leaves out what a code never contains", () => { joinStep1(); - session.emit({ pasteProblem: "not-neser" }); - expect(text()).toContain("That isn't a NESER code. Copy the whole code from the host's window and paste it again."); - session.emit({ pasteProblem: "wrong-kind" }); - expect(text()).toContain("This is a reply code, not a host's code. Ask the host for the code from their window."); + type("k7"); + expect(field().value).toBe("K7"); + type("k7qi"); + expect(field().value).toBe("K7Q"); + type("K7Q0m"); + expect(field().value).toBe("K7Q-M"); + type("K7Q-M1!4 x9"); + expect(field().value).toBe("K7Q-M4X"); }); - it("names both builds when the host's NESER differs", () => { + it("takes a pasted code with or without its dash, spaces or lower case", () => { + joinStep1(); + type(" k7q m4x "); + expect(field().value).toBe("K7Q-M4X"); + type("k7qm4x"); + expect(field().value).toBe("K7Q-M4X"); + }); + + it("enables Join once six characters are in, and Enter presses it", () => { + joinStep1(); + type("K7QM4"); + expect(button("Join")!.disabled).toBe(true); + enter(); + expect(session.joinRoom).not.toHaveBeenCalled(); + type("K7QM4X"); + expect(button("Join")!.disabled).toBe(false); + enter(); + expect(session.joinRoom).toHaveBeenCalledWith("K7Q-M4X"); + button("Join")!.click(); + expect(session.joinRoom).toHaveBeenCalledTimes(2); + }); + + it("shows Connecting beneath the code, with only Cancel", () => { joinStep1(); + type("K7QM4X"); + session.emit({ phase: "connecting" }); + expect(field().value).toBe("K7Q-M4X"); + expect(field().readOnly).toBe(true); + expect(text()).toContain("⟳ Connecting…"); + expect(buttonNames()).toEqual(["Cancel"]); + session.emit({ phase: "waiting" }); + expect(text()).toContain("⟳ Connecting…"); + expect(buttonNames()).toEqual(["Cancel"]); + }); + + it.each([ + ["room-not-found", "There's no game with that code. Check the code with the host and try again."], + ["room-full", "That game already has two players. Ask the host to start a new game and tell you the new code."], + [ + "service", + "Network play can't start right now. NESER couldn't reach its meeting service on the internet. Check that this computer is online, then try again.", + ], + ["busy", "Too many games are being started right now. Wait a minute, then try again."], + ["host-left", "The host closed this game before you were connected. Ask them to start a new one and tell you the new code."], + ] as const)("says %s beneath the field, keeping the code to fix and Join again", (problem, words) => { + joinStep1(); + type("K7QM4Y"); + session.emit({ phase: "connecting" }); + session.emit({ phase: "idle", joinProblem: problem }); + expect(alerts()).toEqual([["err", words]]); + expect(field().value).toBe("K7Q-M4Y"); + expect(field().readOnly).toBe(false); + expect(document.activeElement).toBe(field()); + expect(buttonNames()).toEqual(["Cancel", "Join"]); + expect(button("Join")!.disabled).toBe(false); + type("K7QM4X"); + button("Join")!.click(); + expect(session.joinRoom).toHaveBeenLastCalledWith("K7Q-M4X"); + }); + + it("names both builds when the host's NESER differs, offering only Close", () => { + joinStep1(); + type("K7QM4X"); session.emit({ phase: "failed", failure: "other-build", theirs: { version: "1.3.0", commit: "b81e07d" } }); - expect(text()).toContain("You have NESER 1.3.0 (4f2a9c1), the host has 1.3.0 (b81e07d)."); + expect(alerts()).toEqual([ + [ + "err", + "Different versions of NESER. You have NESER 1.3.0 (4f2a9c1), the host has 1.3.0 (b81e07d). You both need the same version to play together — reload the page on both computers.", + ], + ]); + expect(field().value).toBe("K7Q-M4X"); expect(buttonNames()).toEqual(["Close"]); }); - it("sends its code back at step 2 while waiting for the host", () => { + it("stops after too many wrong codes, and Start again keeps the code to edit", () => { joinStep1(); - session.emit({ step: 2, phase: "waiting", ownCode: "NSR1-reply" }); - expect(text()).toContain("Send this code back to the host."); - expect(body().querySelector(".netplay-code")!.textContent).toBe("NSR1-reply"); - expect(text()).toContain("⟳ Waiting for the host to paste your code…"); - expect(buttonNames()).toEqual(["Cancel", "Copy code"]); + type("K7QM4Y"); + session.emit({ phase: "failed", failure: "too-many-attempts" }); + expect(alerts()).toEqual([["err", "Too many wrong codes. Check the code with the host, then press Start again."]]); + expect(buttonNames()).toEqual(["Close", "Start again"]); + button("Start again")!.click(); + expect(session.startAgain).toHaveBeenCalled(); + session.emit({ phase: "idle", failure: null }); + expect(field().value).toBe("K7Q-M4Y"); + expect(field().readOnly).toBe(false); + expect(buttonNames()).toEqual(["Cancel", "Join"]); + }); + + it("says when the host could not be reached, offering to start again", () => { + joinStep1(); + type("K7QM4X"); + session.emit({ phase: "failed", failure: "unreachable" }); + expect(alerts()).toEqual([ + [ + "err", + "Couldn't reach the other player. Network play needs both computers on the same network (or the same VPN). Check that, then start again.", + ], + ]); + expect(buttonNames()).toEqual(["Close", "Start again"]); + }); + + it("starts with an empty field after the window is closed and opened again", () => { + joinStep1(); + type("K7QM4X"); + q("#netplay-close")!.click(); + open(); + body().querySelector("[data-choice=join]")!.click(); + expect(field().value).toBe(""); }); it("is connected to the host, waiting for the host to start the game", () => { joinStep1(); - session.emit({ step: 3, phase: "connected", rtt: 18 }); + session.emit({ step: 2, phase: "connected", rtt: 18 }); expect(text()).toContain("Connected to the host."); expect(text()).toContain("⟳ Waiting for the host to start the game…"); - expect(text()).not.toContain("The host will send the game next."); expect(buttonNames()).toEqual(["Disconnect"]); }); }); @@ -399,7 +546,7 @@ describe("closing the window", () => { it("once connected keeps the connection, and the top-bar button shows it", () => { open(); - session.emit({ role: "host", step: 3, phase: "connected", rtt: 18 }); + session.emit({ role: "host", step: 2, phase: "connected", rtt: 18 }); q("#netplay-close")!.click(); expect(session.cancel).not.toHaveBeenCalled(); expect(chip().querySelector(".netplay-chip-long")!.textContent).toBe("Connected · "); @@ -413,7 +560,7 @@ describe("closing the window", () => { it("shows Disconnected on the button when the other player leaves, until it is pressed", () => { open(); - session.emit({ role: "host", step: 3, phase: "connected", rtt: 18 }); + session.emit({ role: "host", step: 2, phase: "connected", rtt: 18 }); q("#netplay-close")!.click(); session.emit({ phase: "failed", failure: "peer-left" }); expect(chip().textContent).toBe("Disconnected"); @@ -432,31 +579,6 @@ describe("closing the window", () => { }); }); -describe("the pasted code", () => { - it("stays in view while connecting", () => { - open(); - body().querySelector("[data-choice=host]")!.click(); - session.emit({ step: 2 }); - const box = body().querySelector("textarea")!; - box.value = "NSR1-reply"; - box.dispatchEvent(new Event("input")); - session.emit({ phase: "connecting" }); - expect(body().querySelector("textarea")!.value).toBe("NSR1-reply"); - }); -}); - -describe("a failed step", () => { - it.each([ - ["host", { step: 2 as const, phase: "failed" as const, failure: "unreachable" as const }], - ["join", { step: 1 as const, phase: "failed" as const, failure: "other-build" as const, theirs: BUILD }], - ])("keeps the %s's pasted code but no longer takes another", (choice, failed) => { - open(); - body().querySelector(`[data-choice=${choice}]`)!.click(); - session.emit(failed); - expect(body().querySelector("textarea")!.readOnly).toBe(true); - }); -}); - describe("copying without the clipboard API", () => { it("says Copied only when the browser copied", async () => { Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); @@ -475,30 +597,19 @@ describe("copying without the clipboard API", () => { }); }); -describe("a host whose connection cannot start", () => { - it("says it could not reach the other player, offering to start again", () => { - open(); - body().querySelector("[data-choice=host]")!.click(); - session.emit({ ownCode: null, phase: "failed", failure: "unreachable" }); - expect(text()).toContain("Couldn't reach the other player."); - expect(buttonNames()).toEqual(["Close", "Start again"]); - }); -}); - describe("sending the game (nr-ayk5.4)", () => { function hostConnected() { open(); body().querySelector("[data-choice=host]")!.click(); - session.emit({ step: 3, phase: "connected", rtt: 18 }); + session.emit({ step: 2, phase: "connected", rtt: 18 }); } function joinConnected() { open(); body().querySelector("[data-choice=join]")!.click(); - session.emit({ step: 3, phase: "connected", rtt: 18 }); + session.emit({ step: 2, phase: "connected", rtt: 18 }); } const sending = (direction: "send" | "receive", done: number, total: number) => session.emit({ sending: { direction, name: "Super Mario Bros. 3", done, total } }); - const alerts = () => [...body().querySelectorAll(".netplay-alert")].map((a) => [a.dataset.tone, a.textContent]); it("offers the host Start game, with focus, under the hint", () => { hostConnected(); @@ -637,7 +748,7 @@ describe("sending the game (nr-ayk5.4)", () => { describe("playing together (nr-ayk5.5)", () => { function playingTogether(role: "host" | "join") { playing = true; - session.emit({ role, step: 3, phase: "connected", rtt: 18 }); + session.emit({ role, step: 2, phase: "connected", rtt: 18 }); } it.each(["host", "join"] as const)("offers the %s only Disconnect while the game is played, which asks first", (role) => { diff --git a/web/src/ui/play_together.ts b/web/src/ui/play_together.ts index 3e914b80d..822f7a35b 100644 --- a/web/src/ui/play_together.ts +++ b/web/src/ui/play_together.ts @@ -2,7 +2,8 @@ import type { ConsoleKind } from "../console/consoles"; import { buildLabel, type BuildId } from "../netplay/build_id"; import { responseTimeRating } from "../netplay/response_time"; import { NETPLAY_KINDS, type GamePackage } from "../netplay/game_package"; -import type { NetplaySession, NetplayView, Role } from "../netplay/session"; +import { ROOM_CODE_LENGTH, showRoomCode, tidyRoomCode } from "../netplay/room_code"; +import type { JoinProblem, NetplaySession, NetplayView, Role } from "../netplay/session"; /** * The Play together window and its top-bar button (nr-ayk5.3; the agreed design is @@ -13,6 +14,10 @@ import type { NetplaySession, NetplayView, Role } from "../netplay/session"; * does not stop it, and the chip shows how far it is. While the game is played together * (nr-ayk5.5) the connected step offers only Disconnect, which the page asks about first, and the * chip says "Waiting…" while the other player's game holds this one. + * + * The two meet by one short room code (nr-z4nw, docs/ui/nr-z4nw-room-code.html): the host's step + * shows it, with Copy code and Copy invite message; the joiner's step is one field that takes only + * code characters, puts the dash in itself and keeps what was typed through every error. */ export type CurrentGame = { kind: ConsoleKind; title: string }; @@ -21,10 +26,20 @@ const HOSTABLE: readonly ConsoleKind[] = NETPLAY_KINDS; const COPIED_FOR_MS = 1500; const STEPS: Record = { - host: ["1. Send your code", "2. Paste their code", "3. Connected"], - join: ["1. Paste the host's code", "2. Send your code", "3. Connected"], + host: ["1. Share the code", "2. Connected"], + join: ["1. Type the host's code", "2. Connected"], }; +/** Where the invite sends the other player. */ +const NESER_ADDRESS = "https://neser.rmstdope.eu"; + +export function inviteMessage(gameTitle: string, room: string): string { + return ( + `Play ${gameTitle} with me in NESER! Open ${NESER_ADDRESS}, press Play together, ` + + `then Join a game, and type the code ${room}` + ); +} + const OTHER: Record = { host: "the other player", join: "the host" }; const kb = (bytes: number) => Math.floor(bytes / 1024); @@ -55,18 +70,21 @@ function alert(tone: "warn" | "err", lead: string, rest = ""): HTMLElement { return box; } -function pasteAlert(role: Role, problem: NetplayView["pasteProblem"]): HTMLElement | null { - if (problem === "not-neser") { - const from = role === "join" ? "the host's window" : "the other player's window"; - return alert("err", "That isn't a NESER code.", `Copy the whole code from ${from} and paste it again.`); - } - if (problem === "wrong-kind") { - return role === "join" - ? alert("err", "This is a reply code, not a host's code.", "Ask the host for the code from their window.") - : alert("err", "This is a host's code, not the other player's reply.", "Ask the other player for the code from their window."); - } - return null; -} +const JOIN_PROBLEMS: Record = { + "room-not-found": ["There's no game with that code.", "Check the code with the host and try again."], + "room-full": ["That game already has two players.", "Ask the host to start a new game and tell you the new code."], + service: [ + "Network play can't start right now.", + "NESER couldn't reach its meeting service on the internet. Check that this computer is online, then try again.", + ], + busy: ["Too many games are being started right now.", "Wait a minute, then try again."], + "host-left": ["The host closed this game before you were connected.", "Ask them to start a new one and tell you the new code."], +}; + +const ROOM_NOTES = { + expired: "The old code ran out, so here is a new one.", + left: "The other player left before you were connected. Here is a new code.", +} as const; function pill(rtt: number): HTMLElement { const rating = responseTimeRating(rtt); @@ -124,8 +142,8 @@ export function createPlayTogether({ /** Which screen is drawn; a change of it redraws the window, anything else updates it in place. */ let screenKey = ""; let update: (view: NetplayView) => void = () => {}; - /** The text last pasted, shown again while it is being connected or after it failed. */ - let lastPaste = ""; + /** The code characters in the joiner's field, kept through every error and Start again. */ + let typedRoom = ""; const buttonLabel = openButton.textContent ?? "Play together"; /** This player ended playing together: the chip says Disconnected until it is pressed. */ let endedHere = false; @@ -236,128 +254,123 @@ export function createPlayTogether({ "Join a game", "Connect to a game someone else is hosting. You are player 2 — you don't need the game file.", true, - () => session.join() + () => { + typedRoom = ""; + session.join(); + } ); body.append(el("p", "netplay-lede", "Two browsers on the same network, one game, two pads."), host, join); (hostable ? host : join).focus(); } - /** A code to copy, with Copy code turning to "✓ Copied" for a moment. */ - function codeBox(view: NetplayView) { - const box = el("div", "netplay-code"); - box.tabIndex = 0; - box.setAttribute("aria-label", "Your code"); - let copiedTimer: ReturnType | undefined; - const copy = btn("Copy code", async () => { - const code = session.view().ownCode; - if (!code) return; - if (!(await copyText(code))) return; - copy.textContent = "✓ Copied"; - clearTimeout(copiedTimer); - copiedTimer = setTimeout(() => (copy.textContent = "Copy code"), COPIED_FOR_MS); + /** A copy button that says `done` for a moment once `text()` is on the clipboard. */ + function copyButton(label: string, done: string, text: () => string | null, onCopied: (copied: string) => void = () => {}) { + let timer: ReturnType | undefined; + const button = btn(label, async () => { + const copied = text(); + if (!copied || !(await copyText(copied))) return; + button.textContent = done; + onCopied(copied); + clearTimeout(timer); + timer = setTimeout(() => (button.textContent = label), COPIED_FOR_MS); }); - const fill = (v: NetplayView) => { - box.textContent = v.ownCode ?? "…"; - copy.disabled = v.ownCode === null; - }; - fill(view); - return { box, copy, fill }; + return button; } - function pasteBox(role: Role, placeholder: string, view: NetplayView) { - const box = el("textarea", "netplay-paste"); - box.value = view.phase === "idle" ? "" : lastPaste; - // Once connecting or failed, the code shown is the one that was tried; Start again takes a new one. - box.readOnly = view.phase !== "idle"; - box.placeholder = placeholder; - box.rows = 4; - box.spellcheck = false; - box.setAttribute("aria-label", placeholder); - box.addEventListener("input", () => { - lastPaste = box.value; - void session.paste(box.value); - }); - const problem = el("div"); - const fill = (v: NetplayView) => problem.replaceChildren(...[pasteAlert(role, v.pasteProblem)].filter((n) => n !== null)); - return { box, problem, fill }; + /** The first button of the step's row, for a step whose only way on is a button. */ + function focusFirstAction() { + body.querySelector(".netplay-actions button")?.focus(); } function drawHost(view: NetplayView) { - if (view.step === 1) { - const code = codeBox(view); - body.append(steps("host", 1), el("p", "", "Send this code to the other player — by chat, email, anything."), code.box); - if (view.phase === "failed") { - // The browser could not make a connection to offer at all. - body.append(failureAlert(view), actions(btn("Close", close), btn("Start again", () => session.startAgain(), true))); - body.querySelector(".netplay-actions button")?.focus(); - return; - } - const next = btn("Next", () => session.next(), true); - body.append(actions(btn("Cancel", close), code.copy, next)); - update = (v) => { - code.fill(v); - next.disabled = v.ownCode === null; - }; - update(view); - code.copy.focus(); - return; - } if (view.phase === "failed" && view.failure === "too-slow") return drawRefused(view); - const paste = pasteBox("host", "Paste their code here", view); - body.append(steps("host", 2), el("p", "", "Paste the code the other player sends back."), paste.box, paste.problem); + body.append(steps("host", 1)); + if (view.phase === "failed") { + // Liaison out of reach or busy is tried again; anything else starts again. + const again = view.failure === "service" || view.failure === "busy" ? "Try again" : "Start again"; + body.append(failureAlert(view), actions(btn("Close", close), btn(again, () => session.startAgain(), true))); + return focusFirstAction(); + } + const room = el("div", "netplay-room", view.room ?? "…"); + room.setAttribute("aria-label", "Room code"); + body.append(el("p", "", "Tell the other player this code:"), room); + if (view.roomNote) body.append(alert("warn", "", ROOM_NOTES[view.roomNote])); if (view.phase === "connecting") { - body.append(el("p", "netplay-muted", "⟳ Connecting…"), actions(btn("Cancel", close))); - return; + body.append(el("p", "netplay-muted", "⟳ The other player is here — connecting…"), actions(btn("Cancel", close))); + return focusFirstAction(); } + body.append(el("p", "netplay-muted", "⟳ Waiting for the other player to type it…")); + const shown = () => session.view().room; + const copiedLine = el("p", "netplay-muted netplay-small netplay-copied"); + const invite = copyButton( + "Copy invite message", + "✓ Invite copied", + () => { + const code = shown(); + return code ? inviteMessage(currentGame()?.title ?? "a game", code) : null; + }, + (message) => { + copiedLine.textContent = `Copied: “${message}”`; + if (!copiedLine.isConnected) body.append(copiedLine); + } + ); + const copy = copyButton("Copy code", "✓ Copied", shown); + invite.disabled = copy.disabled = view.room === null; + body.append(actions(btn("Cancel", close), invite, copy)); + if (view.room) copy.focus(); + else focusFirstAction(); + } + + function drawJoin(view: NetplayView) { + if (view.phase === "failed" && view.failure === "too-slow") return drawRefused(view); + const field = el("input", "netplay-room-field"); + field.type = "text"; + field.placeholder = "ABC-DEF"; + field.setAttribute("aria-label", "Room code"); + field.setAttribute("autocapitalize", "characters"); + field.autocomplete = "off"; + field.spellcheck = false; + field.maxLength = ROOM_CODE_LENGTH + 1; + field.value = showRoomCode(typedRoom); + // While a code is tried, and after a failure, the field shows the code that was tried. + field.readOnly = view.phase !== "idle"; + body.append( + steps("join", 1), + el("p", "", "Type the code the host tells you."), + field, + el("p", "netplay-muted netplay-small", "Codes never use I, O, 0 or 1.") + ); if (view.phase === "failed") { - body.append(failureAlert(view)); body.append( + failureAlert(view), view.failure === "other-build" ? actions(btn("Close", close)) : actions(btn("Close", close), btn("Start again", () => session.startAgain(), true)) ); - return; - } - const connect = btn("Connect", () => session.connect(), true); - body.append(actions(btn("Back", () => session.back()), connect)); - update = (v) => { - paste.fill(v); - connect.disabled = !v.pasteReady; - }; - update(view); - paste.box.focus(); - } - - function drawJoin(view: NetplayView) { - if (view.phase === "failed" && view.failure === "too-slow") return drawRefused(view); - if (view.step === 1) { - const paste = pasteBox("join", "Paste the host's code here", view); - body.append(steps("join", 1), el("p", "", "Paste the code the host sent you."), paste.box, paste.problem); - if (view.phase === "failed") { - body.append(failureAlert(view), actions(btn("Close", close))); - return; - } - const own = currentGame(); - if (own) body.append(alert("warn", "", `${own.title} will be closed when the host's game arrives. Your saves are kept.`)); - const next = btn("Next", () => session.next(), true); - body.append(actions(btn("Cancel", close), next)); - update = (v) => { - paste.fill(v); - next.disabled = !v.pasteReady; - }; - update(view); - paste.box.focus(); - return; + return focusFirstAction(); } - const code = codeBox(view); - body.append(steps("join", 2), el("p", "", "Send this code back to the host."), code.box); - if (view.phase === "failed") { - body.append(failureAlert(view), actions(btn("Close", close), btn("Start again", () => session.startAgain(), true))); - return; + if (view.phase !== "idle") { + body.append(el("p", "netplay-muted", "⟳ Connecting…"), actions(btn("Cancel", close))); + return focusFirstAction(); } - body.append(el("p", "netplay-muted", "⟳ Waiting for the host to paste your code…"), actions(btn("Cancel", close), code.copy)); - update = code.fill; - code.copy.focus(); + if (view.joinProblem) body.append(alert("err", ...JOIN_PROBLEMS[view.joinProblem])); + const own = currentGame(); + if (own) body.append(alert("warn", "", `${own.title} will be closed when the host's game arrives. Your saves are kept.`)); + const join = btn("Join", () => session.joinRoom(showRoomCode(typedRoom)), true); + const ready = () => (join.disabled = typedRoom.length !== ROOM_CODE_LENGTH); + field.addEventListener("input", () => { + typedRoom = tidyRoomCode(field.value); + field.value = showRoomCode(typedRoom); + ready(); + }); + field.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || join.disabled) return; + event.preventDefault(); + join.click(); + }); + ready(); + body.append(actions(btn("Cancel", close), join)); + field.focus(); } function failureAlert(view: NetplayView): HTMLElement { @@ -371,6 +384,11 @@ export function createPlayTogether({ ); } if (view.failure === "peer-left") return alert("err", "The other player disconnected."); + if (view.failure === "service") return alert("err", ...JOIN_PROBLEMS.service); + if (view.failure === "busy") return alert("err", ...JOIN_PROBLEMS.busy); + if (view.failure === "too-many-attempts") { + return alert("err", "Too many wrong codes.", "Check the code with the host, then press Start again."); + } return alert( "err", "Couldn't reach the other player.", @@ -396,7 +414,7 @@ export function createPlayTogether({ function drawConnected(view: NetplayView) { const role = view.role!; - body.append(steps(role, 3)); + body.append(steps(role, 2)); if (view.phase === "failed") { body.append(failureAlert(view), actions(btn("Close", close), btn("Start again", () => session.startAgain(), true))); body.querySelector(".netplay-actions button")?.focus(); @@ -512,14 +530,24 @@ export function createPlayTogether({ } function draw(view: NetplayView) { - const key = `${view.role}|${view.step}|${view.phase}|${view.failure}|${view.sending?.direction ?? ""}|${view.notice}`; + const key = [ + view.role, + view.step, + view.phase, + view.failure, + view.sending?.direction ?? "", + view.notice, + view.room, + view.roomNote, + view.joinProblem, + ].join("|"); if (key === screenKey) return update(view); screenKey = key; update = () => {}; body.replaceChildren(); title.textContent = view.role === "host" ? "Host this game" : view.role === "join" ? "Join a game" : "Play together"; if (view.role === null) drawChoice(); - else if (view.step === 3) drawConnected(view); + else if (view.step === 2) drawConnected(view); else if (view.role === "host") drawHost(view); else drawJoin(view); } From b1c3d0017962974952e127e47e65a4ead0056998 Mon Sep 17 00:00:00 2001 From: Henrik Kurelid Date: Fri, 30 Oct 2026 16:06:02 +0100 Subject: [PATCH 4/4] fix(nr-z4nw): a pasted room code is taken whole, and fixing a typo keeps the caret in place Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WCDKexiF6SkDodmyKWmppK --- README.md | 4 ++- .../specs/play-together.integration.spec.ts | 5 +++ web/src/ui/play_together.test.ts | 31 +++++++++++++++++++ web/src/ui/play_together.ts | 6 +++- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9b2a47abe..4e19bffa7 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,9 @@ asks for each chip's file once. See [README-SNES.md](README-SNES.md). the host gets a short room code (like `K7Q-M4X`) to read out or copy, and the other player types it. The two browsers meet through NESER's meeting service, liaison (`wss://liaison.rmstdope.eu/ws`), which only passes the connection setup between them and never sees the game; they then connect directly, -so both computers must be on the same network (or VPN). The two must run the same NESER build, and +so both computers must be on the same network (or VPN). liaison accepts pages served from +https://neser.rmstdope.eu and from `localhost`/`127.0.0.1` only, so a self-hosted copy reached by another +address (a LAN IP, say) cannot start a network game. The two must run the same NESER build, and a connection slower than 120 ms round trip is refused. Once connected the host presses **Start game**: the game (with any SNES firmware it needs) and the host's current moment are sent to the other player, who needs no copy of the game and does not keep it, and both start from that moment after a diff --git a/web/integration/specs/play-together.integration.spec.ts b/web/integration/specs/play-together.integration.spec.ts index ab745a314..be78f2c71 100644 --- a/web/integration/specs/play-together.integration.spec.ts +++ b/web/integration/specs/play-together.integration.spec.ts @@ -489,6 +489,11 @@ test.describe("Play together", () => { await expect(panel(page)).toContainText("There's no game with that code. Check the code with the host and try again."); await expect(field).toHaveValue("ABC-DEF"); await expect(field).toBeFocused(); + // A code pasted from a chat, with spaces round the dash, is taken whole. + await field.selectText(); + await page.keyboard.insertText(" k7q - m4x "); + await expect(field).toHaveValue("K7Q-M4X"); + await expect(panel(page).getByRole("button", { name: "Join", exact: true })).toBeEnabled(); await page.keyboard.press("Escape"); await expect(panel(page)).toBeHidden(); await expect(page.locator("#netplay-open")).toBeFocused(); diff --git a/web/src/ui/play_together.test.ts b/web/src/ui/play_together.test.ts index f311a6d71..3155d56b9 100644 --- a/web/src/ui/play_together.test.ts +++ b/web/src/ui/play_together.test.ts @@ -415,6 +415,37 @@ describe("joining", () => { expect(field().value).toBe("K7Q-M4X"); }); + it("takes a pasted code whatever its length before tidying, even into a full field", () => { + joinStep1(); + expect(field().hasAttribute("maxlength")).toBe(false); + type("K7QM4X"); + type("K7Q-M4X k7q - m4y "); + expect(field().value).toBe("K7Q-M4X"); + }); + + it("keeps the caret where a typo is being fixed", () => { + joinStep1(); + type("K7QM4X"); + // The 7 is deleted: the dash moves, and the caret stays after the K. + field().value = "KQ-M4X"; + field().setSelectionRange(1, 1); + field().dispatchEvent(new Event("input")); + expect(field().value).toBe("KQM-4X"); + expect(field().selectionStart).toBe(1); + // A 7 typed there lands where the old one was. + field().value = "K7QM-4X"; + field().setSelectionRange(2, 2); + field().dispatchEvent(new Event("input")); + expect(field().value).toBe("K7Q-M4X"); + expect(field().selectionStart).toBe(2); + // Past the dash, the caret keeps its place after the code character before it. + field().value = "K7Q-MZ4X"; + field().setSelectionRange(6, 6); + field().dispatchEvent(new Event("input")); + expect(field().value).toBe("K7Q-MZ4"); + expect(field().selectionStart).toBe(6); + }); + it("enables Join once six characters are in, and Enter presses it", () => { joinStep1(); type("K7QM4"); diff --git a/web/src/ui/play_together.ts b/web/src/ui/play_together.ts index 822f7a35b..e186a0092 100644 --- a/web/src/ui/play_together.ts +++ b/web/src/ui/play_together.ts @@ -330,7 +330,7 @@ export function createPlayTogether({ field.setAttribute("autocapitalize", "characters"); field.autocomplete = "off"; field.spellcheck = false; - field.maxLength = ROOM_CODE_LENGTH + 1; + // No maxlength: the browser would cut a paste short before it is tidied (" k7q - m4x "). field.value = showRoomCode(typedRoom); // While a code is tried, and after a failure, the field shows the code that was tried. field.readOnly = view.phase !== "idle"; @@ -359,8 +359,12 @@ export function createPlayTogether({ const join = btn("Join", () => session.joinRoom(showRoomCode(typedRoom)), true); const ready = () => (join.disabled = typedRoom.length !== ROOM_CODE_LENGTH); field.addEventListener("input", () => { + // The caret stays after the same code character, so a typo is fixed where it is. + const before = tidyRoomCode(field.value.slice(0, field.selectionStart ?? field.value.length)).length; typedRoom = tidyRoomCode(field.value); field.value = showRoomCode(typedRoom); + const at = showRoomCode(typedRoom.slice(0, before)).length; + field.setSelectionRange(at, at); ready(); }); field.addEventListener("keydown", (event) => {