diff --git a/README.md b/README.md index 4e19bffa7..40c662c6d 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,11 @@ 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). liaison accepts pages served from +each browser first asking outpost, NESER's own STUN server (`stun:outpost.rmstdope.eu:3478`), for its +public address, so players in different homes can play together and each can see the other's internet +address. Networks that pick a new public port for every destination (common on mobile, hotspot and +work networks) cannot be connected this way; after about 10 seconds both players are told so. With +outpost down, only computers on the same network (or VPN) connect. 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**: diff --git a/architecture.md b/architecture.md index 8d438abbd..72c312e6a 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), `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/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; each browser asks outpost, NESER's own STUN server `stun:outpost.rmstdope.eu:3478`, for its public address (nr-ri8n), gathering for at most 3 s, and no TURN relay is used, 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 10 s connect limit from the joiner being in the room (nr-ri8n) 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/integration/specs/play-together.integration.spec.ts b/web/integration/specs/play-together.integration.spec.ts index be78f2c71..6de7deb96 100644 --- a/web/integration/specs/play-together.integration.spec.ts +++ b/web/integration/specs/play-together.integration.spec.ts @@ -9,10 +9,11 @@ import { waitForRunningState } from "../helpers/lifecycle.helpers"; -// Two browser contexts on one runner connect over WebRTC with no STUN server, as two browsers on -// one network do. Chromium hides its local addresses behind multicast-DNS names by default; the -// runner may not resolve those, so this spec has the browser offer its addresses as they are. -// What is under test is the page's connecting, not the operating system's mDNS. +// Two browser contexts on one runner connect over WebRTC by their own addresses, as two browsers on +// one network do (the STUN address from outpost, nr-ri8n, is offered too, but not needed here). +// Chromium hides its local addresses behind multicast-DNS names by default; the runner may not +// resolve those, so this spec has the browser offer its addresses as they are. What is under test +// is the page's connecting, not the operating system's mDNS. test.use({ launchOptions: { ...config.use?.launchOptions, diff --git a/web/main.css b/web/main.css index fb3e04ad7..829002031 100644 --- a/web/main.css +++ b/web/main.css @@ -2537,6 +2537,12 @@ body.handheld:not(.handheld-portrait).touch-running .screen-wrap canvas { font-size: 12px; } +.netplay-body p.netplay-address { + margin: 8px 0 0; + font-size: 13px; + color: var(--steel-hi); +} + .netplay-pill { display: inline-flex; margin: 6px 0; diff --git a/web/src/netplay/peer_link.test.ts b/web/src/netplay/peer_link.test.ts index 566f5dc18..438f584d2 100644 --- a/web/src/netplay/peer_link.test.ts +++ b/web/src/netplay/peer_link.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { wrapChannel } from "./peer_link"; +import { webRtcLinks, wrapChannel } from "./peer_link"; /** Just enough of a data channel and its connection for the link wrapper. */ function fakeChannel() { @@ -37,3 +37,34 @@ describe("the link to the other browser", () => { expect(channel.send).toHaveBeenNthCalledWith(2, "hi"); }); }); + +describe("connecting across the internet", () => { + it("asks outpost, NESER's own STUN server, for each browser's public address", async () => { + const configs: RTCConfiguration[] = []; + class FakeConnection extends EventTarget { + iceGatheringState = "complete"; + localDescription: { sdp: string } | null = null; + constructor(config: RTCConfiguration) { + super(); + configs.push(config); + } + createDataChannel() { + return new EventTarget(); + } + async setLocalDescription() { + this.localDescription = { sdp: "local" }; + } + async setRemoteDescription() {} + close() {} + } + vi.stubGlobal("RTCPeerConnection", FakeConnection); + try { + (await webRtcLinks.startHost()).cancel(); + (await webRtcLinks.startJoin("offer")).cancel(); + } finally { + vi.unstubAllGlobals(); + } + const outpost = { iceServers: [{ urls: "stun:outpost.rmstdope.eu:3478" }] }; + expect(configs).toEqual([outpost, outpost]); + }); +}); diff --git a/web/src/netplay/peer_link.ts b/web/src/netplay/peer_link.ts index 28c4aa28b..ff3b960a0 100644 --- a/web/src/netplay/peer_link.ts +++ b/web/src/netplay/peer_link.ts @@ -1,11 +1,15 @@ /** * The direct connection between two browsers (nr-ayk5.3): one WebRTC data channel, set up from - * 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`. + * an offer and an answer the two browsers swap through liaison (nr-z4nw). Each browser asks + * outpost, NESER's own STUN server, for the public address its router gives it (nr-ri8n), so two + * players in different homes can reach each other directly; no TURN relay is used (liaison's is + * left for nr-1wbx), so a pair whose networks block a direct connection cannot connect. This is + * the only file that touches `RTCPeerConnection`; the session drives it through `PeerLinks`. */ +/** outpost answers STUN only; it never carries game data. Without it, same-network play still works. */ +export const ICE_SERVERS: RTCIceServer[] = [{ urls: "stun:outpost.rmstdope.eu:3478" }]; + /** * An open channel to the other browser, carrying text (the session's messages) and bytes (a game * being sent, nr-ayk5.4). `onClosed` is told once, however the channel ends. @@ -36,7 +40,10 @@ export interface PeerLinks { startJoin(hostSdp: string): Promise; } -/** The offer and answer each carry every address at once, so they 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; + * with outpost out of reach, the wait ends here and only the browser's own addresses are offered. + */ 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; @@ -105,7 +112,7 @@ function opened(pc: RTCPeerConnection, channel: Promise): Promis } async function startHost(): Promise { - const pc = new RTCPeerConnection({ iceServers: [] }); + const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); const channel = pc.createDataChannel("neser"); try { await pc.setLocalDescription(); @@ -125,7 +132,7 @@ async function startHost(): Promise { } async function startJoin(hostSdp: string): Promise { - const pc = new RTCPeerConnection({ iceServers: [] }); + const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); const channel = new Promise((resolve) => { pc.addEventListener("datachannel", (event) => resolve(event.channel), { once: true }); }); diff --git a/web/src/netplay/session.test.ts b/web/src/netplay/session.test.ts index 92dc768ea..90d32120a 100644 --- a/web/src/netplay/session.test.ts +++ b/web/src/netplay/session.test.ts @@ -242,12 +242,13 @@ describe("hosting", () => { expect(fake.hostCancel).not.toHaveBeenCalled(); }); - it("gives up 15 seconds after someone typed the code when the other player cannot be reached", async () => { + // About 10 seconds at most, on both sides (nr-ri8n): the limit is pinned here, not read back. + it("gives up 10 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(); socket.says({ type: "signal", data: { answer: "answer-sdp" } }); - await vi.advanceTimersByTimeAsync(CONNECT_LIMIT_MS - 100); + await vi.advanceTimersByTimeAsync(9_900); expect(session.view().phase).toBe("connecting"); await vi.advanceTimersByTimeAsync(200); expect(session.view()).toMatchObject({ phase: "failed", failure: "unreachable" }); @@ -382,9 +383,11 @@ describe("joining", () => { expect(link.closed).toBe(true); }); - it("gives up 15 seconds after getting into the room when the host cannot be reached", async () => { + it("gives up 10 seconds after getting into the room when the host cannot be reached", async () => { await joinToWaiting(); - await vi.advanceTimersByTimeAsync(CONNECT_LIMIT_MS + 100); + await vi.advanceTimersByTimeAsync(9_900); + expect(session.view().phase).not.toBe("failed"); + await vi.advanceTimersByTimeAsync(200); expect(session.view()).toMatchObject({ phase: "failed", failure: "unreachable" }); expect(fake.joinCancel).toHaveBeenCalled(); }); diff --git a/web/src/netplay/session.ts b/web/src/netplay/session.ts index 79bde3a52..c86652fed 100644 --- a/web/src/netplay/session.ts +++ b/web/src/netplay/session.ts @@ -185,8 +185,12 @@ export function inputDelayFor(rttMs: number): number { return Math.min(MAX_INPUT_DELAY, Math.max(MIN_INPUT_DELAY, frames)); } -/** 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; +/** + * How long the two have, from the joiner being in the room, for the connection and its check: + * "about 10 seconds at most" before both are told their networks don't allow it (nr-ri8n). It + * leaves room for both browsers' address gathering (3 s each at most) when outpost is out of reach. + */ +export const CONNECT_LIMIT_MS = 10_000; const CHECK_SAMPLES = 5; const CHECK_INTERVAL_MS = 200; const LIVE_INTERVAL_MS = 1000; diff --git a/web/src/ui/play_together.test.ts b/web/src/ui/play_together.test.ts index 3155d56b9..8abfcbe11 100644 --- a/web/src/ui/play_together.test.ts +++ b/web/src/ui/play_together.test.ts @@ -160,6 +160,31 @@ describe("the choice", () => { expect(host.textContent).toContain("Network play works with NES and Super Nintendo games."); }); + const ADDRESS_LINE = + "ⓘ Playing together connects your computer directly to the other player's, so each of you can see the other's internet address."; + + it.each([ + ["a NES game", { kind: "nes", title: "Super Mario Bros. 3" }], + ["no game", null], + ["a Game Boy game", { kind: "gb", title: "Tetris" }], + ] as const)("says beneath the cards that each player sees the other's internet address, with %s (nr-ri8n)", (_, current) => { + game = current; + open(); + const lines = body().querySelectorAll(".netplay-address"); + expect(lines).toHaveLength(1); + expect(lines[0].textContent).toBe(ADDRESS_LINE); + expect(lines[0].tagName).toBe("P"); + expect(lines[0].hasAttribute("tabindex")).toBe(false); + const join = body().querySelector("[data-choice=join]")!; + expect(join.compareDocumentPosition(lines[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it("shows the address line only in the first window", () => { + open(); + body().querySelector("[data-choice=join]")!.click(); + expect(body().querySelector(".netplay-address")).toBeNull(); + }); + it("lets a SNES game be hosted", () => { game = { kind: "snes", title: "Super Mario World" }; open(); @@ -287,13 +312,12 @@ describe("hosting", () => { expect(buttonNames()).toEqual(["Close", "Try again"]); }); - it("says when the other player could not be reached, offering to start again", () => { + it("says when the two computers could not be connected, offering to start again (nr-ri8n)", () => { hostStep1(); 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." - ); + expect(alerts()).toEqual([["err", "Couldn't connect your two computers. Your networks don't allow a direct connection between them. This often happens on mobile, hotspot or work networks — try again with both of you on a home network."]]); expect(buttonNames()).toEqual(["Close", "Start again"]); + expect(document.activeElement).toBe(button("Start again")); button("Start again")!.click(); expect(session.startAgain).toHaveBeenCalled(); }); @@ -326,7 +350,7 @@ describe("hosting", () => { session.emit({ rtt: 190 }); expect(body().querySelector(".netplay-pill")!.textContent).toBe("Response time 190 ms · Too slow"); expect(text()).toContain( - "The connection has become too slow to play. It may recover by itself — if not, try both being on the same network, or closer to the router." + "The connection has become too slow to play. It may recover by itself — if not, try a wired connection or being closer to the router." ); expect(buttonNames()).toEqual(["Disconnect", "Start game"]); session.emit({ rtt: 20 }); @@ -339,7 +363,7 @@ describe("hosting", () => { expect(text()).toContain("Too slow to play"); expect(body().querySelector(".netplay-pill")!.textContent).toBe("Response time 210 ms · Too slow"); expect(text()).toContain( - "The connection between the two browsers is too slow for the game to keep up. Try both being on the same network, or closer to the router." + "The connection between the two computers is too slow for the game to keep up. Try a wired connection or being closer to the router — or play on the same network." ); expect(buttonNames()).toEqual(["Close", "Try again"]); button("Try again")!.click(); @@ -526,17 +550,13 @@ describe("joining", () => { expect(buttonNames()).toEqual(["Cancel", "Join"]); }); - it("says when the host could not be reached, offering to start again", () => { + it("says when the two computers could not be connected, offering to start again (nr-ri8n)", () => { 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(alerts()).toEqual([["err", "Couldn't connect your two computers. Your networks don't allow a direct connection between them. This often happens on mobile, hotspot or work networks — try again with both of you on a home network."]]); expect(buttonNames()).toEqual(["Close", "Start again"]); + expect(document.activeElement).toBe(button("Start again")); }); it("starts with an empty field after the window is closed and opened again", () => { diff --git a/web/src/ui/play_together.ts b/web/src/ui/play_together.ts index e186a0092..ce1f2865a 100644 --- a/web/src/ui/play_together.ts +++ b/web/src/ui/play_together.ts @@ -40,6 +40,10 @@ export function inviteMessage(gameTitle: string, room: string): string { ); } +/** Always beneath the two cards (nr-ri8n, docs/ui/nr-ri8n-internet-play.html): plain information, nothing to accept. */ +const ADDRESS_LINE = + "ⓘ Playing together connects your computer directly to the other player's, so each of you can see the other's internet address."; + const OTHER: Record = { host: "the other player", join: "the host" }; const kb = (bytes: number) => Math.floor(bytes / 1024); @@ -259,7 +263,12 @@ export function createPlayTogether({ session.join(); } ); - body.append(el("p", "netplay-lede", "Two browsers on the same network, one game, two pads."), host, join); + body.append( + el("p", "netplay-lede", "Two browsers on the same network, one game, two pads."), + host, + join, + el("p", "netplay-address", ADDRESS_LINE) + ); (hostable ? host : join).focus(); } @@ -282,6 +291,12 @@ export function createPlayTogether({ body.querySelector(".netplay-actions button")?.focus(); } + /** Couldn't connect puts focus on Start again, as agreed for it (nr-ri8n); other failures on the first button. */ + function focusFailureAction(view: NetplayView) { + if (view.failure === "unreachable") body.querySelector(".netplay-actions .btn-primary")?.focus(); + else focusFirstAction(); + } + function drawHost(view: NetplayView) { if (view.phase === "failed" && view.failure === "too-slow") return drawRefused(view); body.append(steps("host", 1)); @@ -289,7 +304,7 @@ export function createPlayTogether({ // 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(); + return focusFailureAction(view); } const room = el("div", "netplay-room", view.room ?? "…"); room.setAttribute("aria-label", "Room code"); @@ -347,7 +362,7 @@ export function createPlayTogether({ ? actions(btn("Close", close)) : actions(btn("Close", close), btn("Start again", () => session.startAgain(), true)) ); - return focusFirstAction(); + return focusFailureAction(view); } if (view.phase !== "idle") { body.append(el("p", "netplay-muted", "⟳ Connecting…"), actions(btn("Cancel", close))); @@ -395,8 +410,8 @@ export function createPlayTogether({ } return alert( "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." + "Couldn't connect your two computers.", + "Your networks don't allow a direct connection between them. This often happens on mobile, hotspot or work networks — try again with both of you on a home network." ); } @@ -409,7 +424,7 @@ export function createPlayTogether({ alert( "err", "", - "The connection between the two browsers is too slow for the game to keep up. Try both being on the same network, or closer to the router." + "The connection between the two computers is too slow for the game to keep up. Try a wired connection or being closer to the router — or play on the same network." ), actions(btn("Close", close), btn("Try again", () => session.startAgain(), true)) ); @@ -441,7 +456,7 @@ export function createPlayTogether({ alert( "err", "", - "The connection has become too slow to play. It may recover by itself — if not, try both being on the same network, or closer to the router." + "The connection has become too slow to play. It may recover by itself — if not, try a wired connection or being closer to the router." ) ); }