LAN co-op easy join — implementation plan
LAN co-op easy join Implementation Plan
Section titled “LAN co-op easy join Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. In this repo, also consultbh-sdd-concurrentbefore Task 1 and dispatchbh-implementer(model opus) per task, each in its OWN worktree.
Goal: A second device joins a LAN co-op game with no typing (nearby list), or with a 5-letter room code, falling back to a typed IP only as a last resort.
Architecture: The control Worker gains a tiny host registry (/mp/*, D1 table mp_hosts, CONTROL_KEY-gated) keyed by a salted hash of the caller’s public IP. A new render-side LanRegistry node registers the host, lists nearby hosts and resolves codes over HTTP; LanLobby merges registry + beacon hosts into one tap-to-join list with code entry; main.gd wires it in and tries a host’s private IPs in order. The Remote Console’s Join box takes a code or an IP. Nothing touches /sim, so determinism baselines cannot move.
Tech Stack: Cloudflare Worker (plain JS) + D1, node:test with the sqlite-backed control/test/_d1.mjs; Godot 4.7 typed GDScript + GUT.
Spec: docs/superpowers/specs/2026-10-07-lan-easy-join-design.md (approved by Chris 2026-10-07). Background: docs/superpowers/specs/2026-10-07-networked-coop-design.md, docs/architecture/coop-and-pilots.md § LAN co-op.
Global Constraints
Section titled “Global Constraints”- Branch base: work branches off
origin/overnight-2026-10-07(the LAN code is NOT on main). One worktree per task under.claude/worktrees/<name>, never branch inside a live checkout. Merge each finished task back intoovernight-2026-10-07andgit push origin overnight-2026-10-07right after. - Dev-only: every
/mp/*route requiresCONTROL_KEYexactly like the existing game-facing routes (POST:keyin the body; GET:?key=). The game side only ever constructsLanRegistryin a dev build (BuildConfig.dev_tools()). No privacy page / ASC label change. - Never store a raw IP: the Worker stores only
SHA-256(MP_SALT + "|" + netKey);MP_SALTis a Worker secret. MissingMP_SALT→ every/mp/*route answers 503 (after the key check). - Room code: 5 chars from
ABCDEFGHJKMNPQRSTUVWXYZ23456789(no 0/O/1/I/L), random, collision retry, case-insensitive lookup. - Liveness: host re-POSTs every 15 s; a row expires 60 s after its last refresh;
leavedeletes at once. - “Same network” key: IPv4 → the full address (
v4:a.b.c.d); IPv6 → its /64 (v6:+ first 4 expanded hextets);::ffff:a.b.c.dcounts as IPv4. A row keeps every key it has been seen from;nearbymatches on ANY overlap. - Limits:
lan_ips≤ 4 entries, private IPv4 only (10/8, 172.16/12, 192.168/16), others dropped;name≤ 24 chars;portmust be 1024–65535;builda positive integer. POST bodies are guarded: malformed → 400, never 500. - D1 table:
mp_hosts(code TEXT PRIMARY KEY, net_keys TEXT, name TEXT, build INTEGER, port INTEGER, lan_ips TEXT, updated_ms INTEGER)incontrol/migrations/0002_mp_hosts.sql. - Offline: if the Worker is unreachable, the game behaves exactly as today (beacon list + typed IP). Nearby failures are silent; a code lookup failure shows a status line.
- Panel client string rule:
control/src/panel/*.jsare string modules: no backtick, no${, no backslash inside them (control/test/panel-modules.test.mjsenforces it). Write regex classes like[.]and[0-9], never\./\d. - Tests: never pull a whole GUT run into context; use
scripts/check-test-count.sh(full log at$TMPDIR/bh-gut-last.log). Single GUT script:godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=<exact_name>.gd -gexit. A fresh worktree needsgodot --headless --path . --importonce first. Node:node --test control/test/*.test.mjs. Merge gate:bash scripts/gate.sh. - Godot strictness: warnings are errors; annotate
Dictionary.get()/JSON.parse_string()results asVariant, not:=. - No new D1 table for telemetry (this is the control DB, not telemetry), so
RETENTION_TABLESis untouched.
File structure
Section titled “File structure”| File | Responsibility |
|---|---|
control/src/mp.js (new) |
Pure registry helpers: code alphabet/alloc/normalise, private-v4 check, net key, salted hash, host body validation, row → host view |
control/src/worker.js |
/mp/host, /mp/host/leave, /mp/nearby, /mp/code/<CODE> routes + mp_hosts prune |
control/migrations/0002_mp_hosts.sql (new), control/schema.sql |
The table |
control/test/mp-registry.test.mjs (new) |
Worker + helper tests |
net/lan_registry.gd (new) |
Game-side HTTP client for the registry (host refresh, nearby poll, code resolve) + pure IP helpers |
tests/test_lan_registry.gd (new) |
Registry against a stub requester |
ui/lan_lobby.gd |
Host view shows the code; join view = merged list → code entry → collapsed IP; merge_hosts |
tests/test_lan_lobby.gd (new) |
Lobby merge / de-dupe / BUILD gating / code entry |
main.gd (LAN section ~L3588-3720, build_status ~L3299) |
Wire registry into host/join, code resolve, multi-IP fallover, lan_coop code, status hosting |
tests/test_lan_easy_join_main.gd (new) |
The wiring, through the real main.tscn |
tests/gut_pre_run.gd |
LanRegistry.io_enabled = false |
control/src/panel/{markup,client,tooltips}.js, control/src/whats_new.js, control/test/_panel-harness.mjs |
Console Join box (code or IP), hosting line, last-host prefill |
control/test/panel-lan.test.mjs (new) |
Console tests |
control/README.md, docs/architecture/coop-and-pilots.md |
Docs |
Task 1: Worker host registry (/mp/*)
Section titled “Task 1: Worker host registry (/mp/*)”Files:
- Create:
control/src/mp.js,control/migrations/0002_mp_hosts.sql,control/test/mp-registry.test.mjs - Modify:
control/src/worker.js(imports at L1,pruneStaleL11-17, new routes before the final 404 at L131),control/schema.sql(append),control/README.md(endpoint table + secret + deploy steps)
Interfaces:
-
Produces (HTTP, consumed by Task 2):
POST /mp/hostbody{key, code?, build, port, name, lan_ips}→200 {code, ttl_s: 60};400 {ok:false,error};401;503.POST /mp/host/leavebody{key, code}→200 {ok:true}.GET /mp/nearby?key=→200 {hosts: [{code, name, build, port, lan_ips}]}.GET /mp/code/<CODE>?key=→200 {code, name, build, port, lan_ips}or404 {ok:false,error:'not found'}.
-
Produces (JS):
mp.jsexportsMP_CODE_ALPHABET, MP_CODE_LEN, MP_TTL_MS, MP_MAX_IPS, MP_NAME_MAX, allocCode(rand), normCode(v), isPrivateV4(ip), netKey(ip), hashKey(salt, key), hostBody(b), hostView(row). -
Test seam:
env.MP_RAND(a() => number) replacesMath.randomfor code allocation. -
Step 1: Write the migration and schema
control/migrations/0002_mp_hosts.sql:
-- LAN co-op easy join (2026-10-07): live LAN hosts by room code. A host re-POSTs /mp/host every-- 15 s; a row is dead 60 s after updated_ms and is pruned on the next /mp/host or /caps POST.-- net_keys = JSON array of salted SHA-256 hashes of the networks the host was seen from (never a-- raw IP); lan_ips = JSON array of the host's private IPv4s. docs/superpowers/specs/2026-10-07-lan-easy-join-design.mdCREATE TABLE IF NOT EXISTS mp_hosts ( code TEXT PRIMARY KEY, net_keys TEXT NOT NULL, name TEXT NOT NULL, build INTEGER NOT NULL, port INTEGER NOT NULL, lan_ips TEXT NOT NULL, updated_ms INTEGER NOT NULL);Append the same CREATE TABLE IF NOT EXISTS mp_hosts (...) to control/schema.sql with a one-line comment -- LAN hosts by room code. migrations/0002_mp_hosts.sql (matches how watch is mirrored there).
- Step 2: Write the failing tests
control/test/mp-registry.test.mjs:
// LAN co-op easy join (2026-10-07): the /mp/* host registry. Runs the real worker against the// sqlite-backed D1 stand-in (./_d1.mjs, schema.sql + migrations). 203.0.113.0/24 and 2001:db8::/32// are documentation ranges.import { test } from 'node:test';import assert from 'node:assert/strict';import worker from '../src/worker.js';import { fakeD1 } from './_d1.mjs';import { allocCode, normCode, netKey, isPrivateV4, hostBody, MP_CODE_ALPHABET, MP_TTL_MS } from '../src/mp.js';
const KEY = 'k';const env = (extra = {}) => ({ ...fakeD1(), CONTROL_KEY: KEY, MP_SALT: 'salt', ...extra });const call = (e, method, path, { ip, body, key = KEY } = {}) => { const headers = ip ? { 'CF-Connecting-IP': ip } : {}; const q = method === 'GET' && key ? (path.includes('?') ? '&' : '?') + 'key=' + key : ''; const init = { method, headers }; if (method === 'POST') init.body = typeof body === 'string' ? body : JSON.stringify({ key, ...body }); return worker.fetch(new Request('https://x.test' + path + q, init), e);};const HOST = { build: 265, port: 47610, name: 'Mac · macOS', lan_ips: ['192.168.1.20'] };const host = async (e, ip, extra = {}) => (await call(e, 'POST', '/mp/host', { ip, body: { ...HOST, ...extra } })).json();const nearby = async (e, ip) => (await (await call(e, 'GET', '/mp/nearby', { ip })).json()).hosts;
test('the code alphabet has no 0/O/1/I/L and every allocated code normalises to itself', () => { for (const ch of '0O1IL') assert.ok(!MP_CODE_ALPHABET.includes(ch), ch); for (let i = 0; i < 500; i++) { const c = allocCode(); assert.equal(normCode(c), c); } assert.equal(normCode(' abcde '), 'ABCDE'); assert.equal(normCode('ABCD0'), null); assert.equal(normCode('ABCDEF'), null); assert.equal(normCode(12345), null);});
test('netKey: v4 whole address, v6 /64, v4-mapped v6 counts as v4', () => { assert.equal(netKey('203.0.113.7'), 'v4:203.0.113.7'); assert.equal(netKey('2001:db8:1:2::5'), 'v6:2001:0db8:0001:0002'); assert.equal(netKey('2001:DB8:1:2:aaaa:bbbb:cccc:dddd'), 'v6:2001:0db8:0001:0002'); assert.equal(netKey('::ffff:203.0.113.7'), 'v4:203.0.113.7'); assert.equal(netKey('999.1.1.1'), null); assert.equal(netKey(''), null); assert.equal(netKey(null), null);});
test('isPrivateV4 and hostBody limits', () => { assert.ok(isPrivateV4('10.0.0.5') && isPrivateV4('172.16.0.1') && isPrivateV4('172.31.9.9') && isPrivateV4('192.168.1.2')); assert.ok(!isPrivateV4('172.32.0.1') && !isPrivateV4('8.8.8.8') && !isPrivateV4('192.169.0.1') && !isPrivateV4('fe80::1')); const b = hostBody({ build: 265, port: 47610, name: 'x'.repeat(40), lan_ips: ['8.8.8.8', '10.0.0.5', '172.16.0.1', '172.32.0.1', '192.168.1.2', '192.168.1.3', '10.1.1.1', '10.0.0.5'] }); assert.equal(b.name.length, 24); assert.deepEqual(b.lan_ips, ['10.0.0.5', '172.16.0.1', '192.168.1.2', '192.168.1.3']); assert.equal(hostBody({ build: 265, port: 80 }), null); assert.equal(hostBody({ build: 265, port: 70000 }), null); assert.equal(hostBody({ build: 'x', port: 47610 }), null); assert.equal(hostBody(null), null); assert.equal(hostBody({ build: 265, port: 47610 }).name, 'Device');});
test('host allocates a code; a refresh with that code keeps it and updates the row', async () => { const e = env(); const a = await host(e, '203.0.113.7'); assert.equal(normCode(a.code), a.code); assert.equal(a.ttl_s, 60); const b = await host(e, '203.0.113.7', { code: a.code.toLowerCase(), name: 'Renamed' }); assert.equal(b.code, a.code); const rows = e.db.prepare('SELECT * FROM mp_hosts').all(); assert.equal(rows.length, 1); assert.equal(rows[0].name, 'Renamed');});
test('a refresh whose row expired re-registers the SAME code', async () => { const e = env(); const a = await host(e, '203.0.113.7'); e.db.prepare('UPDATE mp_hosts SET updated_ms=?').run(Date.now() - MP_TTL_MS - 1000); const b = await host(e, '203.0.113.7', { code: a.code }); assert.equal(b.code, a.code);});
test('allocation retries past a live collision', async () => { const seq = [0, 0, 0, 0, 0, 0.1, 0.1, 0.1, 0.1, 0.1]; const e = env({ MP_RAND: () => seq.shift() ?? 0.5 }); e.db.prepare('INSERT INTO mp_hosts VALUES(?,?,?,?,?,?,?)').run('AAAAA', '[]', 'x', 1, 47610, '[]', Date.now()); const a = await host(e, '203.0.113.7'); assert.equal(a.code, 'DDDDD'); // 0.1 * 31 -> index 3});
test('expiry: a row 60 s past its refresh is gone from code lookup and nearby', async () => { const e = env(); const a = await host(e, '203.0.113.7'); assert.equal((await call(e, 'GET', '/mp/code/' + a.code)).status, 200); e.db.prepare('UPDATE mp_hosts SET updated_ms=?').run(Date.now() - MP_TTL_MS - 1); assert.equal((await call(e, 'GET', '/mp/code/' + a.code)).status, 404); assert.deepEqual(await nearby(e, '203.0.113.7'), []);});
test('code lookup is case-insensitive and returns the host view', async () => { const e = env(); const a = await host(e, '203.0.113.7'); const r = await call(e, 'GET', '/mp/code/' + a.code.toLowerCase()); assert.equal(r.status, 200); assert.deepEqual(await r.json(), { code: a.code, name: HOST.name, build: 265, port: 47610, lan_ips: ['192.168.1.20'] }); assert.equal((await call(e, 'GET', '/mp/code/ZZZZZ')).status, 404); assert.equal((await call(e, 'GET', '/mp/code/0OO1L')).status, 404);});
test('leave deletes the row at once', async () => { const e = env(); const a = await host(e, '203.0.113.7'); const r = await call(e, 'POST', '/mp/host/leave', { body: { code: a.code } }); assert.equal(r.status, 200); assert.equal((await call(e, 'GET', '/mp/code/' + a.code)).status, 404); assert.equal((await call(e, 'POST', '/mp/host/leave', { body: { code: 'nope' } })).status, 400);});
test('nearby: same v4 and same v6 /64 match; another network does not', async () => { const e = env(); const v4 = await host(e, '203.0.113.7'); const v6 = await host(e, '2001:db8:1:2::5', { name: 'v6 host' }); assert.deepEqual((await nearby(e, '203.0.113.7')).map((h) => h.code), [v4.code]); assert.deepEqual((await nearby(e, '2001:db8:1:2:ffff::9')).map((h) => h.code), [v6.code]); assert.deepEqual(await nearby(e, '198.51.100.1'), []); assert.deepEqual(await nearby(e, '2001:db8:1:3::5'), []); assert.deepEqual(await nearby(e, undefined), [], 'no CF-Connecting-IP = no network = nothing');});
test('nearby: a host seen over v4 then v6 matches joiners on either family', async () => { const e = env(); const a = await host(e, '203.0.113.7'); await host(e, '2001:db8:1:2::5', { code: a.code }); assert.deepEqual((await nearby(e, '203.0.113.7')).map((h) => h.code), [a.code]); assert.deepEqual((await nearby(e, '2001:db8:1:2::77')).map((h) => h.code), [a.code]);});
test('only salted hashes are stored, never a raw IP', async () => { const e = env(); await host(e, '203.0.113.7'); await host(e, '2001:db8:1:2::5'); const dump = JSON.stringify(e.db.prepare('SELECT * FROM mp_hosts').all()); assert.ok(!dump.includes('203.0.113'), dump); assert.ok(!dump.toLowerCase().includes('2001:db8') && !dump.includes('2001:0db8'), dump); const other = env({ MP_SALT: 'other' }); await host(other, '203.0.113.7'); const k1 = JSON.parse(e.db.prepare('SELECT net_keys FROM mp_hosts LIMIT 1').get().net_keys)[0]; const k2 = JSON.parse(other.db.prepare('SELECT net_keys FROM mp_hosts').get().net_keys)[0]; assert.notEqual(k1, k2, 'the salt changes the hash');});
test('bad bodies are 400, never 500', async () => { const e = env(); assert.equal((await call(e, 'POST', '/mp/host', { body: '{nope' })).status, 400); assert.equal((await call(e, 'POST', '/mp/host', { body: { build: 265, port: 80 } })).status, 400); assert.equal((await call(e, 'POST', '/mp/host', { body: { build: 'x', port: 47610 } })).status, 400); assert.equal((await call(e, 'POST', '/mp/host/leave', { body: '[]' })).status, 401);});
test('every /mp route is key-gated (401), then needs MP_SALT (503)', async () => { const e = env(); assert.equal((await call(e, 'POST', '/mp/host', { body: HOST, key: 'bad' })).status, 401); assert.equal((await call(e, 'POST', '/mp/host/leave', { body: { code: 'ABCDE' }, key: 'bad' })).status, 401); assert.equal((await call(e, 'GET', '/mp/nearby', { key: '' })).status, 401); assert.equal((await call(e, 'GET', '/mp/code/ABCDE', { key: '' })).status, 401); const n = env({ MP_SALT: '' }); assert.equal((await call(n, 'POST', '/mp/host', { body: HOST })).status, 503); assert.equal((await call(n, 'GET', '/mp/nearby')).status, 503); assert.equal((await call(n, 'GET', '/mp/code/ABCDE')).status, 503);});
test('the /caps prune also drops dead mp_hosts rows', async () => { const e = env(); await host(e, '203.0.113.7'); e.db.prepare('UPDATE mp_hosts SET updated_ms=?').run(Date.now() - MP_TTL_MS - 1); await call(e, 'POST', '/caps', { body: { code: 'AAA111', blob: { x: 1 } } }); assert.equal(e.db.prepare('SELECT COUNT(*) AS n FROM mp_hosts').get().n, 0);});- Step 3: Run to verify they fail
Run: node --test control/test/mp-registry.test.mjs
Expected: FAIL — Cannot find module '../src/mp.js'.
- Step 4: Write
control/src/mp.js
// LAN co-op easy join (2026-10-07): pure helpers for the /mp/* host registry in worker.js. No D1;// the only global is crypto.subtle (Workers and Node 22 both have it), so node --test covers it.// Spec: docs/superpowers/specs/2026-10-07-lan-easy-join-design.md.
export const MP_CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // no 0/O/1/I/Lexport const MP_CODE_LEN = 5;export const MP_TTL_MS = 60000; // a host row is dead 60 s after its last refresh (the game refreshes every 15 s)export const MP_MAX_IPS = 4;export const MP_NAME_MAX = 24;
export function allocCode(rand = Math.random) { let c = ''; for (let i = 0; i < MP_CODE_LEN; i++) c += MP_CODE_ALPHABET[Math.floor(rand() * MP_CODE_ALPHABET.length)]; return c;}
// The canonical (upper-case) code, or null for anything that is not one.export function normCode(v) { if (typeof v !== 'string') return null; const c = v.trim().toUpperCase(); if (c.length !== MP_CODE_LEN) return null; for (const ch of c) if (!MP_CODE_ALPHABET.includes(ch)) return null; return c;}
const V4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;function v4Octets(ip) { const m = typeof ip === 'string' ? ip.match(V4) : null; if (!m) return null; const o = m.slice(1).map(Number); return o.every((n) => n <= 255) ? o : null;}
export function isPrivateV4(ip) { const o = v4Octets(ip); if (!o) return false; return o[0] === 10 || (o[0] === 172 && o[1] >= 16 && o[1] <= 31) || (o[0] === 192 && o[1] === 168);}
// Eight zero-padded hextets, or null.function expand6(ip) { const halves = ip.toLowerCase().split('::'); if (halves.length > 2) return null; const head = halves[0] ? halves[0].split(':') : []; const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : []; const fill = halves.length === 2 ? 8 - head.length - tail.length : 0; if (fill < 0) return null; const all = [...head, ...Array(fill).fill('0'), ...tail]; if (all.length !== 8 || !all.every((h) => /^[0-9a-f]{1,4}$/.test(h))) return null; return all.map((h) => h.padStart(4, '0'));}
// The "same network" key for one request's CF-Connecting-IP: IPv4 = the whole address, IPv6 = its /64.export function netKey(ip) { if (typeof ip !== 'string' || !ip) return null; const mapped = ip.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i); const addr = mapped ? mapped[1] : ip; if (v4Octets(addr)) return 'v4:' + addr; const h = addr.includes(':') ? expand6(addr) : null; return h ? 'v6:' + h.slice(0, 4).join(':') : null;}
// Hex SHA-256 of salt|key. Only this is ever stored.export async function hashKey(salt, key) { const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(salt + '|' + key)); return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');}
// A validated /mp/host body, or null (-> 400). Non-private / extra lan_ips are dropped, not refused.export function hostBody(b) { if (!b || typeof b !== 'object' || Array.isArray(b)) return null; const { build, port } = b; if (!Number.isInteger(build) || build <= 0) return null; if (!Number.isInteger(port) || port < 1024 || port > 65535) return null; const name = (typeof b.name === 'string' && b.name.trim() ? b.name.trim() : 'Device').slice(0, MP_NAME_MAX); const ips = Array.isArray(b.lan_ips) ? b.lan_ips.filter(isPrivateV4) : []; return { code: normCode(b.code), build, port, name, lan_ips: [...new Set(ips)].slice(0, MP_MAX_IPS) };}
export function hostView(row) { return { code: row.code, name: row.name, build: row.build, port: row.port, lan_ips: JSON.parse(row.lan_ips) };}- Step 5: Wire the routes into
control/src/worker.js
Imports (top of file):
import { allocCode, normCode, netKey, hashKey, hostBody, hostView, MP_TTL_MS } from './mp.js';In pruneStale, after the watch delete (a Worker deployed before migration 0002 has no table, so guard it):
try { await env.DB.prepare('DELETE FROM mp_hosts WHERE updated_ms < ?').bind(now - MP_TTL_MS).run(); } catch { /* pre-0002 DB */ }Add above export default:
// ── LAN co-op easy join (2026-10-07): host registry. spec 2026-10-07-lan-easy-join-design.md ──// The caller's network key as a salted hash ([] when CF-Connecting-IP is missing or unparseable).async function mpKeys(env, req) { const k = netKey(req.headers.get('CF-Connecting-IP')); return k ? [await hashKey(env.MP_SALT, k)] : [];}
const MP_INSERT = 'INSERT OR IGNORE INTO mp_hosts(code,net_keys,name,build,port,lan_ips,updated_ms) VALUES(?,?,?,?,?,?,?)';
async function mpRoute(req, env, url) { const now = Date.now(); const p = url.pathname; if (req.method === 'POST' && (p === '/mp/host' || p === '/mp/host/leave')) { let body; try { body = JSON.parse(await req.text()); } catch { return j({ ok: false, error: 'bad json' }, 400); } if (!keyOk(env.CONTROL_KEY, body?.key)) return j({ ok: false, error: 'unauthorized' }, 401); if (!env.MP_SALT) return j({ ok: false, error: 'mp disabled' }, 503); if (p === '/mp/host/leave') { const c = normCode(body.code); if (!c) return j({ ok: false, error: 'bad code' }, 400); await env.DB.prepare('DELETE FROM mp_hosts WHERE code=?').bind(c).run(); return j({ ok: true }); } const h = hostBody(body); if (!h) return j({ ok: false, error: 'bad host' }, 400); await env.DB.prepare('DELETE FROM mp_hosts WHERE updated_ms < ?').bind(now - MP_TTL_MS).run(); const keys = await mpKeys(env, req); const ttl = MP_TTL_MS / 1000; if (h.code) { const row = await env.DB.prepare('SELECT net_keys FROM mp_hosts WHERE code=?').bind(h.code).first(); if (row) { const merged = [...new Set([...JSON.parse(row.net_keys), ...keys])].slice(-4); await env.DB.prepare('UPDATE mp_hosts SET net_keys=?, name=?, build=?, port=?, lan_ips=?, updated_ms=? WHERE code=?') .bind(JSON.stringify(merged), h.name, h.build, h.port, JSON.stringify(h.lan_ips), now, h.code).run(); return j({ code: h.code, ttl_s: ttl }); } } // A refresh whose row expired takes its old code back if still free; otherwise a fresh one. const tries = h.code ? [h.code] : []; for (let i = 0; i < 8; i++) tries.push(allocCode(env.MP_RAND || Math.random)); for (const c of tries) { const r = await env.DB.prepare(MP_INSERT) .bind(c, JSON.stringify(keys), h.name, h.build, h.port, JSON.stringify(h.lan_ips), now).run(); if (r.meta && r.meta.changes === 1) return j({ code: c, ttl_s: ttl }); } return j({ ok: false, error: 'no free code' }, 503); } if (req.method === 'GET' && (p === '/mp/nearby' || p.startsWith('/mp/code/'))) { if (!keyOk(env.CONTROL_KEY, url.searchParams.get('key'))) return j({ ok: false, error: 'unauthorized' }, 401); if (!env.MP_SALT) return j({ ok: false, error: 'mp disabled' }, 503); if (p === '/mp/nearby') { const keys = await mpKeys(env, req); if (!keys.length) return j({ hosts: [] }); const rows = (await env.DB.prepare('SELECT * FROM mp_hosts WHERE updated_ms >= ? ORDER BY updated_ms DESC') .bind(now - MP_TTL_MS).all()).results || []; return j({ hosts: rows.filter((r) => JSON.parse(r.net_keys).some((k) => keys.includes(k))).map(hostView) }); } let raw = ''; try { raw = decodeURIComponent(p.slice('/mp/code/'.length)); } catch { raw = ''; } const c = normCode(raw); const row = c ? await env.DB.prepare('SELECT * FROM mp_hosts WHERE code=? AND updated_ms >= ?').bind(c, now - MP_TTL_MS).first() : null; return row ? j(hostView(row)) : j({ ok: false, error: 'not found' }, 404); } return null;}In fetch, just before return new Response('not found', { status: 404 });:
if (url.pathname.startsWith('/mp/')) { const r = await mpRoute(req, env, url); if (r) return r; }- Step 6: Run the tests
Run: node --test control/test/*.test.mjs
Expected: all pass, including the 15 new ones; no existing test changes.
- Step 7: Document it in
control/README.md
Add four rows to the endpoint table (one per route, same column style, Auth = “key in body” / “key in query”, descriptions from the Interfaces block above). Add a section:
## `MP_SALT` secret (LAN co-op easy join, 2026-10-07)
The `/mp/*` host registry stores only `SHA-256(MP_SALT|network)` for "same network" matching, never anIP. Without the secret every `/mp/*` route answers 503. Set once (value lives in `~/.secrets` as`BULLET_HEAVEN_MP_SALT`, added via `lumara-passwords`):
echo "$BULLET_HEAVEN_MP_SALT" | npx wrangler secret put MP_SALTIn the Deploy block add after the 0001 line:
npx wrangler d1 execute bullet-heaven-control --remote --file=migrations/0002_mp_hosts.sql # 2026-10-07: mp_hosts.
In “D1 schema” add: - `mp_hosts` — live LAN co-op hosts by room code, hashed network keys, 60 s TTL (`migrations/0002_mp_hosts.sql`).
- Step 8: Commit
git add control/src/mp.js control/src/worker.js control/migrations/0002_mp_hosts.sql control/schema.sql control/test/mp-registry.test.mjs control/README.mdgit commit -m "feat(control): /mp host registry for LAN co-op easy join (room codes, hashed same-network match)"Task 2: LanRegistry (game-side registry client)
Section titled “Task 2: LanRegistry (game-side registry client)”Files:
- Create:
net/lan_registry.gd,tests/test_lan_registry.gd - Modify:
tests/gut_pre_run.gd(addLanRegistry.io_enabled = falseafter the ControlClient line)
Interfaces:
-
Consumes: Task 1’s HTTP routes;
ControlClient.ENDPOINT,ControlClient.CONTROL_KEY(net/control_client.gd). -
Produces (
class_name LanRegistry extends Node):- signals
code_ready(code: String),nearby(hosts: Array[Dictionary]),resolved(host: Dictionary),failed(reason: String) var requester: Callable— called asrequester.call(method: int, path: String, body: Dictionary, done: Callable);done.call(status: int, data: Variant),status == 0= transport failure. Default = real HTTP.var host_code: Stringfunc host_start(build: int, port: int, name: String, lan_ips: Array[String]) -> void,func host_stop() -> void,func is_hosting() -> boolfunc start_nearby() -> void,func stop_nearby() -> void,func refresh_nearby() -> voidfunc resolve_code(code: String) -> voidfunc tick(dt: float) -> void(called by_process; tests call it directly)- statics:
normalize_code(v: Variant) -> String(“” if invalid),sanitize_host(d: Variant) -> Dictionary({} if invalid; else{code, name, build, port, lan_ips: Array[String]}),order_ips(host_ips: Array, own_ips: Array) -> Array[String],private_ipv4s(ips: Array) -> Array[String] - consts
REFRESH_S := 15.0,NEARBY_S := 3.0,CODE_ALPHABET,CODE_LEN := 5
- signals
-
Step 1: Write the failing test —
tests/test_lan_registry.gd:
extends GutTest
# LanRegistry (LAN co-op easy join, 2026-10-07) against a stub requester: no network in tests.# Every request lands in `calls`; a test answers one with calls[i]["done"].call(status, data).
var calls: Array[Dictionary] = []
func _stub(method: int, path: String, body: Dictionary, done: Callable) -> void: calls.append({"method": method, "path": path, "body": body, "done": done})
func _reg() -> LanRegistry: calls.clear() var r := LanRegistry.new() r.requester = _stub add_child_autofree(r) return r
func _host_calls() -> Array[Dictionary]: return calls.filter(func(c: Dictionary) -> bool: return c["path"] == "/mp/host")
func test_host_start_registers_then_refreshes_with_the_code() -> void: var r := _reg() watch_signals(r) var ips: Array[String] = ["192.168.1.20"] r.host_start(265, 47610, "Mac · macOS", ips) assert_eq(calls.size(), 1) assert_eq(calls[0]["method"], HTTPClient.METHOD_POST) var b: Dictionary = calls[0]["body"] assert_eq(b["build"], 265) assert_eq(b["port"], 47610) assert_eq(b["name"], "Mac · macOS") assert_eq(b["lan_ips"], ips) assert_eq(b["key"], ControlClient.CONTROL_KEY) assert_false(b.has("code"), "first registration asks for a code") (calls[0]["done"] as Callable).call(200, {"code": "ABCDE", "ttl_s": 60}) assert_signal_emitted_with_parameters(r, "code_ready", ["ABCDE"]) assert_eq(r.host_code, "ABCDE") r.tick(LanRegistry.REFRESH_S - 0.1) assert_eq(_host_calls().size(), 1, "no refresh before 15 s") r.tick(0.2) assert_eq(_host_calls().size(), 2) assert_eq((_host_calls()[1]["body"] as Dictionary)["code"], "ABCDE", "a refresh resends its code")
func test_no_second_host_post_while_one_is_in_flight() -> void: var r := _reg() var ips: Array[String] = [] r.host_start(265, 47610, "x", ips) r.tick(LanRegistry.REFRESH_S * 3) assert_eq(_host_calls().size(), 1)
func test_host_stop_leaves_and_stops_refreshing() -> void: var r := _reg() var ips: Array[String] = [] r.host_start(265, 47610, "x", ips) (calls[0]["done"] as Callable).call(200, {"code": "ABCDE"}) r.host_stop() assert_false(r.is_hosting()) var last: Dictionary = calls[-1] assert_eq(last["path"], "/mp/host/leave") assert_eq((last["body"] as Dictionary)["code"], "ABCDE") var n := calls.size() r.tick(LanRegistry.REFRESH_S * 3) assert_eq(calls.size(), n, "nothing after stop") assert_eq(r.host_code, "")
func test_a_code_arriving_after_stop_is_released() -> void: var r := _reg() var ips: Array[String] = [] r.host_start(265, 47610, "x", ips) r.host_stop() # before the first answer: no code to leave yet (calls[0]["done"] as Callable).call(200, {"code": "ABCDE"}) assert_eq(calls[-1]["path"], "/mp/host/leave", "the late code is deleted, not orphaned") assert_eq(r.host_code, "")
func test_nearby_polls_every_3s_and_sanitises() -> void: var r := _reg() watch_signals(r) r.start_nearby() assert_eq(calls.size(), 1) assert_eq(calls[0]["method"], HTTPClient.METHOD_GET) assert_true(String(calls[0]["path"]).begins_with("/mp/nearby?key=")) (calls[0]["done"] as Callable).call(200, {"hosts": [ {"code": "abcde", "name": "Mac", "build": 265, "port": 47610, "lan_ips": ["192.168.1.20", "fe80::1", 7]}, {"code": "0OO1L", "name": "bad code", "build": 265, "port": 47610, "lan_ips": []}, "junk"]}) var got: Array = get_signal_parameters(r, "nearby")[0] assert_eq(got.size(), 1) assert_eq((got[0] as Dictionary)["code"], "ABCDE") assert_eq((got[0] as Dictionary)["lan_ips"], ["192.168.1.20"] as Array[String]) r.tick(LanRegistry.NEARBY_S + 0.01) assert_eq(calls.size(), 2) r.stop_nearby() (calls[1]["done"] as Callable).call(200, {"hosts": []}) r.tick(LanRegistry.NEARBY_S * 3) assert_eq(calls.size(), 2, "stopped") assert_signal_emit_count(r, "nearby", 1, "an answer after stop is dropped")
func test_nearby_failure_is_silent() -> void: var r := _reg() watch_signals(r) r.start_nearby() (calls[0]["done"] as Callable).call(0, null) assert_signal_not_emitted(r, "nearby") assert_signal_not_emitted(r, "failed")
func test_resolve_code_paths() -> void: var r := _reg() watch_signals(r) r.resolve_code(" abcde ") assert_true(String(calls[0]["path"]).begins_with("/mp/code/ABCDE?key=")) (calls[0]["done"] as Callable).call(200, {"code": "ABCDE", "name": "Mac", "build": 265, "port": 47610, "lan_ips": ["10.0.0.5"]}) assert_signal_emitted(r, "resolved") assert_eq((get_signal_parameters(r, "resolved")[0] as Dictionary)["lan_ips"], ["10.0.0.5"] as Array[String]) r.resolve_code("FGHJK") (calls[1]["done"] as Callable).call(404, {"ok": false}) assert_string_contains(String(get_signal_parameters(r, "failed")[0]), "FGHJK") r.resolve_code("FGHJK") (calls[2]["done"] as Callable).call(0, null) assert_string_contains(String(get_signal_parameters(r, "failed")[0]), "matchmaker") var n := calls.size() r.resolve_code("0OO1L") assert_eq(calls.size(), n, "an impossible code never hits the network") assert_string_contains(String(get_signal_parameters(r, "failed")[0]), "room code")
func test_order_ips_puts_own_slash24_first() -> void: assert_eq(LanRegistry.order_ips(["10.0.0.9", "192.168.1.20", "10.8.0.2"], ["192.168.1.33"]), ["192.168.1.20", "10.0.0.9", "10.8.0.2"] as Array[String]) assert_eq(LanRegistry.order_ips(["10.0.0.9"], []), ["10.0.0.9"] as Array[String])
func test_private_ipv4s_and_normalize_code() -> void: assert_eq(LanRegistry.private_ipv4s(["8.8.8.8", "10.1.2.3", "172.20.0.1", "172.40.0.1", "192.168.0.9", "fe80::1"]), ["10.1.2.3", "172.20.0.1", "192.168.0.9"] as Array[String]) assert_eq(LanRegistry.normalize_code("abcde"), "ABCDE") assert_eq(LanRegistry.normalize_code("ABCD0"), "") assert_eq(LanRegistry.normalize_code(5), "")- Step 2: Run to verify it fails
Run: godot --headless --path . --import (once per worktree), then
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_lan_registry.gd -gexit 2>&1 | tail -20
Expected: FAIL — LanRegistry not found / parse error.
- Step 3: Write
net/lan_registry.gd
class_name LanRegistryextends Node
# LAN co-op easy join (2026-10-07, dev-only). Talks to the control Worker's /mp/* host registry so a# second device can join with no typing: the host registers {build, port, name, private IPs} and gets# a 5-letter room code (refreshed every 15 s, gone 60 s after the last refresh); a joiner lists hosts# seen from the same public network (`nearby`) or resolves a code. HTTP only, never touches the sim.# Failures are quiet: the beacon + typed IP keep working offline. Spec:# docs/superpowers/specs/2026-10-07-lan-easy-join-design.md. main.gd constructs this only in a dev build.
signal code_ready(code: String)signal nearby(hosts: Array[Dictionary])signal resolved(host: Dictionary)signal failed(reason: String)
const REFRESH_S := 15.0const NEARBY_S := 3.0const CODE_LEN := 5const CODE_ALPHABET := "ABCDEFGHJKMNPQRSTUVWXYZ23456789" # = control/src/mp.js MP_CODE_ALPHABET
# Off under GUT (tests/gut_pre_run.gd): the default requester then answers status 0 at once.static var io_enabled: bool = true
# requester.call(method, path, body, done); done.call(status, data). status 0 = transport failure.var requester: Callablevar host_code := ""
var _hosting := falsevar _host_body: Dictionary = {}var _host_busy := falsevar _host_accum := 0.0var _nearby_on := falsevar _nearby_busy := falsevar _nearby_accum := 0.0
func _init() -> void: requester = _http
func _process(dt: float) -> void: tick(dt)
func tick(dt: float) -> void: if _hosting: _host_accum += dt if _host_accum >= REFRESH_S: _host_accum = 0.0 _send_host() if _nearby_on: _nearby_accum += dt if _nearby_accum >= NEARBY_S: _nearby_accum = 0.0 refresh_nearby()
# ── Host ──────────────────────────────────────────────────────────────────────────────────────────func host_start(build: int, port: int, name: String, lan_ips: Array[String]) -> void: _hosting = true host_code = "" _host_accum = 0.0 _host_body = {"build": build, "port": port, "name": name.left(24), "lan_ips": lan_ips} _send_host()
func is_hosting() -> bool: return _hosting
func host_stop() -> void: if _hosting and host_code != "": _leave(host_code) _hosting = false host_code = ""
func _send_host() -> void: if _host_busy: return _host_busy = true var body := _host_body.duplicate() if host_code != "": body["code"] = host_code body["key"] = ControlClient.CONTROL_KEY requester.call(HTTPClient.METHOD_POST, "/mp/host", body, _on_host)
func _on_host(status: int, data: Variant) -> void: _host_busy = false if status != 200 or not (data is Dictionary): return var code := normalize_code((data as Dictionary).get("code", "")) if code == "": return if not _hosting: _leave(code) # stopped while the registration was in flight: release the code at once return if code != host_code: host_code = code code_ready.emit(code)
func _leave(code: String) -> void: requester.call(HTTPClient.METHOD_POST, "/mp/host/leave", {"code": code, "key": ControlClient.CONTROL_KEY}, func(_s: int, _d: Variant) -> void: pass)
# ── Join ──────────────────────────────────────────────────────────────────────────────────────────func start_nearby() -> void: _nearby_on = true _nearby_accum = 0.0 refresh_nearby()
func stop_nearby() -> void: _nearby_on = false
func refresh_nearby() -> void: if _nearby_busy: return _nearby_busy = true requester.call(HTTPClient.METHOD_GET, "/mp/nearby?key=%s" % ControlClient.CONTROL_KEY.uri_encode(), {}, _on_nearby)
func _on_nearby(status: int, data: Variant) -> void: _nearby_busy = false if not _nearby_on or status != 200 or not (data is Dictionary): return var raw: Variant = (data as Dictionary).get("hosts", []) if not (raw is Array): return var out: Array[Dictionary] = [] for item: Variant in raw: var h := sanitize_host(item) if not h.is_empty(): out.append(h) nearby.emit(out)
func resolve_code(code: String) -> void: var c := normalize_code(code) if c == "": failed.emit("'%s' is not a room code (5 letters/digits, shown on the host)." % code.strip_edges()) return requester.call(HTTPClient.METHOD_GET, "/mp/code/%s?key=%s" % [c, ControlClient.CONTROL_KEY.uri_encode()], {}, func(status: int, data: Variant) -> void: _on_resolve(c, status, data))
func _on_resolve(code: String, status: int, data: Variant) -> void: if status == 200: var h := sanitize_host(data) if not h.is_empty(): resolved.emit(h) return if status == 404: failed.emit("No game with code %s (it may have ended)." % code) else: failed.emit("Couldn't reach the matchmaker. Enter the host's IP instead.")
# ── Pure helpers ──────────────────────────────────────────────────────────────────────────────────static func normalize_code(v: Variant) -> String: if not (v is String): return "" var c := (v as String).strip_edges().to_upper() if c.length() != CODE_LEN: return "" for ch in c: if not CODE_ALPHABET.contains(ch): return "" return c
# {code, name, build, port, lan_ips: Array[String]} from a Worker row, or {} if it is not one.static func sanitize_host(d: Variant) -> Dictionary: if not (d is Dictionary): return {} var h: Dictionary = d var code := normalize_code(h.get("code", "")) if code == "": return {} var ips: Array[String] = [] var raw: Variant = h.get("lan_ips", []) if raw is Array: for a: Variant in raw: if a is String and (a as String).is_valid_ip_address() and not (a as String).contains(":"): ips.append(a as String) var name_v: Variant = h.get("name", "") var build_v: Variant = h.get("build", 0) var port_v: Variant = h.get("port", 0) return { "code": code, "name": (name_v as String).left(24) if name_v is String else "Device", "build": int(build_v) if (build_v is int or build_v is float) else 0, "port": int(port_v) if (port_v is int or port_v is float) else 0, "lan_ips": ips, }
# The host's IPs with any on one of our own /24s first (a VPN / second NIC address last).static func order_ips(host_ips: Array, own_ips: Array) -> Array[String]: var nets := {} for o: Variant in own_ips: nets[_slash24(String(o))] = true var same: Array[String] = [] var rest: Array[String] = [] for a: Variant in host_ips: var s := String(a) if nets.has(_slash24(s)): same.append(s) else: rest.append(s) same.append_array(rest) return same
static func private_ipv4s(ips: Array) -> Array[String]: var out: Array[String] = [] for a: Variant in ips: var s := String(a) if s.contains(":") or not s.is_valid_ip_address(): continue var o := s.split(".") var a0 := o[0].to_int() var a1 := o[1].to_int() if a0 == 10 or (a0 == 172 and a1 >= 16 and a1 <= 31) or (a0 == 192 and a1 == 168): out.append(s) return out
static func _slash24(a: String) -> String: return a.substr(0, a.rfind("."))
# Default requester: one short-lived HTTPRequest per call, so host refresh, nearby and resolve never# block each other (an HTTPRequest refuses a second request while busy).func _http(method: int, path: String, body: Dictionary, done: Callable) -> void: if not io_enabled or not is_inside_tree(): done.call(0, null) return var h := HTTPRequest.new() h.timeout = 6.0 add_child(h) h.request_completed.connect(func(result: int, code: int, _hd: PackedStringArray, bytes: PackedByteArray) -> void: h.queue_free() if result != HTTPRequest.RESULT_SUCCESS: done.call(0, null) return var parsed: Variant = JSON.parse_string(bytes.get_string_from_utf8()) done.call(code, parsed)) var payload := JSON.stringify(body) if method == HTTPClient.METHOD_POST else "" var err := h.request(ControlClient.ENDPOINT + path, PackedStringArray(["Content-Type: text/plain"]), method, payload) if err != OK: h.queue_free() done.call(0, null)Add to tests/gut_pre_run.gd run(): LanRegistry.io_enabled = false # LAN easy join: no Worker traffic from a test run.
- Step 4: Run the test
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_lan_registry.gd -gexit 2>&1 | tail -20
Expected: all 9 tests pass. If the typed-array equality asserts (as Array[String]) misbehave, compare with assert_eq(str(a), str(b)) rather than loosening the production types.
- Step 5: Commit
git add net/lan_registry.gd net/lan_registry.gd.uid tests/test_lan_registry.gd tests/gut_pre_run.gdgit commit -m "feat(net): LanRegistry — room-code host registration, nearby list, code resolve (dev LAN co-op)"(The .uid is generated by the import pass; add it if it exists.)
Task 3: LanLobby — code display, merged tap-to-join list, code entry, collapsed IP
Section titled “Task 3: LanLobby — code display, merged tap-to-join list, code entry, collapsed IP”Files:
- Modify:
ui/lan_lobby.gd(whole file is 118 lines; changes below) - Create:
tests/test_lan_lobby.gd
Interfaces:
-
Consumes:
LanRegistry.order_ips,LanRegistry.normalize_code(Task 2); beacon rows{ip, build, port, name, seen}fromLanBeacon.hosts(). -
Produces:
- new signals
join_host_requested(ips: Array[String]),code_requested(code: String); existingcancelled,join_requested(ip: String)stay (typed-IP path). static func merge_hosts(beacon: Array[Dictionary], registry: Array[Dictionary], own_ips: Array[String]) -> Array[Dictionary]— rows{name: String, build: int, ips: Array[String], code: String}; registry rows first (IPs ordered byorder_ips), then beacon rows whose IP no earlier row carries.func set_hosts(rows: Array[Dictionary], own_build: int) -> void— CHANGED signature (was beacon rows). Rows withbuild != own_buildare disabled buttons reading"<name> · needs BUILD <build>".func show_hosting(ips: Array[String], port: int, code: String = "") -> void,func set_code(code: String) -> voidfunc show_joining(default_ip: String) -> void— IP row collapsed unlessdefault_ip != "".func host_count() -> intunchanged.
- new signals
-
Step 1: Write the failing test —
tests/test_lan_lobby.gd:
extends GutTest
# LanLobby join list + code entry (LAN co-op easy join, 2026-10-07), through the real CanvasLayer.
func _lobby() -> LanLobby: var l := LanLobby.new() add_child_autofree(l) return l
func _beacon(ip: String, build: int, name: String) -> Dictionary: return {"ip": ip, "build": build, "port": 47610, "name": name, "seen": 0}
func _reg_host(code: String, build: int, ips: Array[String]) -> Dictionary: return {"code": code, "name": "Mac", "build": build, "port": 47610, "lan_ips": ips}
func test_merge_dedupes_by_ip_registry_first_with_ordered_ips() -> void: var beacon: Array[Dictionary] = [_beacon("192.168.1.20", 265, "Mac"), _beacon("192.168.1.40", 265, "ATV")] var reg: Array[Dictionary] = [_reg_host("ABCDE", 265, ["10.8.0.2", "192.168.1.20"] as Array[String])] var own: Array[String] = ["192.168.1.33"] var rows := LanLobby.merge_hosts(beacon, reg, own) assert_eq(rows.size(), 2, "the Mac heard twice is one row") assert_eq(rows[0]["code"], "ABCDE") assert_eq(rows[0]["ips"], ["192.168.1.20", "10.8.0.2"] as Array[String], "own /24 first") assert_eq(rows[1]["ips"], ["192.168.1.40"] as Array[String]) assert_eq(rows[1]["code"], "")
func test_set_hosts_disables_other_builds_and_emits_ips() -> void: var l := _lobby() watch_signals(l) var rows: Array[Dictionary] = [ {"name": "Mac", "build": 265, "ips": ["192.168.1.20", "10.8.0.2"] as Array[String], "code": "ABCDE"}, {"name": "Old", "build": 250, "ips": ["192.168.1.41"] as Array[String], "code": ""}] l.set_hosts(rows, 265) assert_eq(l.host_count(), 2) var ok: Button = l._hosts_box.get_child(0) var old: Button = l._hosts_box.get_child(1) assert_false(ok.disabled) assert_string_contains(ok.text, "ABCDE") assert_true(old.disabled) assert_string_contains(old.text, "needs BUILD 250") ok.pressed.emit() assert_signal_emitted_with_parameters(l, "join_host_requested", [["192.168.1.20", "10.8.0.2"]])
func test_set_hosts_rebuilds_only_on_change() -> void: var l := _lobby() var rows: Array[Dictionary] = [{"name": "Mac", "build": 265, "ips": ["192.168.1.20"] as Array[String], "code": ""}] l.set_hosts(rows, 265) var first: Node = l._hosts_box.get_child(0) l.set_hosts(rows.duplicate(true), 265) assert_eq(l._hosts_box.get_child(0), first, "same list keeps the same button (focus survives)")
func test_code_entry_normalises_or_explains() -> void: var l := _lobby() watch_signals(l) l.show_joining("") l._code_edit.text = " abcde " l._submit_code() assert_signal_emitted_with_parameters(l, "code_requested", ["ABCDE"]) l._code_edit.text = "AB" l._submit_code() assert_signal_emit_count(l, "code_requested", 1) assert_string_contains(l._status.text, "room code")
func test_ip_row_collapsed_unless_prefilled_and_toggle_opens_it() -> void: var l := _lobby() l.show_joining("") assert_false(l._ip_row.visible, "typed IP is the last resort: collapsed") assert_true(l._ip_toggle.visible) l._ip_toggle.pressed.emit() assert_true(l._ip_row.visible) l.show_joining("192.168.1.20") assert_true(l._ip_row.visible, "a remembered IP opens it") assert_eq(l._ip_edit.text, "192.168.1.20")
func test_hosting_shows_the_code_once_it_arrives() -> void: var l := _lobby() var ips: Array[String] = ["192.168.1.20"] l.show_hosting(ips, 47610) assert_false(l._code_row.visible, "join widgets hidden on the host") assert_false(l._ip_toggle.visible) assert_string_contains(l._status.text, "192.168.1.20") l.set_code("ABCDE") assert_string_contains(l._status.text, "ABCDE") assert_string_contains(l._status.text, "192.168.1.20")- Step 2: Run to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_lan_lobby.gd -gexit 2>&1 | tail -20
Expected: FAIL (no merge_hosts, _code_edit, _ip_toggle, _code_row, set_code).
- Step 3: Implement in
ui/lan_lobby.gd
Update the header comment to: # ... the join view lists hosts from the control Worker's registry (same public network) merged with the beacon (Mac/Android/tvOS), then a room-code box, then a collapsed manual IP box (LAN co-op easy join, 2026-10-07).
New signals and fields (beside the existing ones):
signal join_host_requested(ips: Array[String])signal code_requested(code: String)
var _code_row: HBoxContainervar _code_edit: LineEditvar _ip_toggle: Buttonvar _host_ips: Array[String] = []var _host_port := 0var _host_code := ""In _ready(), between box.add_child(_hosts_box) and the _ip_row block, add the code row and the IP toggle; keep the existing _ip_row creation after them:
_code_row = HBoxContainer.new() TokenBind.theme_constant(_code_row, &"separation", func() -> int: return Tokens.space(2)) _code_edit = LineEdit.new() _code_edit.placeholder_text = "ROOM CODE" _code_edit.max_length = LanRegistry.CODE_LEN _code_edit.custom_minimum_size = Vector2(200, 48) TokenBind.theme_font_size(_code_edit, &"font_size", &"font.lg") _code_edit.text_submitted.connect(func(_t: String) -> void: _submit_code()) _code_row.add_child(_code_edit) _code_row.add_child(_button("Join code", _submit_code)) box.add_child(_code_row) _ip_toggle = _button("Enter IP…", func() -> void: _ip_row.visible = true _ip_toggle.visible = false _ip_edit.grab_focus.call_deferred()) box.add_child(_ip_toggle)(_ip_row is assigned after this lambda is created; the lambda only runs on press, so it reads the assigned field.)
Replace show_hosting, show_joining, set_hosts and add set_code, merge_hosts, _submit_code, _render_hosting:
func show_hosting(ips: Array[String], port: int, code: String = "") -> void: _title.text = "LAN CO-OP · HOSTING" _host_ips = ips _host_port = port _host_code = code _code_row.visible = false _ip_toggle.visible = false _ip_row.visible = false _render_hosting() _cancel.grab_focus.call_deferred() # a controller / Apple TV host needs a focused control to reach Cancel
func set_code(code: String) -> void: _host_code = code _render_hosting()
func _render_hosting() -> void: var where := ", ".join(_host_ips) if not _host_ips.is_empty() else "(no LAN address found)" var code_line := "Room code: %s" % _host_code if _host_code != "" else "Room code: (registering…)" set_status("%s\nWaiting for a player to join: they pick this device in their list or type the code.\nThis device: %s (port %d)" % [code_line, where, _host_port])
func show_joining(default_ip: String) -> void: _title.text = "LAN CO-OP · JOIN" set_status("Pick a host below, or type its room code. An IP works too (Enter IP).") _code_row.visible = true _ip_edit.text = default_ip _ip_row.visible = default_ip != "" _ip_toggle.visible = default_ip == "" _code_edit.grab_focus.call_deferred()
# One row per host: registry hosts first (IPs on our own /24 first), then beacon hosts whose IP no# earlier row already carries. Row = {name, build, ips: Array[String], code}.static func merge_hosts(beacon: Array[Dictionary], registry: Array[Dictionary], own_ips: Array[String]) -> Array[Dictionary]: var out: Array[Dictionary] = [] var seen := {} for h: Dictionary in registry: var raw: Variant = h.get("lan_ips", []) var fresh: Array[String] = [] for ip: String in LanRegistry.order_ips(raw if raw is Array else [], own_ips): if not seen.has(ip): seen[ip] = true fresh.append(ip) if fresh.is_empty(): continue var name_v: Variant = h.get("name", "") var build_v: Variant = h.get("build", 0) var code_v: Variant = h.get("code", "") out.append({"name": String(name_v), "build": int(build_v), "ips": fresh, "code": String(code_v)}) for h: Dictionary in beacon: var ip := String(h["ip"]) if seen.has(ip): continue seen[ip] = true var one: Array[String] = [ip] out.append({"name": String(h["name"]), "build": int(h["build"]), "ips": one, "code": ""}) return out
# Rebuilt only when the list changes, so focus stays put.func set_hosts(rows: Array[Dictionary], own_build: int) -> void: var key := "%s|%d" % [str(rows), own_build] if key == _shown_hosts: return _shown_hosts = key for c in _hosts_box.get_children(): c.queue_free() _hosts_box.remove_child(c) # host_count() must not count rows already on their way out for r: Dictionary in rows: var ips: Array[String] = r["ips"] var name := String(r["name"]) var build := int(r["build"]) var code := String(r["code"]) if build != own_build: var b := _button("%s · needs BUILD %d" % [name, build], func() -> void: pass) b.disabled = true b.focus_mode = Control.FOCUS_NONE _hosts_box.add_child(b) continue var tag := " · %s" % code if code != "" else "" _hosts_box.add_child(_button("Join %s · %s%s · BUILD %d" % [name, ips[0], tag, build], func() -> void: join_host_requested.emit(ips)))
func _submit_code() -> void: var c := LanRegistry.normalize_code(_code_edit.text) if c == "": set_status("'%s' is not a room code (5 letters/digits, shown on the host)." % _code_edit.text.strip_edges()) return code_requested.emit(c)Note: the old set_hosts used queue_free() alone, which left children counted by host_count() until the next frame; remove_child fixes that for the test and for real use.
- Step 4: Fix the one existing caller so the project parses
main.gd _lan_pump (~L3719) calls _net_lobby.set_hosts(_net_beacon.hosts()). Change it to the new signature for now (Task 4 replaces it with the registry merge):
var none: Array[Dictionary] = [] _net_lobby.set_hosts(LanLobby.merge_hosts(_net_beacon.hosts(), none, LanBeacon.local_ipv4()), Sim_Const.BUILD)Grep for any other caller first: git grep -n "set_hosts(\|show_hosting(\|show_joining(" -- '*.gd' — the only callers today are in main.gd (show_hosting/show_joining keep compatible signatures).
- Step 5: Run the lobby test and the existing LAN tests
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_lan_lobby.gd -gexit 2>&1 | tail -20, then the same with -gselect=test_lan_ (matches test_lan_beacon, test_lan_coop_main, test_lan_lobby, test_lan_registry).
Expected: all pass.
- Step 6: Commit
git add ui/lan_lobby.gd tests/test_lan_lobby.gd tests/test_lan_lobby.gd.uid main.gdgit commit -m "feat(ui): LAN lobby — merged tap-to-join list, room-code entry, collapsed IP, host shows its code"Task 4: Wire it into main.gd (host registration, nearby, code join, multi-IP fallover, console code, status hosting)
Section titled “Task 4: Wire it into main.gd (host registration, nearby, code join, multi-IP fallover, console code, status hosting)”Files:
- Modify:
main.gd— LAN section (~L3588-3720: vars,_lan_dev_command,_lan_open,_lan_host_with,_lan_join,_lan_close,_lan_pump,_start_net_run),build_status(~L3299, BOTH return dicts), beacon name (L3666) - Create:
tests/test_lan_easy_join_main.gd - Modify:
docs/architecture/coop-and-pilots.md§ LAN co-op (thenet/lan_beacon.gdandmain.gdLAN bullets ~L112-125)
Interfaces:
-
Consumes:
LanRegistry(Task 2: signals,host_start/host_stop/start_nearby/stop_nearby/resolve_code,order_ips,private_ipv4s),LanLobby(Task 3:join_host_requested,code_requested,merge_hosts,set_hosts(rows, own_build),set_code). -
Produces:
- Remote Console command:
{kind:'lan_coop', action:'join', code:'ABCDE'}(code optional;ipstill accepted). build_status()["hosting"]:{"code": String, "ips": Array[String]}while this device’s host lobby is open (before the run starts), elsenull. Consumed by Task 5.- Test seams:
var _net_registry: LanRegistry(a test assigns a stub-requester registry before_lan_open),var _net_new_transport: Callable(returns anENetTransport; tests return a fake).
- Remote Console command:
-
Step 1: Write the failing test —
tests/test_lan_easy_join_main.gd:
extends GutTest
# LAN co-op easy join through the REAL main.tscn (2026-10-07): the registry (stub requester, no# network) feeds the lobby list, a room code resolves into ordered ENet joins (fake transport), a# host registers and reports `hosting` on the console status. Pure wiring: no sim is touched.
class FakeJoin extends ENetTransport: var address := "" func join(addr: String, _port: int) -> Error: address = addr return OK
var calls: Array[Dictionary] = []
func _stub(method: int, path: String, body: Dictionary, done: Callable) -> void: calls.append({"method": method, "path": path, "body": body, "done": done})
func _boot() -> Node: calls.clear() var m: Node = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame var reg := LanRegistry.new() reg.requester = _stub m.add_child(reg) m._net_registry = reg m._net_new_transport = func() -> ENetTransport: return FakeJoin.new() return m
func _last(path_prefix: String) -> Dictionary: for i in range(calls.size() - 1, -1, -1): if String(calls[i]["path"]).begins_with(path_prefix): return calls[i] return {}
func _host(code: String, build: int, ips: Array) -> Dictionary: return {"code": code, "name": "Mac · macOS", "build": build, "port": 47610, "lan_ips": ips}
func test_host_registers_shows_the_code_and_reports_hosting_until_closed() -> void: var m: Node = await _boot() m._lan_open("host") m._lan_host_with(LoopbackTransport.make_pair(0, 0, 0, 5)[0]) var reg_call := _last("/mp/host") assert_false(reg_call.is_empty(), "the host registered") assert_eq((reg_call["body"] as Dictionary)["build"], Sim_Const.BUILD) assert_eq((reg_call["body"] as Dictionary)["port"], NetCoopRun.PORT) (reg_call["done"] as Callable).call(200, {"code": "ABCDE", "ttl_s": 60}) assert_string_contains(m._net_lobby._status.text, "ABCDE") var st: Dictionary = m.build_status() assert_eq((st["hosting"] as Dictionary)["code"], "ABCDE") m._lan_close() assert_false(_last("/mp/host/leave").is_empty(), "closing the lobby releases the code") assert_null(m.build_status()["hosting"])
func test_join_lists_registry_hosts_with_build_gating() -> void: var m: Node = await _boot() m._lan_open("join") var near := _last("/mp/nearby") assert_false(near.is_empty(), "the join lobby polls nearby hosts") (near["done"] as Callable).call(200, {"hosts": [ _host("ABCDE", Sim_Const.BUILD, ["10.9.9.9"]), _host("FGHJK", 1, ["10.9.9.10"])]}) m._lan_pump() assert_eq(m._net_lobby.host_count(), 2) var old: Button = m._net_lobby._hosts_box.get_child(1) assert_true(old.disabled, "a different BUILD is listed but not joinable")
func test_a_room_code_joins_its_ips_in_order_and_falls_over_on_timeout() -> void: var m: Node = await _boot() m._lan_open("join") m._net_lobby.code_requested.emit("ABCDE") var res := _last("/mp/code/ABCDE") assert_false(res.is_empty(), "the code was looked up") (res["done"] as Callable).call(200, _host("ABCDE", Sim_Const.BUILD, ["10.9.9.9", "10.9.9.8"])) assert_eq(m._net_last_ip, "10.9.9.9", "first IP tried first") assert_eq(m._net_join_queue, ["10.9.9.8"] as Array[String]) m._net.session._end("host_timeout") m._lan_pump() assert_eq(m._net_last_ip, "10.9.9.8", "a timed-out IP falls over to the host's next one") m._net.session._end("host_timeout") m._lan_pump() assert_string_contains(m._net_lobby._status.text, "host_timeout", "out of IPs: the reason shows")
func test_a_code_for_another_build_is_refused_without_connecting() -> void: var m: Node = await _boot() m._lan_open("join") m._net_lobby.code_requested.emit("ABCDE") (_last("/mp/code/ABCDE")["done"] as Callable).call(200, _host("ABCDE", Sim_Const.BUILD + 7, ["10.9.9.9"])) assert_null(m._net, "no connection attempt") assert_string_contains(m._net_lobby._status.text, "BUILD %d" % (Sim_Const.BUILD + 7))
func test_a_failed_lookup_shows_its_reason() -> void: var m: Node = await _boot() m._lan_open("join") m._net_lobby.code_requested.emit("ABCDE") (_last("/mp/code/ABCDE")["done"] as Callable).call(0, null) assert_string_contains(m._net_lobby._status.text, "matchmaker")
func test_console_join_with_a_code_resolves_it_on_the_device() -> void: var m: Node = await _boot() assert_true(m.apply_dev_command({"kind": "lan_coop", "action": "join", "code": "abcde"})) assert_false(_last("/mp/code/ABCDE").is_empty())
func test_tapping_a_listed_host_joins_its_ips() -> void: var m: Node = await _boot() m._lan_open("join") var ips: Array[String] = ["10.9.9.9", "10.9.9.8"] m._net_lobby.join_host_requested.emit(ips) assert_eq(m._net_last_ip, "10.9.9.9") assert_eq(m._net_join_queue, ["10.9.9.8"] as Array[String])- Step 2: Run to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_lan_easy_join_main.gd -gexit 2>&1 | tail -30
Expected: FAIL (_net_registry, _net_new_transport, _net_join_queue don’t exist; no /mp/ calls).
- Step 3: Implement in
main.gd
Add to the LAN vars block (after var _net_last_ip := ""):
var _net_registry: LanRegistry = null # LAN easy join (2026-10-07): room code + nearby list; dev builds onlyvar _net_registry_hosts: Array[Dictionary] = []var _net_join_queue: Array[String] = [] # a host's remaining IPs, tried in order on host_timeoutvar _net_host_code := ""var _net_new_transport: Callable = func() -> ENetTransport: return ENetTransport.new() # test seamAdd helpers (after _net_factory):
# The registry node, created on first use in a dev build with networking allowed. A test assigns one# with a stub requester first; that one is used even headless.func _lan_reg() -> LanRegistry: if _net_registry == null and net_allowed and BuildConfig.dev_tools(): _net_registry = LanRegistry.new() add_child(_net_registry) if _net_registry != null and not _net_registry.code_ready.is_connected(_on_lan_code): _net_registry.code_ready.connect(_on_lan_code) _net_registry.nearby.connect(_on_lan_nearby) _net_registry.resolved.connect(_on_lan_resolved) _net_registry.failed.connect(_on_lan_failed) return _net_registry
static func _lan_display_name() -> String: return ("%s · %s" % [OS.get_model_name(), OS.get_name()]).left(24)
func _on_lan_code(code: String) -> void: _net_host_code = code if _net_lobby != null and is_instance_valid(_net_lobby): _net_lobby.set_code(code)
func _on_lan_nearby(hosts: Array[Dictionary]) -> void: _net_registry_hosts = hosts
func _on_lan_resolved(host: Dictionary) -> void: if _net_lobby == null: return if int(host["build"]) != Sim_Const.BUILD: _net_lobby.set_status("That game is BUILD %d; this device is BUILD %d. Install the same build on both." % [int(host["build"]), Sim_Const.BUILD]) return var ips: Array[String] = host["lan_ips"] _lan_join_ips(LanRegistry.order_ips(ips, LanBeacon.local_ipv4()))
func _on_lan_failed(reason: String) -> void: if _net_lobby != null: _net_lobby.set_status(reason)
func _lan_join_code(code: String) -> void: var reg := _lan_reg() if reg == null: if _net_lobby != null: _net_lobby.set_status("Room codes need the matchmaker, which this build can't reach. Enter the host's IP.") return if _net_lobby != null: _net_lobby.set_status("Looking up %s…" % code.strip_edges().to_upper()) reg.resolve_code(code)
# Try a host's IPs in order: the first now, the rest on host_timeout (see _lan_pump).func _lan_join_ips(ips: Array[String]) -> void: if ips.is_empty(): if _net_lobby != null: _net_lobby.set_status("That host has no LAN address. Enter its IP.") return _lan_join(ips[0], false) _net_join_queue = ips.slice(1)
# `hosting` on the console status: the open host lobby's code + IPs, so the console can pre-fill the# other device's Join box. null once the run starts or the lobby closes.func _lan_hosting_status() -> Variant: if _net_lobby == null or _net == null or not _net.is_host() or _net_started: return null return {"code": _net_host_code, "ips": LanRegistry.private_ipv4s(LanBeacon.local_ipv4())}_lan_dev_command join branch:
"join": _lan_open("join", String(cmd.get("ip", "")), String(cmd.get("code", "")))_lan_open — signature func _lan_open(action: String, ip: String = "", code: String = "") -> void:; after the existing join_requested.connect(_lan_join) add:
_net_lobby.join_host_requested.connect(_lan_join_ips) _net_lobby.code_requested.connect(_lan_join_code)In the host branch change the beacon name to _lan_display_name(). Replace the join branch with:
else: _net_lobby.show_joining(ip if ip != "" else _net_last_ip) var reg := _lan_reg() if reg != null: reg.start_nearby() if code != "": _lan_join_code(code) if not net_allowed: if code == "": _net_lobby.set_status("Networking is off in headless test runs.") return _net_beacon = LanBeacon.new() if _net_beacon.start_listen(NetCoopRun.PORT + 1) != OK: _net_beacon = null # someone else holds the beacon port (a second instance): IP only if ip != "" and code == "": _lan_join(ip)_lan_host_with — register after creating the session (this is also the test seam):
func _lan_host_with(t: NetTransport) -> void: _net = NetCoopRun.create_host(t, randi(), _net_factory, Sim_Const.BUILD, RemoteConfig.active_hash) _net_started = false var reg := _lan_reg() if reg != null: reg.host_start(Sim_Const.BUILD, NetCoopRun.PORT, _lan_display_name(), LanRegistry.private_ipv4s(LanBeacon.local_ipv4()))_lan_join — new signature, transport seam, queue reset for a typed/single IP:
func _lan_join(ip: String, clear_queue: bool = true) -> void: if clear_queue: _net_join_queue.clear() _net_last_ip = ip var t: ENetTransport = _net_new_transport.call() var err := t.join(ip, NetCoopRun.PORT) ... # rest unchanged_lan_close — add before _net_lobby teardown:
if _net_registry != null: _net_registry.host_stop() _net_registry.stop_nearby() _net_registry_hosts.clear() _net_join_queue.clear() _net_host_code = ""_lan_pump — replace the beacon block and the not-started elif:
func _lan_pump() -> void: var now := Time.get_ticks_msec() if _net_beacon != null: _net_beacon.poll(now) if _net_lobby != null and (_net == null or not _net.is_host()): var heard: Array[Dictionary] = _net_beacon.hosts() if _net_beacon != null else ([] as Array[Dictionary]) _net_lobby.set_hosts(LanLobby.merge_hosts(heard, _net_registry_hosts, LanBeacon.local_ipv4()), Sim_Const.BUILD) if _net == null: return if not _net_started: _net.frame(InputState.new(), now, true) # handshake only: nothing ticks before the run starts if _net.is_running(): _start_net_run() elif _net.is_ended() and _net_lobby != null: if _net.session.end_reason == "host_timeout" and not _net_join_queue.is_empty(): _lan_join(_net_join_queue.pop_front(), false) # the host's next IP (VPN / second NIC first tried) else: _net_lobby.set_status("Could not join: %s" % _net.session.end_reason) return if _net_run(): _net_physics(now)Note: _net.frame on an already-ended session must not throw; the test calls _lan_pump() right after _end, which reaches the elif — if frame() on an ENDED session misbehaves, guard the frame call with if not _net.is_ended():.
_start_net_run — after closing the beacon add:
if _net_registry != null: _net_registry.host_stop() # the room is full (one guest): drop the code _net_registry.stop_nearby()build_status — add "hosting": _lan_hosting_status(), to BOTH returned dictionaries (the sim == null early return and the full one).
- Step 4: Run the new and existing LAN tests
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_lan_ -gexit 2>&1 | tail -30
Expected: all pass, including test_lan_coop_main.gd unchanged (it never injects a registry, and net_allowed is false headless, so _lan_reg() returns null there).
- Step 5: Update
docs/architecture/coop-and-pilots.md§ LAN co-op
Add a bullet after the net/lan_beacon.gd one:
- **Easy join (2026-10-07, dev-only):** `net/lan_registry.gd` (`LanRegistry`) talks to the control Worker's `/mp/*` registry (CONTROL_KEY-gated, salted-hash "same public network" match, 60 s TTL; `control/src/mp.js`). The host registers and shows a 5-letter room code (`LanLobby.set_code`); the joiner's lobby lists registry + beacon hosts merged by IP (`LanLobby.merge_hosts`, other BUILDs greyed), then a code box, then a collapsed IP box. A host's private IPs are tried in order (own /24 first) and fall over on `host_timeout` (`_net_join_queue`). Worker unreachable = the old beacon + typed-IP behaviour. The console status carries `hosting: {code, ips}` while a host lobby is open. Spec: `docs/superpowers/specs/2026-10-07-lan-easy-join-design.md`.In the main.gd LAN bullet change “lan_coop command (host / join ip / leave)” to “lan_coop command (host / join ip|code / leave)”.
- Step 6: Full gate
Run: bash scripts/gate.sh
Expected: exit 0; determinism baselines unmoved (nothing in /sim changed). Then git status — restore project.godot / audio/bus_layout.tres if anything rewrote them.
- Step 7: Commit
git add main.gd tests/test_lan_easy_join_main.gd tests/test_lan_easy_join_main.gd.uid docs/architecture/coop-and-pilots.mdgit commit -m "feat(lan): easy join in main — room code host registration, nearby list, code join with IP fallover"Task 5: Remote Console — Join box takes a code or IP, hosting line, last-host prefill
Section titled “Task 5: Remote Console — Join box takes a code or IP, hosting line, last-host prefill”Files:
- Modify:
control/src/panel/markup.js(LAN card, L52-61),control/src/panel/client.js(applyStatusL30-40 + new LAN block),control/src/panel/tooltips.js(run-lan-join, L22),control/src/whats_new.js(thebuild: nullentry),control/test/_panel-harness.mjs(expose new fns, injectablewindow) - Create:
control/test/panel-lan.test.mjs
Interfaces:
-
Consumes: Task 4’s
status.hosting({code, ips}or null) andlan_coopcodefield. -
Produces: panel functions
lanJoin(),renderLan(s); DOM idslan-hosting,lan-msg; localStorage keydc.lan.lastHost. -
Step 1: Extend the harness
In control/test/_panel-harness.mjs: add lanJoin, renderLan to the returned-names string (e.g. after applyStatus,), and pass opts.window ?? {} instead of the literal {} as the window argument.
- Step 2: Write the failing test —
control/test/panel-lan.test.mjs:
// LAN co-op easy join (2026-10-07): the console's Join box takes a room code or an IP, refuses an// empty box, shows the selected device's hosting line and pre-fills the other device's box.import { test } from 'node:test';import assert from 'node:assert/strict';import { loadPanel } from './_panel-harness.mjs';import { panelHtml } from '../src/panel.html.js';import { CLIENT } from '../src/panel/client.js';
const memStore = () => { const m = new Map(); return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)) }; };const box = (p) => p.document.getElementById('lan-ip');const text = (p, id) => p.document.getElementById(id).textContent;
test('the Join box placeholder can no longer pass for a value', () => { const html = panelHtml(); assert.match(html, /id="lan-ip" placeholder="code or IP"/); assert.ok(!html.includes('placeholder="192.168.1.20"')); assert.match(html, /id="lan-join"[^>]*onclick="lanJoin[(][)]"/);});
test('Join with an empty box sends nothing and says why', async () => { const p = loadPanel(); box(p).value = ' '; await p.api.lanJoin(); assert.deepEqual(p.sent, []); assert.match(text(p, 'lan-msg'), /code or IP/);});
test('a code-shaped value sends {kind:lan_coop, action:join, code} upper-cased', async () => { const p = loadPanel(); box(p).value = ' abcde '; await p.api.lanJoin(); assert.deepEqual(p.sent, [{ kind: 'lan_coop', action: 'join', code: 'ABCDE' }]);});
test('a dotted quad sends ip; junk sends nothing', async () => { const p = loadPanel(); box(p).value = '192.168.1.20'; await p.api.lanJoin(); assert.deepEqual(p.sent, [{ kind: 'lan_coop', action: 'join', ip: '192.168.1.20' }]); box(p).value = 'not a host'; await p.api.lanJoin(); assert.equal(p.sent.length, 1); assert.match(text(p, 'lan-msg'), /not a host/);});
test('hosting shows a line, is remembered, and pre-fills the box for the other device', () => { const p = loadPanel({ window: { localStorage: memStore() } }); p.api.renderLan({ hosting: { code: 'ABCDE', ips: ['192.168.1.20'] } }); assert.equal(text(p, 'lan-hosting'), 'Hosting · ABCDE · 192.168.1.20'); assert.equal(box(p).value, '', 'the host device itself is not pre-filled'); p.api.renderLan({ hosting: null }); // console switched to the other device assert.equal(text(p, 'lan-hosting'), ''); assert.equal(box(p).value, 'ABCDE'); box(p).value = '10.0.0.9'; p.api.renderLan({}); assert.equal(box(p).value, '10.0.0.9', 'never overwrites what was typed');});
test('hosting with no code yet falls back to the IP; no localStorage never throws', () => { const p = loadPanel(); // window = {} (no localStorage) p.api.renderLan({ hosting: { code: '', ips: ['192.168.1.20', { evil: 1 }] } }); assert.equal(text(p, 'lan-hosting'), 'Hosting · (no code yet) · 192.168.1.20'); p.api.renderLan({ hosting: 'junk' }); assert.equal(text(p, 'lan-hosting'), '');});
test('applyStatus drives the LAN card', () => { assert.match(CLIENT, /renderLan[(]s[)]/);});- Step 3: Run to verify it fails
Run: node --test control/test/panel-lan.test.mjs
Expected: FAIL (lanJoin not defined / placeholder mismatch).
- Step 4: Implement
control/src/panel/markup.js LAN card becomes:
<div class="card"> <h3>LAN co-op <small>dev</small></h3> <p class="hint">Host on one device, join from the other: tap it in the in-game list, or type its room code (or IP) here.</p> <div class="row"> <button id="lan-host" data-tip="run-lan-host" onclick="send({kind:'lan_coop',action:'host'})">Host</button> <input id="lan-ip" placeholder="code or IP" aria-label="Host room code or IP"> <button id="lan-join" data-tip="run-lan-join" onclick="lanJoin()">Join</button> <button id="lan-leave" data-tip="run-lan-leave" onclick="send({kind:'lan_coop',action:'leave'})">Leave</button> </div> <p class="hint" id="lan-hosting"></p> <p class="hint" id="lan-msg"></p> </div>control/src/panel/client.js: add renderLan(s); as the last line of applyStatus, and add this block after window.send = send; (NO backslashes, backticks or ${ — string-module rule):
// ── LAN co-op easy join (2026-10-07) ─────────────────────────────// The Join box takes a 5-char room code or a dotted-quad IP; an empty box sends nothing (an empty// join used to open a beacon-only join an iPhone can never complete). The last host seen is kept in// localStorage so switching the console to the other device pre-fills its box.const LAN_CODE = /^[A-Za-z2-9]{5}$/;const LAN_IP = /^[0-9]{1,3}([.][0-9]{1,3}){3}$/;const LAN_STORE = 'dc.lan.lastHost';function lanStore(v){ try{ if(v === undefined) return JSON.parse(window.localStorage.getItem(LAN_STORE) || 'null'); window.localStorage.setItem(LAN_STORE, JSON.stringify(v)); }catch(_){} return null;}function lanJoin(){ const v = String(document.getElementById('lan-ip').value || '').trim(); if(LAN_CODE.test(v)){ setText('lan-msg', ''); return send({kind:'lan_coop',action:'join',code:v.toUpperCase()}); } if(LAN_IP.test(v)){ setText('lan-msg', ''); return send({kind:'lan_coop',action:'join',ip:v}); } setText('lan-msg', v ? 'Not a room code or IP: ' + v : "Type the host's code or IP");}window.lanJoin = lanJoin;function renderLan(s){ const h = s && s.hosting && typeof s.hosting === 'object' ? s.hosting : null; const code = h && typeof h.code === 'string' ? h.code : ''; const ips = h && Array.isArray(h.ips) ? h.ips.filter((x) => typeof x === 'string').slice(0, 4) : []; setText('lan-hosting', h ? ['Hosting', code || '(no code yet)'].concat(ips).join(' · ') : ''); if(h && (code || ips.length)) lanStore({code: code, ips: ips}); const input = document.getElementById('lan-ip'); if(!h && !input.value){ const last = lanStore(); const fill = last && (last.code || (Array.isArray(last.ips) && last.ips[0])); if(typeof fill === 'string') input.value = fill; }}(setText is defined further down in the same script; function declarations hoist, so the order is fine.)
control/src/panel/tooltips.js run-lan-join body: 'Joins the host by its room code (5 letters, shown on the host) or its IP. An empty box sends nothing. Both devices need the same build.'
control/src/whats_new.js: add to the build: null entry’s items (first position):
{ text: 'Dev builds only: LAN co-op is easier to join. The host shows a 5-letter room code; the joining device lists games on the same network to tap, or takes the code, so nobody types an IP (Enter IP is still there as a last resort). Games on another BUILD are listed greyed. In this console the Join box takes a code or an IP, refuses an empty box, and remembers the last host so switching to the other device pre-fills it.', where: 'Debug Settings: Host / Join LAN co-op; this console: LAN co-op card' },- Step 5: Run all node tests
Run: node --test control/test/*.test.mjs
Expected: all pass (including panel-modules.test.mjs’s no-backslash rule and panel-tooltips.test.mjs).
- Step 6: Commit
git add control/src/panel/markup.js control/src/panel/client.js control/src/panel/tooltips.js control/src/whats_new.js control/test/_panel-harness.mjs control/test/panel-lan.test.mjsgit commit -m "feat(console): LAN Join takes a room code or IP, refuses an empty box, pre-fills the last host"Task 6: Review, merge into the overnight branch, rollout (Chris approves before any outward step)
Section titled “Task 6: Review, merge into the overnight branch, rollout (Chris approves before any outward step)”- Step 1: Adversarial review — dispatch
bh-revieweron the integration diff (git diff origin/overnight-2026-10-07...HEAD), one fix round, thenbash scripts/gate.shgreen. - Step 2: Merge into
overnight-2026-10-07from its worktree (.claude/worktrees/overnight-2026-10-07), gate again,git push origin overnight-2026-10-07. - Step 3: Tell Chris and get a go for the outward steps below (Worker secret + D1 migration + deploy are production-facing for the console).
- Step 4: Worker rollout (from the overnight worktree;
rm platform/tvos/build platform/tvos/build-iosfirst if those symlinks exist — re-create them for the device install). Write the commands to a script file andbashit (secrets-guard), never echo the salt:
#!/bin/bashset -euo pipefailsource ~/.secretsif [ -z "${BULLET_HEAVEN_MP_SALT:-}" ]; then openssl rand -hex 24 | lumara-passwords add BULLET_HEAVEN_MP_SALT --group "$(lumara-passwords group-of BULLET_HEAVEN_CONTROL_KEY 2>/dev/null || echo BulletHeaven)" source ~/.secretsficd controlexport CLOUDFLARE_API_TOKEN="$CF_LUMARA_DEPLOY_TOKEN" CLOUDFLARE_ACCOUNT_ID="$CF_ACCOUNT_ID"printf '%s' "$BULLET_HEAVEN_MP_SALT" | npx wrangler secret put MP_SALTnpx wrangler d1 execute bullet-heaven-control --remote --file=migrations/0002_mp_hosts.sqlnpx wrangler deploycurl -fsS "https://bullet-heaven-control.chris-allen-06f.workers.dev/mp/nearby?key=$BULLET_HEAVEN_CONTROL_KEY" ; echoCheck lumara-passwords --help for the right group flag/value before running (the group-of subcommand above is a guess; use whatever group BULLET_HEAVEN_CONTROL_KEY lives in). Expected smoke output: {"hosts":[]}.
- Step 5: Dev BUILD ≥ 265 on the devices via skill
bh-worktree-device-install(bump past the highest BUILD on live branches first, regenerate the build manifest +control/src/tunable_docs.jsif the gate asks). - Step 6: Manual device test (Chris) per spec § Testing: Mac or ATV hosts → iPhone joins from the nearby list with no typing → then by code → then Worker unreachable (e.g. Wi-Fi with no internet) falls back to typed IP. Record whether the nearby list ever missed a same-network host (the v4/v6 mismatch risk) in memory
bullet-heaven-overnight-2026-10-07.
Execution handoff
Section titled “Execution handoff”Written 2026-10-07 by the planning session (Chris said “continue with the net improvements” after a /clear; that resolved to this spec’s next step, writing-plans).
- Decisions already made (spec): nearby list → room code → typed IP; dev/testers only behind
CONTROL_KEY; Bonjour, the multicast entitlement and QR codes rejected. Planning-time calls on the spec’s “Open” items: host display name ="<OS.get_model_name()> · <OS.get_name()>"capped at 24 chars (also used for the beacon now); cross-network “same account” listing stays out of scope. - Planning-time rulings (not in the spec):
MP_SALTunset →/mp/*answers 503 after the key check (fail closed); a refresh whose row expired re-takes its old code if still free; the host deregisters when the run starts (room is full, one guest); a code that arrives afterhost_stopis released at once; the console never overwrites what was typed and only pre-fills when the selected device is not hosting; a registry row’sportis ignored on join (alwaysNetCoopRun.PORT, as today). No ownership check on refresh-by-code (dev-only, 31^5 codes): revisit before any player-facing promotion. - Authorized: implementing Tasks 1-5 on a branch off
origin/overnight-2026-10-07, merging into that branch and pushing it (repo rule: push after every merge). - Still needs Chris’s same-day OK: Task 6 Step 4 (Worker secret
MP_SALT, remote D1 migration 0002, console Worker deploy from the branch) and Step 5 (Dev BUILD ≥ 265 device installs). Merging the overnight branch intomainis a separate pending Chris call (playtest verdict first; memorybullet-heaven-overnight-2026-10-07). - State outside the plan: the console Worker is ALREADY deployed from the overnight branch (version 1cd14251, LAN Host/Join buttons); a console deploy from
maindrops them.platform/tvos/build{,-ios}symlinks exist untracked in the overnight worktree:rmthem before a Worker deploy (a PreToolUse hook also blocks deploys with untracked files), re-create for an install.mainhas unrelated uncommitted economy-sim edits (another session’s work): don’t touch. - Gotchas:
-gselectis a substring match; a fresh worktree needsgodot --headless --path . --importonce; panel string modules forbid backslashes (use[.]/[0-9]); thesecurity-guidancehook flagsconst KEY = 'k'in tests as a credential (test key, ignore); the deploy-guard hook fires on any Bash command whose TEXT contains the deploy command, even a heredoc writing docs. - Next action: consult
bh-sdd-concurrent, then subagent-driven-development. Task 1 (Worker) and Task 2 (LanRegistry) are independent and can run in parallel worktrees; Task 3 needs Task 2; Task 4 needs 2 + 3; Task 5 depends on Task 4 only through thestatus.hostingcontract, so it can run after Task 1 alongside 3/4.