From 425c18dcb66f6649f3d0679114a679124ff212b1 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sat, 15 Aug 2026 16:20:39 +0200 Subject: [PATCH] A room you can share by link, a code you can type, and a name to be called MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiplayer setup moves out of a canvas modal and a prompt() dialog into one menu screen: your name, the two ways to host, and the friend's code. The socket opens from the menu and the game stays parked behind the overlay until the room fills — netHooks.paired takes the court one line before netStart writes to it — so the room code can carry a real SHARE button and a wrong code is a typo you fix rather than a CONNECTION LOST card over a match that never began. The name is the world board's name (stats.saveName), crosses on the existing look message, and the scoreboard finally names the person instead of CPU. Co-Authored-By: Claude Opus 5 --- PROJECT.md | 44 +++++++++++++++++- index.html | 88 +++++++++++++++++++++++++++++++----- src/main.js | 120 ++++++++++++++++++++++++++++++++------------------ src/net.js | 49 ++++++++++++++------- src/render.js | 6 ++- src/stats.js | 10 ++++- test.js | 13 ++++++ 7 files changed, 255 insertions(+), 75 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 88cc577..8e89940 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -414,6 +414,46 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu `RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone. +## Session log: THE NETPLAY LOBBY — a link to send, a code to type, a name to be (2026-08-15) + +Oliver: *"multiplayer should be easier to setup — a share with link next to the code; also entering +the code is done in some plain js confirm dialog thingy, this could be embedded more nicely; +somewhere when hosting or also when joining the player should name themselves."* All three are one +screen (`#vs`, `index.html` + THE LOBBY in `main.js`); the protocol underneath is untouched. + +- **THE LOBBY IS A MENU SCREEN, and that is the whole structural change.** Hosting used to call + `start('classic')` first and show the room code in a CANVAS modal over an empty court — which + cannot carry a share button, cannot be typed into, and turned a mistyped code into a CONNECTION + LOST card over a match that never began. The socket is opened from the menu now and the game + stays parked behind the overlay until the room actually fills. **`netHooks.paired` is what takes + the court**, called at the top of `netStart` — one line before everything that writes to it, so + the kit/tour selection `start()` performs is already done when the `look` message reads it. + Two more hooks join `intoSplatter`: `room` (the code landed) and `lost` (returns true when the + lobby said so itself, which is what suppresses the card). Headless leaves them null. +- **SHARE THE LINK, then the code.** `navigator.share` is the phone's own sheet — the friend is in + WhatsApp, not in a room reading letters aloud — with `clipboard.writeText` as the desktop + fallback and the four letters still big on screen for when they are in the same room. The link is + the URL door that already existed (`#net-ABCD`): it opens the lobby with the code prefilled, + ONE tap from the court. Deliberately not an auto-join — that tap is where the joiner sees, and + can change, the name the other phone will call them. +- **ONE NAME PER PLAYER.** The field is prefilled from `stats.savedName()` and written back through + the new `stats.saveName` (which `submit` now shares), so the world board's name and the netplay + name cannot become two things. It crosses on the EXISTING `look` message as `n` — no new message + type, and an older build simply sends none. **The scoreboard names the person** (`render`, the + names block) in both netplay modes: FRIEND until the look lands, never CPU. A stranger's string + lands in an 8-character slot, so it is normalised at the RECEIVING end through the same + `tidyName` the board and `server.py`'s `clean()` agree on. +- **Failure is a typo now, not a card.** A short code never opens a socket; `gone` puts + NO SUCH ROOM — CHECK THE CODE over the picker with the field still there. CANCEL and the header + chevron both `endNetVs`, so a room you walked out of gives its socket back. +- Verified through the real relay in two panes: host → share link → join → both take the court, + host serves, the board reads YOU / GUEST, and the death-match room rebuilds both courts the same + way (blood pips, the friend as the living opponent, no roster body). 96 tests. +- Smaller: the JOIN button is the crimson plate stretched to the field's own height (`#vs .joinrow + .btn` — a bare `.joinrow .btn` loses to `#vs .btn`'s margin, and a margin on a stretched flex + item is exactly the 8px it was coming up short by); `.tin` is now the one typed-field look, worn + by the name prompt and both lobby fields. + ## Session log: gore opens in the gore, the theme comes back, three boards (2026-08-15, same day) Oliver's finetuning pass on the batch below. @@ -678,8 +718,8 @@ agree on the same point from opposite perspectives). 73 tests. serve and rally AI are gated off, `restartRun` closes the socket. - **Honest limits, all deliberate (ponytail)**: NO reconnect-and-resume — a dropped radio is a CONNECTION LOST card and a reload (the recorded plan calls reconnection "the bulk of the real - work"; a prototype must not start there). prompt() is the join UI. The scoreboard still says - CPU. A return snaps the ball to the mirrored strike (~latency of drift eaten in one hop; the + work"; a prototype must not start there). ~~prompt() is the join UI. The scoreboard still says + CPU.~~ *(both gone — see THE NETPLAY LOBBY above.)* A return snaps the ball to the mirrored strike (~latency of drift eaten in one hop; the flight swallows it). Runs bank as classic runs. The wound-decay hook (losing IS zombification) is the recorded next step once the feel survives a two-phone playtest. - **Test on staging with two phones** — the relay deploys with the ordinary push, nothing to diff --git a/index.html b/index.html index f45444b..0020170 100644 --- a/index.html +++ b/index.html @@ -155,7 +155,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { /* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame alive across every screen is the point — a screen that unmounts the whole overlay flashes the court through between menus. */ - #splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #bracket { display: none; } + #splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #bracket, #vs { display: none; } #intro[data-stage="splash"] #splash { display: flex; flex-direction: column; flex: 1; } /* flex COLUMN, not block: it is what lets the footer's margin-top:auto reach the bottom of a screen whose content does not fill it */ @@ -167,6 +167,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { #intro[data-stage="secret"] #secret, #intro[data-stage="bosses"] #bosses, #intro[data-stage="bracket"] #bracket, + #intro[data-stage="vs"] #vs, #intro[data-stage="newbest"] #newbest { display: flex; flex-direction: column; flex: 1; animation: drop 0.3s ease-out; } /* the overlay is HIDDEN during play, not removed: a restart puts it straight back, and the @@ -400,21 +401,53 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { .rkrow span { flex: 1; min-width: 0; font: 400 11px Silkscreen, monospace; color: var(--cream); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rkrow i { font: 400 11px Silkscreen, monospace; font-style: normal; color: var(--dim); } - /* ---- the name prompt. It is the only screen in the game that asks for typing, so the field is - deliberately big, centred and already focused: on a phone this appears with the keyboard - rising under it and there is no room for a second tap to find the input. */ + /* ---- typed fields. Two screens ask for typing — the name prompt and the netplay lobby — and + both want the same field: big, centred, acid on the panel, no rounded corners anywhere near it. + One class, two size overrides. */ .cent { text-align: center; } + .tin { display: block; width: 100%; box-sizing: border-box; margin: 0 auto; + padding: 12px 10px; background: var(--solid); border: 2px solid var(--line); + border-radius: 0; color: var(--acid); font: 700 16px Silkscreen, monospace; + letter-spacing: 3px; text-align: center; text-transform: uppercase; + -webkit-appearance: none; -webkit-tap-highlight-color: transparent; } + .tin:focus { outline: none; border-color: var(--acid); } + .tin::placeholder { color: var(--dim); letter-spacing: 2px; font-size: 12px; } #newbest h2 { font-size: 30px; margin-bottom: 6px; } #nbScore { margin: 0 0 22px; font: 400 11px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; } #nbScore b { color: var(--acid); font-weight: 400; } - #nbName { display: block; width: 100%; box-sizing: border-box; margin: 0 auto 10px; - padding: 14px 10px; background: var(--solid); border: 2px solid var(--line); - border-radius: 0; color: var(--acid); font: 700 20px Silkscreen, monospace; - letter-spacing: 3px; text-align: center; text-transform: uppercase; - -webkit-appearance: none; -webkit-tap-highlight-color: transparent; } - #nbName:focus { outline: none; border-color: var(--acid); } - #nbName::placeholder { color: var(--dim); letter-spacing: 2px; font-size: 14px; } + #nbName { margin-bottom: 10px; padding: 14px 10px; font-size: 20px; } + #nbName::placeholder { font-size: 14px; } + /* ---- THE NETPLAY LOBBY. Everything the mode needs before there is a court: who you are, the + two ways to host, and the friend's code. The room you are waiting in replaces the picker in + place (#vsWait), so the code and the SHARE button stand where the buttons were. */ + #vsErr:empty { display: none; } + #vsErr { margin: 10px 0 0; padding: 8px; border: 2px solid #6e2a24; background: rgba(110,42,36,0.22); + font: 400 10px Silkscreen, monospace; color: var(--bad); letter-spacing: 1px; + text-align: center; } + #vs .btn { margin-bottom: 8px; } + /* the room takes the whole screen the picker had: the code floats in the middle band and CANCEL + sits under your thumb, same three-band anchoring as every other screen */ + #vsWait { display: flex; flex-direction: column; flex: 1; } + #vsWait[hidden] { display: none; } + .joinrow { display: flex; gap: 8px; align-items: stretch; } + .joinrow .tin { flex: 1; letter-spacing: 8px; } + /* JOIN is the act, not a label beside a box: the crimson plate every primary button in the game + wears, and NO padding of its own — the row stretches it to exactly the field's height, which + its own 13px padding could only ever approximate. */ + /* #vs .btn's margin-bottom outranks a bare .joinrow .btn — and a margin on a stretched flex item + is exactly the 8px the button was coming up short by */ + #vs .joinrow .btn { flex: none; width: 104px; margin: 0; padding: 0 6px; background: var(--red); + border-color: #69201c; color: #f6e7dc; font: 400 22px Bangers, cursive; + letter-spacing: 2px; } + /* the code is the biggest thing on the screen — it is read aloud across a room as often as it is + shared, and a four-letter code you have to squint at is the whole friction being removed */ + #vsBig { margin: 4px 0 20px; font: 700 42px Silkscreen, monospace; color: var(--acid); + letter-spacing: 10px; text-indent: 10px; } + #vsMsg { margin-top: 12px; } + /* the h3 rows are label-left/hint-right everywhere else; this one has no hint and sits over a + centred code, so it centres too (text-align cannot reach it — the row is a flex box) */ + #vsWHdr { justify-content: center; } @@ -509,6 +542,37 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { + +
+

VS A FRIEND

+
+

01 · YOUR NAMEON THEIR SCOREBOARD

+ +

02 · HOST A MATCHTHEY COME TO YOU

+ + +

OR JOIN THEIRSWITH THEIR CODE

+
+ + +
+

+
+ +

BODY COUNT

@@ -581,6 +645,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
- + diff --git a/src/main.js b/src/main.js index ca180ba..b7f3b6e 100644 --- a/src/main.js +++ b/src/main.js @@ -6,7 +6,7 @@ import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe } from './config.js'; import { P, O, ball, score, G, setupPoint, selectCourt, selectTour, selectKit, selectSkin } from './state.js'; import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js'; -import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs } from './beats.js'; +import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs } from './beats.js'; import { netConnect, netHooks } from './net.js'; import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook } from './render.js'; import { voice, ambient, menuMusic, menuDuck } from './audio.js'; @@ -100,39 +100,11 @@ const banner = (card, cls, last) => { const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id); } catch {} }; // TAP ADVANCES. Each screen's tap both answers and moves on — the wizard's whole promise is one // decision per screen, and a NEXT button under a single row of choices is a second tap for nothing. -// NETPLAY's second step: two chips, and either one IS the start — a room needs a socket, not a -// setup screen (court and tour ride whatever was last picked). HOST shows the code to share in a -// modal over the frozen court; JOIN asks for a friend's code. -// ponytail: prompt() is the join UI — replace with a drawn code entry if the mode ships. -// The host's one decision — classic or the death match — rides the room, so the joiner never picks -// a mode: the start message carries it to both phones and both courts are rebuilt on pairing. -function netChip(label, join, death = false) { - const b = document.createElement('button'); - b.className = 'chip'; - b.textContent = label; - b.addEventListener('click', () => { - let code = null; - if (join) { - code = (prompt('ROOM CODE FROM YOUR OPPONENT') || '').trim().toUpperCase(); - if (!code) return; - } - start('classic'); // the waiting court is classic either way; a death room swaps it on pairing - // the host's court rides the room too (server.py) — the guest plays where the host plays - if (startNetVs()) netConnect(code, death, picked); - }); - return b; -} - +// (NETPLAY is the exception and has a screen of its own — see THE LOBBY below.) function buildSub(top) { modeSub.innerHTML = ''; const t = top || topOf(pickedMode); document.getElementById('subTitle').textContent = t.diffs ? 'DIFFICULTY' : (t.name || ''); - if (t.net) { - modeSub.appendChild(netChip('HOST — CLASSIC TENNIS', false)); - modeSub.appendChild(netChip('HOST — DEATH MATCH', false, true)); - modeSub.appendChild(netChip('JOIN WITH A CODE', true)); - return; - } if (t.diffs) for (const d of DIFFS) { const b = document.createElement('button'); b.className = 'chip'; @@ -161,9 +133,9 @@ function buildTops() { for (const t of MENU) { const b = banner(t.mode ? modeOf(t.mode) : t, 'mode', topOf(pickedMode) === t); b.addEventListener('click', () => { - // the netplay poster deals HOST/JOIN and never touches pickedMode — it is not a run mode - // the stats screen or the summary could name, and either chip starts the game itself - if (t.net) { buildSub(t); go('modesub'); return; } + // the netplay poster opens THE LOBBY and never touches pickedMode — it is not a run mode + // the stats screen or the summary could name, and the lobby starts the game itself + if (t.net) { vsOpen(); return; } // a group remembers which child you were on; entering it fresh lands on its first card pickMode(t.mode || (t.kids.includes(pickedMode) ? pickedMode : t.kids[0])); buildSub(); @@ -176,6 +148,75 @@ function buildTops() { buildTops(); buildSub(); +// ---- THE LOBBY. Oliver, 2026-08-15: *"multiplayer should be easier to setup — a share with link +// next to the code; entering the code is done in some plain js confirm dialog thingy; somewhere +// when hosting or also when joining the player should name themselves."* All three are one screen. +// +// WHY IT IS A MENU SCREEN AND NOT A COURT. Hosting used to drop you straight onto an empty court +// and show the code in a canvas modal — which cannot carry a SHARE button, cannot be typed into, +// and turns a mistyped code into a CONNECTION LOST card over a match that never started. The +// socket is now opened from the menu and the game stays parked behind the overlay until the room +// actually fills: `netHooks.paired` is what takes the court, one frame before netStart writes to +// it. Everything else about the protocol is untouched. +// +// THE NAME IS THE WORLD BOARD'S NAME (stats.saveName). One name per player, wherever it shows. +const vsPick = document.getElementById('vsPick'), vsWait = document.getElementById('vsWait'); +const vsName = document.getElementById('vsName'), vsCode = document.getElementById('vsCode'); +const vsSay = (id, text) => { document.getElementById(id).textContent = text; }; +// the link IS the invitation: the joiner's phone opens the lobby with the code already in the field +const vsLink = code => location.origin + location.pathname + '#net-' + code; + +function vsOpen(code = '') { + vsName.value = stats.savedName(); + vsCode.value = code; + vsPick.hidden = false; vsWait.hidden = true; + vsSay('vsErr', ''); + go('vs'); +} +// join === null hosts; `death` is the host's one decision and rides the room to both phones +function vsGo(join, death = false) { + stats.saveName(vsName.value); // whatever is in the field is who the other phone sees + const code = join ? stats.tidyName(vsCode.value).replace(/[^A-Z0-9]/g, '') : null; + if (join && code.length < 4) { vsSay('vsErr', 'THE CODE IS FOUR LETTERS'); return; } + if (!startNetVs()) return; // the court-ownership contract, same as every other mode + netConnect(code, death, picked); // the host's court rides the room (server.py) + vsPick.hidden = true; vsWait.hidden = false; + vsSay('vsWHdr', join ? 'JOINING' : 'ROOM CODE'); + vsSay('vsBig', join ? code : '····'); + vsSay('vsMsg', join ? 'KNOCKING ON THEIR DOOR' : 'WAITING FOR THEM TO JOIN'); + document.getElementById('vsShare').hidden = !!join; // nothing to share on the way in +} +document.getElementById('vsHostC').addEventListener('click', () => vsGo(null)); +document.getElementById('vsHostD').addEventListener('click', () => vsGo(null, true)); +document.getElementById('vsJoin').addEventListener('click', () => vsGo(true)); +vsCode.addEventListener('keydown', e => { if (e.key === 'Enter') vsGo(true); }); +// SHARE, then COPY. navigator.share is the phone's own sheet (WhatsApp, Signal, wherever the +// friend actually is) and is exactly the "easier setup" — the clipboard is the desktop fallback, +// and the code stays on screen for the two of them in the same room reading it aloud. +document.getElementById('vsShare').addEventListener('click', async () => { + const url = vsLink(G.net && G.net.code || ''); + try { + if (navigator.share) await navigator.share({ title: 'SPLATTER TENNIS', text: 'COME AND PLAY ME', url }); + else { await navigator.clipboard.writeText(url); vsSay('vsMsg', 'LINK COPIED — SEND IT TO THEM'); } + } catch { /* a cancelled share sheet is not an error */ } +}); +const vsQuit = () => { endNetVs(); vsOpen(); }; // a room you walked out of must give its socket back +document.getElementById('vsCancel').addEventListener('click', vsQuit); +document.getElementById('vsBack').addEventListener('click', () => { + if (!vsWait.hidden) return vsQuit(); // waiting: the chevron cancels rather than leaves + endNetVs(); goMode(); +}); +// the three hooks net.js calls back through — all of them no-ops once the court has been taken +netHooks.paired = () => start('classic'); // start() refuses when the menu is already down (URL entry) +netHooks.room = code => { vsSay('vsBig', code); vsSay('vsMsg', 'WAITING FOR THEM TO JOIN'); }; +netHooks.lost = text => { + if (!menuUp()) return false; // mid-match: the court's own card says it + endNetVs(); + vsPick.hidden = false; vsWait.hidden = true; + vsSay('vsErr', text); + return true; +}; + // --- tour tiles, built from the table. Same "tap selects, SERVE UP starts" rule as the court rows — // the setup screen picks WHO and WHERE, and nothing on it commits you. const tourList = document.getElementById('tours'); @@ -733,15 +774,10 @@ if (location.hash === '#drill') setTimeout(() => { start(null); intoSplatter(); if (location.hash === '#match') setTimeout(() => { start(null); intoSplatter(); startMatch(); }, 700); if (location.hash === '#cup') setTimeout(() => { start(null); intoSplatter(); startMatch(true); }, 700); if (location.hash === '#bench') setTimeout(() => { start(null); intoSplatter(); startBench(); }, 700); -// #net hosts classic, #netd hosts a death match, #net-ABCD joins — the netplay doors from a URL -if (location.hash === '#netd') setTimeout(() => { - start('classic'); - if (startNetVs()) netConnect(null, true, picked); -}, 700); -else if (location.hash.startsWith('#net')) setTimeout(() => { - start('classic'); - if (startNetVs()) netConnect(location.hash.slice(5).toUpperCase() || null, false, picked); -}, 700); +// #net opens the lobby, #net-ABCD opens it with a friend's code already in the field — that link +// IS what the SHARE button sends, so the invited phone lands one tap from the court (the tap is +// not ceremony: it is where they see, and can change, the name the other phone will call them). +if (location.hash.startsWith('#net')) setTimeout(() => vsOpen(location.hash.slice(5).toUpperCase()), 700); requestAnimationFrame(frame); diff --git a/src/net.js b/src/net.js index 7169bd3..8e1cc06 100644 --- a/src/net.js +++ b/src/net.js @@ -19,14 +19,19 @@ // plan calls reconnection "the bulk of the real work" and a prototype must not start there. import { COURT, UI, KITS, SKINS } from './config.js'; import { G, P, O, ball, score, setupPoint, selectCourt, court, kit, skin } from './state.js'; -import { pointTo, fault, showModal, tossBall } from './gameplay.js'; +import { pointTo, fault, tossBall } from './gameplay.js'; import { startMatch, matchReady, resolveInterlude, netMatchWon } from './beats.js'; // runtime-only cycle: beats sends verdicts back through this module import { zombies } from './zombies.js'; +import { savedName, tidyName } from './stats.js'; -// main.js hangs its court builders here, so this module never has to know how a splatter court is -// entered (the transition hack lives in main). Headless tests leave it empty and net falls back to -// the minimal direct entry, which is exactly what the suite's own freshSplatter does. -export const netHooks = { intoSplatter: null }; +// main.js hangs its court builders and its LOBBY here, so this module never has to know how a +// splatter court is entered or what the menu looks like. Headless tests leave them empty and net +// falls back to the minimal direct entry, which is exactly what the suite's own freshSplatter does. +// intoSplatter the death match's court rebuild +// paired the room filled: leave the menu and take the court, BEFORE netStart touches it +// room the code the relay minted (the lobby shows it and shares the link) +// lost the line died; returns true if the lobby said so itself and needs no card +export const netHooks = { intoSplatter: null, paired: null, room: null, lost: null }; let ws = null, hb = 0, posT = 0; let applying = false; // a remote verdict being applied must not echo back out @@ -115,10 +120,16 @@ function netSync(m) { } function netStart(side, death, courtId) { + // THE LOBBY HANDS THE COURT OVER FIRST. Hosting and joining both happen on the menu now (the + // code and its share link are DOM, not a canvas modal), so the game is still parked behind the + // overlay when the room fills — and everything below writes to a court that must already exist, + // with the kit and tour this phone picked already selected (the look message reads them). + if (netHooks.paired) netHooks.paired(); G.net.ready = true; G.net.side = side; - // tell them how we look, the moment there is somebody to tell. Sent once, on pairing: neither - // phone can change its pick mid-match, because the setup screen is only reachable between runs. - send({ t: 'look', k: kit.id, s: skin.id }); + // tell them how we look AND WHO WE ARE, the moment there is somebody to tell. Sent once, on + // pairing: neither phone can change its pick mid-match, because the setup screen is only + // reachable between runs. + send({ t: 'look', k: kit.id, s: skin.id, n: savedName() }); G.modal = null; G.modalQ.length = 0; // the waiting card has done its job // THE HOST'S COURT RIDES THE ROOM, exactly as the death-match pick does — two phones playing the // same rally on two different surfaces disagree about the bounce as well as the picture, and the @@ -159,6 +170,9 @@ function netDead(text) { G.net.ready = false; G.net.dead = true; ball.active = false; G.modal = null; G.modalQ.length = 0; + // the lobby is a menu screen: while it is up there is no court to lay a card on, and it says the + // bad news in its own words (a wrong code is a typo to fix, not a match that ended) + if (netHooks.lost && netHooks.lost(text)) return; G.card = { title: 'CONNECTION LOST', sub1: text, color: UI.bad, t: 30 }; } @@ -166,12 +180,12 @@ export function netMsg(m) { if (!G.net) return; if (m.t === 'room') { G.net.code = m.code; - // a modal is legal here: an empty court waiting for a stranger is the calmest court there is - showModal({ title: m.code, color: UI.pick, - body: ['THE ROOM CODE — TELL YOUR OPPONENT', 'WAITING FOR THEM TO JOIN'] }); + if (netHooks.room) netHooks.room(m.code); // the lobby: the code big, and a link to send them } else if (m.t === 'start') netStart(m.side, m.death, m.court); - else if (m.t === 'gone') netDead('NO SUCH ROOM — RELOAD AND HOST ONE'); + // 'gone' can only ever reach a phone that is JOINING, and joining happens in the lobby — so it is + // a typo to fix on the screen you are already looking at, not a match that ended + else if (m.t === 'gone') netDead('NO SUCH ROOM — CHECK THE CODE'); else if (m.t === 'bye') netDead('YOUR OPPONENT LEFT — RELOAD TO PLAY AGAIN'); else if (m.t === 'toss') netApplyToss(); else if (m.t === 'shot') netApplyShot(m); @@ -179,13 +193,16 @@ export function netMsg(m) { else if (m.t === 'fault') netApplyFault(); else if (m.t === 'dead') netMatchWon(); // their blood ran out: this side's win ending else if (m.t === 'pos') { G.net.opp = m; if (m.srv) netSync(m); } - // WHAT THEY LOOK LIKE. Their phone's kit and skin, resolved against our own tables so a build - // that has never heard of a colour falls back to the drawn one rather than drawing nothing. - // Unknown message types are ignored above, so an older build simply never sends this and their - // friend sees the opponent as drawn — the match still plays. + // WHAT THEY LOOK LIKE AND WHAT THEY ARE CALLED. Their phone's kit and skin, resolved against our + // own tables so a build that has never heard of a colour falls back to the drawn one rather than + // drawing nothing. Unknown message types are ignored above, so an older build simply never sends + // this and their friend sees the opponent as drawn, called FRIEND — the match still plays. + // The name is a stranger's string on OUR scoreboard: it goes through the same tidyName the world + // board uses, then into the board's 8-character slot. else if (m.t === 'look') G.net.look = { kit: KITS.find(k => k.id === m.k) || KITS[0], skin: SKINS.find(s => s.id === m.s) || SKINS[0], + name: tidyName(m.n || '').slice(0, 8) || 'FRIEND', }; } diff --git a/src/render.js b/src/render.js index 1555f87..c34bb81 100644 --- a/src/render.js +++ b/src/render.js @@ -2519,7 +2519,11 @@ export function render() { // ...and the match names the thing across the net rather than calling it THEM. Eight characters, // which is the name slot — see `short` on the opponent rows in config. const foe = G.match && zombies.find(z => z.variant === 'r' && z.foe); - const names = G.match ? ['YOU', (foe && foe.foe.short) || 'THEM'] + // NETPLAY NAMES THE PERSON, in either mode — the friend typed it into the lobby and it crossed + // on the look message. FRIEND until it lands (or an older build never sends one), never CPU: + // there is no CPU in this mode and the board saying so was the netplay prototype's last lie. + const names = G.net ? ['YOU', (G.net.look && G.net.look.name) || 'FRIEND'] + : G.match ? ['YOU', (foe && foe.foe.short) || 'THEM'] : splat ? ['YOU', 'THEM'] : ['YOU', 'CPU']; for (let i = 0; i < 2 && !benchOn; i++) { const cy = 12 + i * 18, svr = (score.server === 'P') === (i === 0); // rows aligned to the art's two bands diff --git a/src/stats.js b/src/stats.js index a931754..fe60bf7 100644 --- a/src/stats.js +++ b/src/stats.js @@ -208,13 +208,19 @@ export async function board(mode = MODE_GORE) { // appear on the board. export const tidyName = s => String(s).toUpperCase().replace(/[^A-Z0-9 ._-]/g, '').trim().slice(0, 12); +// ...and the one place it is remembered. The world board and the netplay lobby ask for the same +// name and must not keep two of them: whoever you signed the board as is who the other phone sees. +export const saveName = s => { + const name = tidyName(s); + try { localStorage.setItem(NAME_KEY, name); } catch {} // type it once, ever + return name; +}; // `k` is the BOARD's number and `g` its second column — what those mean is the mode's business // (BOARDS), and the server only ever sorts them. `m` is which board the row goes on. export async function submit(rawName, k, g, mode = MODE_GORE) { - const name = tidyName(rawName); + const name = saveName(rawName); if (!name) throw new Error('empty name'); - try { localStorage.setItem(NAME_KEY, name); } catch {} // type it once, ever const r = await fetch('api/scores', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ n: name, k, g, m: mode, id: myId() }) }); diff --git a/test.js b/test.js index de46c2a..9b9bc22 100644 --- a/test.js +++ b/test.js @@ -1645,6 +1645,19 @@ test('netplay: your look goes over the wire on pairing, and theirs comes back', state.selectKit(cfg.KITS[0].id); state.selectSkin(cfg.SKINS[0].id); // leave the table as found }); +// THE NAME RIDES THE SAME MESSAGE — the lobby's one typed field, on the other phone's scoreboard. +// It is a stranger's string landing in an 8-character slot, so it is normalised at the receiving +// end (the same tidyName the world board and server.py's clean() agree on), never as sent. +test('netplay: the friend names themselves, and the name lands tidy in the board\'s slot', () => { + freshNet('A'); + const look = netTest.sent.find(m => m.t === 'look'); + assert.equal(look.n, statsMod.savedName(), 'this phone announces the name it remembers'); + netMsg({ t: 'look', k: 'red', s: 'pale', n: ' bobranathan ' }); + assert.equal(G.net.look.name, 'BBOBBRAN', 'upper, stripped and cut to the eight-character slot'); + netMsg({ t: 'look', k: 'red', s: 'pale' }); // an older build sends no name at all + assert.equal(G.net.look.name, 'FRIEND', '...and an unnamed opponent is FRIEND, never CPU'); +}); + test('every kit and skin in the tables is a live, distinct pick', () => { for (const table of [cfg.KITS, cfg.SKINS]) { assert.equal(new Set(table.map(e => e.id)).size, table.length, 'ids are unique');