@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.
- package/CHANGELOG.md +41 -0
- package/README.md +20 -22
- package/guides/game/REWRITE.md +20 -2
- package/guides/game/RULES.md +51 -14
- package/guides/game/SKILL.md +12 -8
- package/lib/build.mjs +11 -8
- package/lib/cloudflare.mjs +6 -4
- package/lib/dev.mjs +4 -2
- package/lib/mcp.mjs +1 -1
- package/lib/rules-build.mjs +2 -1
- package/lib/scaffold.mjs +9 -9
- package/lib/template-history.json +97 -0
- package/lib/typecheck.mjs +16 -2
- package/lib/worker-build.mjs +18 -4
- package/netplay/NETPLAY.md +22 -177
- package/netplay/netplay.ts +14 -9
- package/package.json +1 -1
- package/rules/collision.ts +10 -4
- package/rules/core.ts +3 -4
- package/rules/host.ts +2 -0
- package/rules/interest.mjs +106 -21
- package/rules/live.ts +6 -4
- package/rules/map-index.ts +51 -0
- package/rules/math.ts +5 -2
- package/rules/rules.ts +15 -7
- package/rules/terrain.ts +93 -0
- package/starters/coin-dash/game.json +14 -4
- package/starters/ember-vale/game.json +0 -4
- package/starters/ember-vale/src/view.ts +2 -2
- package/starters/gem-rush/game.json +0 -4
- package/starters/gem-rush-3d/game.json +0 -4
- package/starters/hero-rush-3d/game.json +0 -4
- package/starters/hero-rush-3d/src/move.ts +2 -0
- package/worker/gate.mjs +151 -0
- package/worker/index.mjs +21 -11
- package/worker/mcp-room.mjs +0 -1
- package/worker/office.mjs +3 -3
- package/worker/room.mjs +35 -19
- package/worker/seats.mjs +9 -13
- package/worker/servers.mjs +6 -6
- package/worker/version.mjs +1 -1
package/worker/gate.mjs
ADDED
|
@@ -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 {
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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));
|
package/worker/mcp-room.mjs
CHANGED
|
@@ -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 {
|
|
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.
|
|
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
|
|
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 =
|
|
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.
|
|
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.
|
|
488
|
-
|
|
489
|
-
client.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
/**
|
|
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
|
-
|
|
14
|
-
|
|
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
|
package/worker/servers.mjs
CHANGED
|
@@ -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,
|
|
145
|
-
guides: policy === 'beginner' ? int(pick('guides', 'guides'), 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,
|
|
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,
|
|
380
|
-
if (!num('guides', 0,
|
|
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,
|
|
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; }
|
package/worker/version.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.45.0';
|