@homie-rocks/studio 0.44.1 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,151 @@
1
+ /** Trusted, ordered multiplex links. Public authentication stays at the Worker;
2
+ * only bindings can open these endpoints. A lost link closes its downstream
3
+ * sockets: clients resume with their existing seat tokens and a fresh keyframe.
4
+ * Inputs and commands are batched together, never overwritten or reordered.
5
+ */
6
+ export function roomLayout(seats) {
7
+ const gates = Math.ceil(seats / 64);
8
+ return { gates, concentrators: gates > 8 ? Math.ceil(gates / 8) : 0 };
9
+ }
10
+ export function gateFor(env, game, room, seats, key) {
11
+ const layout = roomLayout(seats);
12
+ // One logical Gate is embedded in the Table for a small room. Same relay,
13
+ // admission and delivery protocol; no extra Durable Object or seat clamp.
14
+ if (layout.gates === 1) return env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
15
+ let hash = 2166136261;
16
+ for (const c of key || crypto.randomUUID()) hash = Math.imul(hash ^ c.charCodeAt(0), 16777619) >>> 0;
17
+ const index = hash % layout.gates;
18
+ return { fetch(request) {
19
+ const url = new URL(request.url);
20
+ url.searchParams.set('gate', String(index));
21
+ url.searchParams.set('gates', String(layout.gates));
22
+ return env.GATE.get(env.GATE.idFromName(`${game}/${room}/${index}`)).fetch(new Request(url, request));
23
+ } };
24
+ }
25
+
26
+ export function* expandBatch(rows) {
27
+ for (const row of rows) {
28
+ if (row[0] === 'fan') { for (const id of row[1]) yield ['data', id, row[2]]; }
29
+ else yield row;
30
+ }
31
+ }
32
+ export function batchLink(socket, { setTimer = setTimeout, clearTimer = clearTimeout, delay = 5 } = {}) {
33
+ let queue = [], timer = null, closed = false;
34
+ const flush = () => {
35
+ timer = null;
36
+ if (closed || !queue.length) return;
37
+ const rows = []; let last = null;
38
+ for (const row of queue) {
39
+ if (row[0] === 'data' && last && (last[0] === 'data' || last[0] === 'fan') && last[2] === row[2]) {
40
+ if (last[0] === 'data') { last[0] = 'fan'; last[1] = [last[1]]; }
41
+ last[1].push(row[1]);
42
+ } else { last = row.slice(); rows.push(last); }
43
+ }
44
+ queue = [];
45
+ try { socket.send(JSON.stringify(rows)); } catch { close(); try { socket.close(1011, 'room link send failed'); } catch {} }
46
+ };
47
+ const close = () => { closed = true; queue = []; if (timer !== null) clearTimer(timer); timer = null; };
48
+ return { send(row) { if (closed) return; queue.push(row); if (queue.length >= 128) { if (timer !== null) clearTimer(timer); flush(); } else if (timer === null) timer = setTimer(flush, delay); }, flush, close, disconnect() { close(); socket.close(1012, 'room link restarted'); } };
49
+ }
50
+
51
+ /** Adapt one upstream socket into the same transport the Table accepts locally. */
52
+ export function multiplexSession(socket, connect) {
53
+ const out = batchLink(socket), clients = new Map();
54
+ let chain = Promise.resolve(), closed = false;
55
+ const shutdown = () => {
56
+ if (closed) return;
57
+ closed = true; out.close();
58
+ for (const client of clients.values()) client.emit('close', {});
59
+ clients.clear();
60
+ };
61
+ socket.addEventListener('message', event => {
62
+ chain = chain.then(async () => {
63
+ if (closed) return;
64
+ const rows = JSON.parse(event.data);
65
+ if (!Array.isArray(rows)) throw new Error('invalid multiplex batch');
66
+ for (const [op, id, value, ip, agent] of expandBatch(rows)) {
67
+ if (op === 'open') {
68
+ if (clients.has(id)) throw new Error('duplicate multiplex client');
69
+ const handlers = new Map();
70
+ const client = {
71
+ accept() {},
72
+ addEventListener(name, fn) { const list = handlers.get(name) ?? []; list.push(fn); handlers.set(name, list); },
73
+ emit(name, event) { for (const fn of handlers.get(name) ?? []) fn(event); },
74
+ send(text) { out.send(['data', id, text]); },
75
+ close(code, reason) { out.send(['close', id, code, reason]); clients.delete(id); client.emit('close', {}); },
76
+ };
77
+ clients.set(id, client);
78
+ await connect(new Request(value, { headers: { Upgrade: 'websocket', ...(ip ? { 'cf-connecting-ip': ip } : {}), ...(agent ? { 'user-agent': agent } : {}) } }), client);
79
+ } else if (op === 'data') clients.get(id)?.emit('message', { data: value });
80
+ else if (op === 'close') { clients.get(id)?.emit('close', {}); clients.delete(id); }
81
+ }
82
+ }).catch(() => { shutdown(); try { socket.close(1011, 'room link failed'); } catch {} });
83
+ });
84
+ socket.addEventListener('close', shutdown);
85
+ socket.addEventListener('error', shutdown);
86
+ return { shutdown, settled: () => chain };
87
+ }
88
+ export function acceptMultiplex(request, connect) {
89
+ const [client, server] = Object.values(new WebSocketPair());
90
+ server.accept(); multiplexSession(server, connect);
91
+ return new Response(null, { status: 101, webSocket: client });
92
+ }
93
+
94
+ export class Gate {
95
+ constructor(ctx, env) { this.ctx = ctx; this.env = env; this.clients = new Map(); this.sequence = 0; this.link = null; this.opening = null; }
96
+ async upstream(url) {
97
+ if (this.link) return this.link;
98
+ if (this.opening) return this.opening;
99
+ this.opening = (async () => {
100
+ const name = `${url.searchParams.get('game')}/${url.searchParams.get('room')}`;
101
+ const gates = Number(url.searchParams.get('gates'));
102
+ const useConcentrator = !(this instanceof Concentrator) && gates > 8;
103
+ const binding = useConcentrator ? this.env.CONCENTRATOR : this.env.TABLE;
104
+ const key = useConcentrator ? `${name}/${Math.floor(Number(url.searchParams.get('gate')) / 8)}` : name;
105
+ const target = new URL(url); target.pathname = '/__multiplex';
106
+ const response = await binding.get(binding.idFromName(key)).fetch(new Request(target, { headers: { Upgrade: 'websocket' } }));
107
+ if (!response.webSocket) throw new Error('room link refused');
108
+ const socket = response.webSocket; socket.accept();
109
+ const link = batchLink(socket);
110
+ const lost = () => {
111
+ if (this.link !== link) return;
112
+ this.link = null; link.close();
113
+ for (const client of this.clients.values()) { try { client.close(1012, 'room link restarted'); } catch {} }
114
+ this.clients.clear();
115
+ };
116
+ socket.addEventListener('message', event => {
117
+ try { for (const [op, id, value, reason] of expandBatch(JSON.parse(event.data))) {
118
+ const client = this.clients.get(id);
119
+ if (op === 'data') {
120
+ try { client?.send(value); } catch {
121
+ this.clients.delete(id); link.send(['close', id]);
122
+ try { client?.close(1011, 'client connection ended'); } catch {}
123
+ }
124
+ } else if (op === 'close') {
125
+ this.clients.delete(id); try { client?.close(value, reason); } catch {}
126
+ }
127
+ } } catch { lost(); try { socket.close(1011, 'room link failed'); } catch {} }
128
+ });
129
+ socket.addEventListener('close', lost); socket.addEventListener('error', lost);
130
+ this.link = link; return link;
131
+ })();
132
+ try { return await this.opening; } finally { this.opening = null; }
133
+ }
134
+ async connect(request, server) {
135
+ const link = await this.upstream(new URL(request.url));
136
+ const id = String(++this.sequence);
137
+ this.clients.set(id, server);
138
+ link.send(['open', id, request.url, request.headers.get('cf-connecting-ip'), request.headers.get('user-agent')]);
139
+ server.addEventListener('message', event => { if (typeof event.data === 'string') link.send(['data', id, event.data]); });
140
+ const left = () => { if (this.clients.delete(id)) link.send(['close', id]); };
141
+ server.addEventListener('close', left); server.addEventListener('error', left);
142
+ }
143
+ async fetch(request) {
144
+ if (new URL(request.url).pathname === '/__multiplex') return acceptMultiplex(request, (req, socket) => this.connect(req, socket));
145
+ const [client, server] = Object.values(new WebSocketPair());
146
+ server.accept();
147
+ await this.connect(request, server);
148
+ return new Response(null, { status: 101, webSocket: client });
149
+ }
150
+ }
151
+ export class Concentrator extends Gate {}
package/worker/index.mjs CHANGED
@@ -104,7 +104,9 @@ import { forgetLines, historyOf, keepLines, keptLine } from './lounge-store.mjs'
104
104
  import { isLoungePath, loungeRoutes } from './lounge.mjs';
105
105
  import { doorPage, serverPage, serversPage } from './site.mjs';
106
106
  import { isLocalOrigin, qrSvg } from './qr.mjs';
107
- import { SEAT_MAX, paramsFrom, perAddress, seatsOf } from './seats.mjs';
107
+ import { acceptMultiplex, gateFor } from './gate.mjs';
108
+ export { Gate, Concentrator } from './gate.mjs';
109
+ import { seatCount, paramsFrom, perAddress, seatsOf } from './seats.mjs';
108
110
  import {
109
111
  SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homeLd, homePage, jsonFeed, landingLd, mediaArt, mediaIndexPage,
110
112
  notFoundPage, postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage, watchOf,
@@ -129,7 +131,7 @@ import { licenseOf } from './license.mjs';
129
131
  // GAME PARTS (parts/PARTS.md section 4): three call-outs below, everything else is worker/parts.mjs.
130
132
  import { PART_FILES, isPartsPath, partsRoutes, withPartsBand } from './parts.mjs';
131
133
 
132
- export { SEAT_MAX } from './seats.mjs';
134
+ export { seatCount, SEAT_MAX } from './seats.mjs';
133
135
  // Server-hosted games (NETPLAY.md section 29): the studio's site/src/worker.mjs hands the build's rules over with this.
134
136
  export { hostRules } from './hosted.mjs';
135
137
  /** For a studio whose Worker has player accounts: hand their server API to the back office once (worker/office.mjs). */
@@ -480,7 +482,7 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
480
482
  const policy = watchOf(meta);
481
483
  const watching = !agent && url.searchParams.get('watch') === '1';
482
484
  if (watching && policy === 'off') return new Response(`${meta?.name ?? game} cannot be watched; play it instead.\n`, { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
483
- const follow = /^(?:auto|overview|\d{1,2})$/.test(url.searchParams.get('follow') ?? '') ? url.searchParams.get('follow') : 'auto';
485
+ const follow = /^(?:auto|overview|\d+)$/.test(url.searchParams.get('follow') ?? '') ? url.searchParams.get('follow') : 'auto';
484
486
  const want = !agent && (watching || url.searchParams.get('want') === 'screen') ? 'screen' : 'play';
485
487
  const device = ['phone', 'desk', 'tv'].includes(url.searchParams.get('device')) ? url.searchParams.get('device') : undefined;
486
488
  const wsBase = `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}`;
@@ -1184,7 +1186,7 @@ async function route(request, env, ctx) {
1184
1186
  // one; none on a kids server, none for an AI or a watcher.
1185
1187
  const pid = !ag && !w && sub === '__net' ? parts.find((x) => x.startsWith('p-'))?.slice(2) : null;
1186
1188
  const badge = pid ? await roomBadge(env, cat, pid, { kids: pol.kids }) : null;
1187
- const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
1189
+ const stub = sub === '__net' ? gateFor(env, game, room, max, b) : env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
1188
1190
  // Revision 9 (NETPLAY.md sections 22 and 23): the build that is live now (`cur`, always said, empty when the game
1189
1191
  // names none), the build this socket's page was served with (`gv`, from the socket's own address), and the game's
1190
1192
  // own stall time.
@@ -1934,10 +1936,17 @@ export class Table {
1934
1936
  }
1935
1937
 
1936
1938
  async fetch(request) {
1939
+ if (new URL(request.url).pathname === '/__multiplex') {
1940
+ return acceptMultiplex(request, (req, socket) => this.connect(req, socket));
1941
+ }
1942
+ return this.connect(request);
1943
+ }
1944
+
1945
+ async connect(request, transport = null) {
1937
1946
  const url = new URL(request.url);
1938
1947
  const game = url.searchParams.get('game');
1939
1948
  const code = url.searchParams.get('room');
1940
- const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
1949
+ const max = seatCount(url.searchParams.get('max'));
1941
1950
  if (url.pathname === '/__restart') {
1942
1951
  if (this.env.HOMIE_PREVIEW !== '1' || request.method !== 'POST') return new Response('not found', { status: 404 });
1943
1952
  // No last-chance save: this measures exactly what a real abrupt restart can lose.
@@ -1986,11 +1995,12 @@ export class Table {
1986
1995
  return json(res);
1987
1996
  }
1988
1997
  room.rules = url.searchParams.get('rules') === '1';
1998
+ room.perIp = perAddress(max, room.rules);
1989
1999
  // Any tick rate a game may declare (1 to 60): the relay's allowances are the page's, from the same table.
1990
2000
  const hz = Number(url.searchParams.get('hz'));
1991
2001
  room.tickHz = Number.isInteger(hz) && hz >= 1 && hz <= 60 ? hz : 20;
1992
2002
  // The owner's room size: a room already open takes it from the next visitor on.
1993
- if (room.seatCap !== max || room.rules && room.maxPlayers !== max) room.setSeats(max, perAddress(max));
2003
+ if (room.seatCap !== max || room.rules && room.maxPlayers !== max) room.setSeats(max, perAddress(max, room.rules));
1994
2004
  // Revision 9: the game's own stall time and the build that is live now, the Worker's word with every socket.
1995
2005
  const said = url.searchParams.has('cur');
1996
2006
  if (said) { room.setStall(url.searchParams.get('stall')); room.setCurrent(url.searchParams.get('cur') || null); }
@@ -2006,7 +2016,7 @@ export class Table {
2006
2016
  const agent = decodeFacts(url.searchParams.get('ag'));
2007
2017
  // Kept chat (0.29.0): the room's history is back in its window before this page hears it.
2008
2018
  await this.hydrateHistory(room);
2009
- const [client, server] = Object.values(new WebSocketPair());
2019
+ const [client, server] = transport ? [null, transport] : Object.values(new WebSocketPair());
2010
2020
  server.accept();
2011
2021
  const via = url.searchParams.get('via');
2012
2022
  const conn = {
@@ -2043,7 +2053,7 @@ export class Table {
2043
2053
  // Watching shells are cheap (outgoing is free) but not free: a room holds at most 256, and 24 from one address
2044
2054
  // (room chat, 0.23.0: another site's page can open one too).
2045
2055
  const same = conn.ip ? [...room.watchers].filter((x) => x.ip === conn.ip).length : 0;
2046
- if (room.watchers.size >= 256 || same >= 24) { try { server.close(1013, 'too many watching'); } catch { /* gone */ } return new Response(null, { status: 101, webSocket: client }); }
2056
+ if (room.watchers.size >= 256 || same >= 24) { try { server.close(1013, 'too many watching'); } catch { /* gone */ } return transport ? null : new Response(null, { status: 101, webSocket: client }); }
2047
2057
  const w = room.watch(conn);
2048
2058
  // The play page's vote card speaks on this socket (section 17); nothing else it says is read.
2049
2059
  server.addEventListener('message', (e) => {
@@ -2088,7 +2098,7 @@ export class Table {
2088
2098
  });
2089
2099
  }
2090
2100
  this.start();
2091
- return new Response(null, { status: 101, webSocket: client });
2101
+ return transport ? null : new Response(null, { status: 101, webSocket: client });
2092
2102
  }
2093
2103
 
2094
2104
  start() {
@@ -2173,7 +2183,7 @@ export class Table {
2173
2183
  if (!r || r.phase !== 'over' || !Number.isFinite(r.n) || r.n <= this.recorded || !this.env.DB) return;
2174
2184
  this.recorded = r.n;
2175
2185
  this.ctx.storage.put('recorded', r.n).catch(() => {});
2176
- const results = Array.isArray(r.results) ? r.results.slice(0, SEAT_MAX * 2) : [];
2186
+ const results = Array.isArray(r.results) ? r.results : [];
2177
2187
  // An AI's row is never a person's (the relay labelled it `agent`): rounds count agents as AI.
2178
2188
  const humans = results.filter((row) => row && !row.bot && !row.agent).length;
2179
2189
  // A round played only by house QA (every seated socket is marked) is kept in `rounds`, not in the stats.
@@ -2273,7 +2283,7 @@ export class Lobby {
2273
2283
  // the build of a game that names none ('').
2274
2284
  const ver = versionOf(url.searchParams.get('ver')) ?? '';
2275
2285
  if (url.pathname === '/join') {
2276
- const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
2286
+ const max = seatCount(url.searchParams.get('max'));
2277
2287
  const sid = url.searchParams.get('server') ?? 'public';
2278
2288
  const server = sid === 'public' || SERVER_ID.test(sid) ? sid : 'public';
2279
2289
  const roomsMax = Math.max(1, Math.min(16, Math.floor(Number(url.searchParams.get('rooms'))) || 16));
@@ -39,7 +39,6 @@ export async function roomSeat(table,room,ctl) {
39
39
  if(a.operation==='stand'){seat?.handle.onClose();seats.delete(a.connection);return {ok:true};}
40
40
  if(a.operation==='sit'&&(!seat||seat.closed)){
41
41
  for(const [id,old] of seats)if(old.closed)seats.delete(id);
42
- if(seats.size>=32)throw new Error('remote seat capacity reached');
43
42
  await table.readVocab(table.game);
44
43
  seat={closed:false,frames:[]};
45
44
  const conn={ip:null,player:a.person,via:'p-'+a.person,agent:{pass:a.connection.replaceAll('-','').slice(0,16),role:'guide',hands:'host',by:'studio',name:a.name},send:text=>{seat.frames.push(JSON.parse(text));if(seat.frames.length>20)seat.frames.shift();},close:()=>{seat.closed=true;},buffered:()=>0};
package/worker/office.mjs CHANGED
@@ -61,7 +61,7 @@ import { toolIdentity } from './tool-identity.mjs';
61
61
  * links to the studio's own Stripe, a CSV, the referral books and signed statements). A refund is the owner's one tap;
62
62
  * from an office key it is ALWAYS an ask (the AI may only propose one), and so is marking a referrer paid.
63
63
  */
64
- import { SEAT_MAX, seatsOf } from './seats.mjs';
64
+ import { seatsOf } from './seats.mjs';
65
65
  import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
66
66
  import { BRAIN_BUDGET, aiName, brainFor, fillSpot, passById, passCreate, passList, passRevoke, think } from './agents.mjs';
67
67
  import { DEFAULT_MODEL, NEURONS_PER_M, OWNER_MODEL, vocabularyOf } from './brain.mjs';
@@ -472,7 +472,7 @@ async function roomRow(env, meta, room, max) {
472
472
  openedAt: office.openedAt ?? f.openedAt ?? null, closedUntil: office.closedUntil ?? null, snapHz: f.snapHz ?? 0,
473
473
  regate: office.regate ?? null,
474
474
  host: f.host ?? null, announce: f.announce ?? null,
475
- slots: slots.slice(0, 64).map((s) => ({ slot: s.slot, seat: s.seat ?? null, name: oneLine(s.name, 40), bot: Boolean(s.bot), ...(s.agent ? { agent: { role: s.agent.role ?? 'party', seat: s.agent.seat ?? null } } : {}) })),
475
+ slots: slots.map((s) => ({ slot: s.slot, seat: s.seat ?? null, name: oneLine(s.name, 40), bot: Boolean(s.bot), ...(s.agent ? { agent: { role: s.agent.role ?? 'party', seat: s.agent.seat ?? null } } : {}) })),
476
476
  clients, bans: office.bans ?? [], mutes: office.mutes ?? [],
477
477
  // Room chat (0.23.0): the room's last minutes, with who sent each line (a client id and account; never an address).
478
478
  chat: office.chat ? { mode: office.chat.rules?.mode ?? null, pending: office.chat.pending ?? 0, lines: (office.chat.lines ?? []).slice(-50).map((l) => ({ id: l.id, at: l.at, t: l.t, kind: l.kind, name: oneLine(l.name, 40), seat: l.seat ?? null, by: l.by, text: l.text ?? null, glyph: l.glyph ?? null, react: l.react ?? null, say: l.say ?? null, acct: Boolean(l.acct), owner: Boolean(l.owner), client: l.client ?? null, player: l.player ?? null, browser: l.browser ?? null, ...(l.review ? { review: l.review } : {}) })) } : null,
@@ -670,7 +670,7 @@ function checkAction(cat, op, body) {
670
670
  if (!meta) return bad('game is one of this studio\'s game ids');
671
671
  if (!room) return bad('room is the room\'s code (pub-3, or a named room)');
672
672
  const id = typeof body.id === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(body.id) ? body.id : null;
673
- const seat = Number.isInteger(body.seat) && body.seat >= 0 && body.seat < SEAT_MAX ? body.seat : null;
673
+ const seat = Number.isInteger(body.seat) && body.seat >= 0 && Number.isSafeInteger(body.seat) ? body.seat : null;
674
674
  // From a chat line (0.23.0): its sender, whoever they are now (a watcher too); `purge` takes their lines down.
675
675
  const line = typeof body.line === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(body.line) ? body.line : null;
676
676
  if (id === null && seat === null && line === null) return bad('name the player: id (from the office), seat, or line (a chat message of theirs)');
package/worker/room.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { interestSnapshot, snapshotEncoder } from '../rules/interest.mjs';
1
+ import { interestSnapshot, snapshotEncoder, scheduledView } from '../rules/interest.mjs';
2
2
  /*
3
3
  * room.mjs — the netplay v1 relay semantics, with no transport in it.
4
4
  * =============================================================================
@@ -58,7 +58,7 @@ import { CHAT_LIMITS, CHAT_RATES, HELD_WORDS, allows, cleanText, floor, normaliz
58
58
 
59
59
  export const NET_VERSION = 1;
60
60
  /** The contract revision this relay speaks (NETPLAY.md): optional fields, frames and refusals; the wire stays `v: 1`. */
61
- export const NET_REVISION = 11;
61
+ export const NET_REVISION = 12;
62
62
  /**
63
63
  * Revision 9 (NETPLAY.md sections 22 and 23). STALL: how long a host may send no snapshot, while others are present,
64
64
  * before the room is handed on: 1.5 s unless the game names its own (game.json `netplay.stallMs`), never under
@@ -316,7 +316,7 @@ export class NetRoom {
316
316
  this.lastSnapText = null;
317
317
  this.lastCkpt = null;
318
318
  this.lastRound = null;
319
- this.lastRoster = null;
319
+ this.lastRoster = null; this.rosterSent = null;
320
320
  /** key → { d, bytes } — slow world state, handed to every joiner and to a promoted host */
321
321
  this.state = new Map();
322
322
  this.stateBytes = 0;
@@ -461,7 +461,7 @@ export class NetRoom {
461
461
  hostSnap(c, m, now, text = null) {
462
462
  // The server's stamp is this relay's own clock; a browser's is its estimate of it, and is checked.
463
463
  const snap = { k: Number(m.k) || 0, st: c ? this.stamp(m.st) : Number(m.st) || now, d: m.d ?? null };
464
- if (Array.isArray(m.c)) snap.c = m.c.slice(0, this.server ? this.seatCap : 64);
464
+ if (Array.isArray(m.c)) snap.c = m.c.slice(0, this.server || this.rules ? this.seatCap : 64);
465
465
  // Revision 10: a rules game's snapshot names the room's epoch.
466
466
  if ((!c || this.rules && c.rules) && Number.isFinite(m.e)) snap.e = m.e;
467
467
  this.lastSnap = snap;
@@ -478,17 +478,23 @@ export class NetRoom {
478
478
  if (this.lite(o)) continue; // an agent with no game client draws nothing
479
479
  // A congested socket skips a snapshot rather than queueing a stale one.
480
480
  if (o.conn.buffered && o.conn.buffered() > 256 * 1024) { this.stats.drops += 1; continue; }
481
- this.sendText(o.conn, this.server?.viewRadiusM != null ? JSON.stringify({ t: 'snap', from: null, ...this.playerSnapshot(o, snap) }) : out);
481
+ this.sendText(o.conn, this.hasViewSchedule() ? JSON.stringify({ t: 'snap', from: null, ...this.playerSnapshot(o, snap) }) : out);
482
482
  }
483
483
  }
484
484
 
485
+ hasViewSchedule() {
486
+ const view = this.server?.viewSettings;
487
+ return this.server?.viewRadiusM != null || Boolean(view?.precisionM > 0 || view?.nearM != null && view?.farHz != null);
488
+ }
489
+
485
490
  /** The same per-player delivery role will run in Gates. Keyframes belong to sockets, not saved seats. */
486
491
  playerSnapshot(client, snap, welcome = false) {
487
- if (!snap || this.server?.viewRadiusM == null) return snap;
488
- const selected = interestSnapshot(snap, client.seat, this.server.viewRadiusM);
489
- client.snapEncoder ??= snapshotEncoder(this.tickHz);
492
+ if (!snap || !this.hasViewSchedule()) return snap;
493
+ client.viewSchedule ??= scheduledView(this.server.viewSettings ?? { radiusM: this.server.viewRadiusM }, this.tickHz);
494
+ const selected = client.viewSchedule(snap, client.seat);
495
+ client.snapEncoder ??= snapshotEncoder(this.tickHz, true);
490
496
  if (welcome) client.snapEncoder.reset();
491
- return client.snapEncoder.encode(selected);
497
+ return client.snapEncoder.encode(selected, client.seat);
492
498
  }
493
499
 
494
500
  /** One key of the slow state channel from the host. `bytes`: the frame's size, counted against the room's cap for a browser host. */
@@ -540,9 +546,16 @@ export class NetRoom {
540
546
  hostRoster(c, m) {
541
547
  if (!Array.isArray(m.slots)) return;
542
548
  // A host cannot hide an AI: a seat an agent holds is named and marked so, a slot with no seat is a bot.
543
- this.lastRoster = this.labelRoster(m.slots.slice(0, this.server ? this.seatCap : 64));
549
+ const previous = this.rosterSent ?? new Map();
550
+ this.lastRoster = this.labelRoster(m.slots.slice(0, this.server || this.rules ? this.seatCap : 64));
544
551
  this.persistDirty = true;
545
- for (const o of (c && this.rules && c.rules ? this.live() : this.others(c))) this.send(o, { t: 'roster', slots: this.lastRoster });
552
+ const present = new Set(this.lastRoster.map(row => row.slot));
553
+ const changed = this.lastRoster.filter(row => previous.get(row.slot) !== JSON.stringify(row));
554
+ this.rosterSent = new Map(this.lastRoster.map(row => [row.slot, JSON.stringify(row)]));
555
+ const removed = [...previous.keys()].filter(slot => !present.has(slot));
556
+ const frame = this.server && previous.size ? { t: 'roster', patch: true, slots: changed, removed } : { t: 'roster', slots: this.lastRoster };
557
+ const text = JSON.stringify(frame);
558
+ for (const o of (c && this.rules && c.rules ? this.live() : this.others(c))) this.sendText(o.conn, text);
546
559
  this.tellWatchers();
547
560
  }
548
561
 
@@ -1121,7 +1134,8 @@ export class NetRoom {
1121
1134
  ...(this.vote ? { vote: this.voteView() } : {}),
1122
1135
  ...(c.agent ? { agent: { ...c.agent, name: c.name } } : {}),
1123
1136
  });
1124
- for (const o of this.others(c)) this.send(o, { t: 'join', peer: this.peer(c) });
1137
+ const joinedText = JSON.stringify({ t: 'join', peer: this.peer(c) });
1138
+ for (const o of this.others(c)) this.sendText(o.conn, joinedText);
1125
1139
  // The server host hears of every seat taken (a reconnect is the same stay: its body is back, not new).
1126
1140
  if (c.seat !== null) { this.syncServerSeats(); this.joinServer(c); }
1127
1141
  // A seat taken by a browser that also watches this room: its watching tab sees the overview from now on.
@@ -1211,7 +1225,8 @@ export class NetRoom {
1211
1225
  if (!this.seatClient(c, '')) return;
1212
1226
  this.stats.seated += 1;
1213
1227
  this.send(c, { t: 'seat', seat: c.seat, token: c.token, name: c.name, colour: c.colour, role: this.roleOf(c) });
1214
- for (const o of this.others(c)) this.send(o, { t: 'join', peer: this.peer(c) });
1228
+ const joinedText = JSON.stringify({ t: 'join', peer: this.peer(c) });
1229
+ for (const o of this.others(c)) this.sendText(o.conn, joinedText);
1215
1230
  this.syncServerSeats(); this.joinServer(c);
1216
1231
  this.log({ ev: 'seated', room: this.code, id: c.id, seat: c.seat });
1217
1232
  this.persist();
@@ -1588,7 +1603,8 @@ export class NetRoom {
1588
1603
  const s = this.seats.get(c.seat);
1589
1604
  if (s && s.token === c.token) { s.present = false; s.since = now; }
1590
1605
  }
1591
- for (const o of this.others(c)) this.send(o, { t: 'leave', id: c.id, seat: c.seat, why });
1606
+ const leftText = JSON.stringify({ t: 'leave', id: c.id, seat: c.seat, why });
1607
+ for (const o of this.others(c)) this.sendText(o.conn, leftText);
1592
1608
  // One line per departure, with what the room looked like. A `leave` is a departure (a closed tab, a lost network
1593
1609
  // arriving as the socket's error, a host that was replaced); a `failed` line (guard) is a room operation that threw.
1594
1610
  this.log({ ev: 'leave', room: this.code, id: c.id, seat: c.seat, why, ...(via ? { via } : {}), role: wasHost ? 'host' : c.seat === null ? 'screen' : 'replica', left: this.live().length });
@@ -1720,7 +1736,7 @@ export class NetRoom {
1720
1736
  forget() {
1721
1737
  this.hostId = null;
1722
1738
  for (const c of this.live()) this.kick(c, 'agents-alone', 4001);
1723
- this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
1739
+ this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null; this.rosterSent = null;
1724
1740
  this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null;
1725
1741
  this.emptySince = 0; this.openedAt = 0; this.askedMax = null;
1726
1742
  // The room is nobody's build again (section 23): the next visitor's is its build.
@@ -1813,7 +1829,7 @@ export class NetRoom {
1813
1829
 
1814
1830
  /** A round's results, labelled the same way (`agent: true` on every AI's row). */
1815
1831
  labelResults(rows) {
1816
- return rows.slice(0, this.server ? this.seatCap : 64).map((raw) => this.labelOne(raw, true)).filter(Boolean);
1832
+ return rows.slice(0, this.server || this.rules ? this.seatCap : 64).map((raw) => this.labelOne(raw, true)).filter(Boolean);
1817
1833
  }
1818
1834
 
1819
1835
  labelOne(raw, result) {
@@ -2197,7 +2213,7 @@ export class NetRoom {
2197
2213
  }
2198
2214
  // Everything the room held goes, as when an empty room forgets (section 4).
2199
2215
  this.hostId = null;
2200
- this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
2216
+ this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null; this.rosterSent = null;
2201
2217
  this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null; this.openedAt = 0; this.askedMax = null;
2202
2218
  this.emptySince = now; this.seatsDirty = false; this.persistDirty = false;
2203
2219
  try { this.store?.clear?.(); } catch { /* best effort */ }
@@ -2303,7 +2319,7 @@ export class NetRoom {
2303
2319
  */
2304
2320
  forgetWorld(next) {
2305
2321
  this.log({ ev: 'build-changed', room: this.code, from: this.gameVer ?? null, to: next ?? null });
2306
- this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
2322
+ this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null; this.rosterSent = null;
2307
2323
  this.state.clear(); this.stateBytes = 0; this.preferHost = null;
2308
2324
  this.persistDirty = true;
2309
2325
  }
@@ -2311,7 +2327,7 @@ export class NetRoom {
2311
2327
  setSeats(n, perIp = null) {
2312
2328
  this.seatCap = Math.max(1, Math.floor(n));
2313
2329
  this.maxPlayers = Math.max(1, Math.min(this.seatCap, (this.server || this.rules) ? this.seatCap : this.askedMax ?? this.seatCap));
2314
- if (Number.isFinite(perIp) && perIp > 0) this.perIp = perIp;
2330
+ if (Number.isFinite(perIp) && perIp >= 0) this.perIp = perIp;
2315
2331
  }
2316
2332
 
2317
2333
  /** The owner's holds, the banner and a closed door: kept apart from the room's play (they outlive an empty room). */
package/worker/seats.mjs CHANGED
@@ -2,24 +2,20 @@
2
2
  * Room sizes, shared by the site Worker (worker/index.mjs) and `homie-studio build` (lib/build.mjs).
3
3
  *
4
4
  * A game names its seats in its netplay manifest (game.json `netplay.maxPlayers`, or `netplay.json` with
5
- * `maxPlayers` or `players.max`); every room of that game has that many, up to SEAT_MAX (32).
5
+ * `maxPlayers` or `players.max`); every room of that game has that many, as chosen by the studio.
6
6
  */
7
7
 
8
8
  import { stallOf, versionOf } from './room.mjs';
9
9
 
10
- /** The most seats one room holds (NETPLAY.md §3). */
10
+ /** Room size is chosen by the studio, not capped by admission. */
11
+ export const seatCount = (n, fallback = 8) => Number.isSafeInteger(Number(n)) && Number(n) > 0 ? Number(n) : fallback;
12
+ /** Compatibility limit for existing browser-hosted netplay games only. */
11
13
  export const SEAT_MAX = 32;
12
-
13
- /** A game's seats from its catalogue row: 1 to SEAT_MAX, 8 when it names none. */
14
- export const seatsOf = (meta) => Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(meta?.players?.max)) || 8));
15
-
16
- /**
17
- * Sockets one client address may hold in one room: 12, or every seat plus four (a big screen or two, and a
18
- * visitor waiting for a seat) when that is more. A party of thirty phones and a TV on one Wi-Fi (or strangers
19
- * behind one carrier's shared address) fills a 32-seat room from a single address, and must be let in; an 8-seat
20
- * game keeps the old 12.
21
- */
22
- export const perAddress = (seats) => Math.max(12, seats + 4);
14
+ export const seatsOf = (meta) => meta?.room?.contract || meta?.kind === 'app'
15
+ ? seatCount(meta?.players?.max)
16
+ : Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(meta?.players?.max)) || 8));
17
+ /** Rules rooms have no carrier-address ceiling; legacy rooms retain their old allowance. */
18
+ export const perAddress = (seats, rules = false) => rules ? 0 : Math.max(12, seats + 4);
23
19
 
24
20
  /**
25
21
  * `net.prefs` (NETPLAY.md section 24): what the play page keeps for one game on one browser, because the game's frame
@@ -141,8 +141,8 @@ export function serverOf(raw, { from = 'd1' } = {}) {
141
141
  name: oneLine(raw.name, SERVER_LIMITS.name) || (id === 'public' ? 'Quick play' : id),
142
142
  blurb: oneLine(raw.blurb, SERVER_LIMITS.blurb),
143
143
  policy,
144
- aiSeats: human || policy === 'open' ? 0 : int(pick('ai_seats', 'aiSeats'), 0, 31, policy === 'hybrid' ? 2 : 0),
145
- guides: policy === 'beginner' ? int(pick('guides', 'guides'), 0, 31, 2) : 0,
144
+ aiSeats: human || policy === 'open' ? 0 : int(pick('ai_seats', 'aiSeats'), 0, Number.MAX_SAFE_INTEGER, policy === 'hybrid' ? 2 : 0),
145
+ guides: policy === 'beginner' ? int(pick('guides', 'guides'), 0, Number.MAX_SAFE_INTEGER, 2) : 0,
146
146
  bots: (pick('bots', 'bots') ?? (human ? 'off' : 'fill')) === 'off' ? 'off' : 'fill',
147
147
  level: Math.min(levelMax, int(pick('level', 'level'), 1, 5, policy === 'beginner' ? 2 : 3)),
148
148
  levelMax,
@@ -152,7 +152,7 @@ export function serverOf(raw, { from = 'd1' } = {}) {
152
152
  beginnerDays: policy === 'beginner' ? int(pick('beginner_days', 'beginnerDays'), 1, 3650, SERVER_LIMITS.beginnerDays) : null,
153
153
  beginnerLevel: policy === 'beginner' && pick('beginner_level', 'beginnerLevel') !== undefined && pick('beginner_level', 'beginnerLevel') !== null ? int(pick('beginner_level', 'beginnerLevel'), 1, 1_000_000, null) : null,
154
154
  roomsMax: int(pick('rooms_max', 'roomsMax'), 1, SERVER_LIMITS.roomsMax, id === 'public' ? SERVER_LIMITS.roomsMax : 4),
155
- seats: pick('seats', 'seats') === undefined || pick('seats', 'seats') === null ? null : int(pick('seats', 'seats'), 1, 32, null),
155
+ seats: pick('seats', 'seats') === undefined || pick('seats', 'seats') === null ? null : int(pick('seats', 'seats'), 1, Number.MAX_SAFE_INTEGER, null),
156
156
  brain: human ? 'off' : BRAINS.includes(pick('brain', 'brain')) ? pick('brain', 'brain') : 'script',
157
157
  listed: pick('listed', 'listed') === undefined || pick('listed', 'listed') === null ? true : pick('listed', 'listed') === true || Number(pick('listed', 'listed')) === 1,
158
158
  state: ['open', 'closed', 'archived'].includes(raw.state) ? raw.state : 'open',
@@ -376,14 +376,14 @@ export function checkServer(body, { create = false } = {}) {
376
376
  }
377
377
  if (body.blurb !== undefined) f.blurb = oneLine(body.blurb, SERVER_LIMITS.blurb);
378
378
  const num = (k, lo, hi) => { if (body[k] === undefined) return true; const n = Math.floor(Number(body[k])); if (!Number.isFinite(n) || n < lo || n > hi) return false; f[k] = n; return true; };
379
- if (!num('aiSeats', 0, 31)) return bad('aiSeats is 0 to 31 (and at most the room\'s seats less one)');
380
- if (!num('guides', 0, 31)) return bad('guides is 0 to 31');
379
+ if (!num('aiSeats', 0, Number.MAX_SAFE_INTEGER)) return bad('aiSeats is a non-negative safe integer (and at most the room\'s seats less one)');
380
+ if (!num('guides', 0, Number.MAX_SAFE_INTEGER)) return bad('guides is a non-negative safe integer');
381
381
  if (!num('level', 1, 5)) return bad('level is 1 to 5 (Rookie, Steady, Fair, Strong, Maxed)');
382
382
  if (!num('levelMax', 1, 5)) return bad('levelMax is 1 to 5');
383
383
  if (!num('rooms', 1, SERVER_LIMITS.roomsMax)) return bad(`rooms is 1 to ${SERVER_LIMITS.roomsMax}`);
384
384
  if (!num('beginnerDays', 1, 3650)) return bad('beginnerDays is 1 to 3650');
385
385
  if (body.beginnerLevel === null) f.beginnerLevel = null; else if (!num('beginnerLevel', 1, 1_000_000)) return bad('beginnerLevel is a number, or null for none');
386
- if (body.seats === null) f.seats = null; else if (!num('seats', 1, 32)) return bad('seats is 1 to 32, or null for the game\'s own');
386
+ if (body.seats === null) f.seats = null; else if (!num('seats', 1, Number.MAX_SAFE_INTEGER)) return bad('seats is a positive safe integer, or null for the game\'s own');
387
387
  if (body.kids !== undefined) f.kids = body.kids === true || body.kids === 'on';
388
388
  if (body.listed !== undefined) f.listed = !(body.listed === false || body.listed === 'off');
389
389
  if (body.bots !== undefined) { if (!['fill', 'off'].includes(body.bots)) return bad('bots is fill or off'); f.bots = body.bots; }
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.44.1';
2
+ export const STUDIO_VERSION_TAG = '0.45.0';