@homie-rocks/studio 0.35.1 → 0.37.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 +40 -0
- package/README.md +4 -2
- package/agents/agents.ts +8 -6
- package/app-starters/welcome/app.json +106 -0
- package/app-starters/welcome/index.html +4 -0
- package/app-starters/welcome/src/main.ts +107 -0
- package/apps/APPS.md +61 -0
- package/apps/apps.ts +28 -0
- package/bin/homie-studio.mjs +22 -10
- package/lab/lab.ts +15 -0
- package/lib/art-cli.mjs +2 -2
- package/lib/art-tools.mjs +6 -6
- package/lib/asset-check.mjs +3 -2
- package/lib/asset-import.mjs +3 -2
- package/lib/asset-manifest.mjs +8 -8
- package/lib/build.mjs +26 -18
- package/lib/characters.mjs +7 -7
- package/lib/check.mjs +49 -0
- package/lib/cloudflare.mjs +1 -1
- package/lib/codex.mjs +2 -2
- package/lib/collision.mjs +1 -1
- package/lib/decisions.mjs +7 -7
- package/lib/dev.mjs +42 -10
- package/lib/directory.mjs +1 -1
- package/lib/doctor.mjs +1 -1
- package/lib/handoff.mjs +1 -1
- package/lib/lab-check.mjs +18 -3
- package/lib/lab.mjs +29 -15
- package/lib/library.mjs +2 -2
- package/lib/mcp-tools.mjs +23 -3
- package/lib/parts-build.mjs +4 -3
- package/lib/parts-cli.mjs +3 -3
- package/lib/parts-store.mjs +8 -7
- package/lib/parts-tools.mjs +7 -7
- package/lib/parts.mjs +19 -14
- package/lib/perf.mjs +2 -1
- package/lib/player-image.mjs +2 -2
- package/lib/rules-build.mjs +97 -49
- package/lib/rules-check-worker.mjs +405 -0
- package/lib/rules-check.mjs +69 -0
- package/lib/rules-compiler.mjs +30 -0
- package/lib/rules-guard.mjs +26 -7
- package/lib/rules-types.mjs +167 -0
- package/lib/scaffold.mjs +21 -2
- package/lib/shop.mjs +1 -1
- package/lib/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +12 -3
- package/lib/stats.mjs +2 -1
- package/lib/studio.mjs +32 -6
- package/lib/style-board.mjs +10 -9
- package/lib/template-history.json +85 -1
- package/lib/typecheck.mjs +52 -3
- package/links/links.d.ts +3 -0
- package/links/links.mjs +16 -0
- package/netplay/NETPLAY.md +123 -6
- package/netplay/netplay.ts +289 -53
- package/netplay/rules-output.mjs +85 -0
- package/package.json +16 -4
- package/parts/PARTS.md +10 -5
- package/rules/browser.ts +100 -0
- package/rules/core.ts +159 -58
- package/rules/guard.ts +11 -4
- package/rules/host.ts +90 -15
- package/rules/math.ts +16 -0
- package/rules/pack.ts +94 -17
- package/rules/rules.ts +21 -13
- package/rules/types.ts +63 -0
- package/rules/view.ts +36 -33
- package/shop/SHOP.md +13 -0
- package/site/SITE.md +1 -1
- package/standalone/shell/shell.js +8 -6
- package/worker/account-page.mjs +4 -3
- package/worker/app-format.mjs +42 -0
- package/worker/app-records.mjs +100 -0
- package/worker/app-shell.mjs +29 -0
- package/worker/brain.mjs +2 -1
- package/worker/embed.mjs +4 -2
- package/worker/hosted.mjs +1 -1
- package/worker/index.mjs +40 -20
- package/worker/limits.mjs +41 -0
- package/worker/pages.mjs +37 -29
- package/worker/parts.mjs +11 -9
- package/worker/room.mjs +136 -63
- package/worker/schema.mjs +6 -5
- package/worker/seats.mjs +2 -2
- package/worker/shop-page.mjs +4 -3
- package/worker/site.mjs +49 -40
- package/worker/standalone.mjs +2 -2
- package/worker/version.mjs +1 -1
package/worker/parts.mjs
CHANGED
|
@@ -38,11 +38,12 @@ export async function partsIndex(env, origin) {
|
|
|
38
38
|
* Only a PUBLIC game is named (a private or invite-only game's parts are still shared, without saying where from).
|
|
39
39
|
*/
|
|
40
40
|
function entryOf(p, cat, url) {
|
|
41
|
-
const g = p.from?.game ? (cat.games ?? []).find((x) => x.id === p.from.game) : null;
|
|
41
|
+
const g = (p.from?.game ?? p.from?.app) ? (cat.games ?? []).find((x) => x.id === (p.from.game ?? p.from.app)) : null;
|
|
42
42
|
const { from, ...rest } = p;
|
|
43
43
|
return {
|
|
44
44
|
...rest,
|
|
45
|
-
...(g ? { from: {
|
|
45
|
+
...(!g && from && (from.music || from.video) ? { from: { ...(from.music ? { music: from.music } : { video: from.video }), name: from.name ?? null, studio: from.studio ?? null } } : {}),
|
|
46
|
+
...(g ? { from: { [g.kind === 'app' ? 'app' : 'game']: g.id, name: g.name ?? g.id, studio: cat.studio?.name ?? from.studio ?? null, page: `${url.origin}/${g.id}/`, [g.kind === 'app' ? 'open' : 'play']: `${url.origin}/${g.id}/${g.kind === 'app' ? 'open' : 'play'}` } } : {}),
|
|
46
47
|
add: `${url.host}/${p.id}`,
|
|
47
48
|
};
|
|
48
49
|
}
|
|
@@ -131,21 +132,21 @@ const CSS = `<style>
|
|
|
131
132
|
</style>`;
|
|
132
133
|
const head = (kicker, title, lead, extra = '') => `<header class="head"><p class="kicker">${esc(kicker)}</p><h1>${esc(title)}</h1>${lead ? `<p class="lead">${esc(lead)}</p>` : ''}${extra}</header>`;
|
|
133
134
|
const studioOf = (cat) => cat.studio?.name ?? 'Studio';
|
|
134
|
-
const fromLine = (p) => (p.from?.game ? `From <a href="/${esc(p.from.game)}/">${esc(p.from.name ?? p.from.game)}</a>` : '');
|
|
135
|
+
const fromLine = (p) => ((p.from?.game ?? p.from?.app) ? `From <a href="/${esc((p.from.game ?? p.from.app))}/">${esc(p.from.name ?? (p.from.game ?? p.from.app))}</a>` : '');
|
|
135
136
|
/** What a person says to their AI to get the part, in plain words with its name and reference: no command to type. */
|
|
136
137
|
const sayAdd = (p) => `Add the "${p.name}" part from ${p.add} to my game`;
|
|
137
138
|
const sayRow = (label, say) => `<div class="psay"><div><span class="ptag">${esc(label)}</span><code>${esc(say)}</code></div><button class="pcopy" type="button" data-copy="${esc(say)}">${icon('copy')}<span data-copy-word>Copy</span></button></div>`;
|
|
138
139
|
|
|
139
140
|
function card(p) {
|
|
140
141
|
const img = p.preview?.image ? `<img src="${esc(`${p.url}${p.preview.image}`)}" alt="" loading="lazy">` : '';
|
|
141
|
-
return `<li><a class="pcard" href="/parts/${esc(p.id)}/">${img}<span class="ptag">${esc(kindOf(p))} · ${esc(p.license ?? '')}</span><h3>${esc(p.name)}</h3><p>${esc(p.summary ?? '')}</p>${p.from?.game ? `<p>From ${esc(p.from.name ?? p.from.game)}</p>` : ''}</a></li>`;
|
|
142
|
+
return `<li><a class="pcard" href="/parts/${esc(p.id)}/">${img}<span class="ptag">${esc(kindOf(p))} · ${esc(p.license ?? '')}</span><h3>${esc(p.name)}</h3><p>${esc(p.summary ?? '')}</p>${(p.from?.game ?? p.from?.app) ? `<p>From ${esc(p.from.name ?? (p.from.game ?? p.from.app))}</p>` : ''}</a></li>`;
|
|
142
143
|
}
|
|
143
144
|
|
|
144
145
|
export function partsPage(cat, parts, url) {
|
|
145
146
|
const name = studioOf(cat);
|
|
146
147
|
// Grouped by the game each came out of: a mashup starts from "the creature in that game".
|
|
147
148
|
const groups = new Map();
|
|
148
|
-
for (const p of parts) { const k = p.from?.game ?? ''; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(p); }
|
|
149
|
+
for (const p of parts) { const k = (p.from?.game ?? p.from?.app) ?? ''; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(p); }
|
|
149
150
|
const body = [...groups].sort(([a], [b]) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b))).map(([k, list]) => `<p class="sec"${k ? ` id="from-${esc(k)}"` : ''}>${k ? `From ${esc(list[0].from.name ?? k)}` : 'More parts'}</p><ul class="pgrid">${list.map(card).join('')}</ul>`).join('');
|
|
150
151
|
return layout(cat, {
|
|
151
152
|
title: `Game parts · ${name}`, description: `Pieces of ${name}'s games that other studios can use in their own: creatures, levels, mechanics, sounds. Each with its licence.`, origin: url.origin, path: '/parts/', page: 'parts', head: CSS,
|
|
@@ -213,7 +214,8 @@ export function partPage(cat, p, url) {
|
|
|
213
214
|
: p.preview?.image ? `<img class="pframe" src="${esc(`${p.url}${p.preview.image}`)}" alt="${esc(p.name)}">` : '';
|
|
214
215
|
const facts = [
|
|
215
216
|
['Kind', esc(kindOf(p))],
|
|
216
|
-
p.
|
|
217
|
+
p.uses?.length ? ['For', esc(p.uses.join(', '))] : null,
|
|
218
|
+
(p.from?.game ?? p.from?.app) ? [p.from.app ? 'From the app' : 'From the game', `<a href="/${esc((p.from.game ?? p.from.app))}/">${esc(p.from.name ?? (p.from.game ?? p.from.app))}</a> · <a href="/${esc((p.from.game ?? p.from.app))}/${p.from.app ? 'open' : 'play'}">${p.from.app ? 'Open it' : 'Play it'}</a>`] : null,
|
|
217
219
|
['Licence', `${esc(p.license ?? 'none named')}${p.license && !/^LicenseRef-/.test(p.license) ? ` · <a href="https://spdx.org/licenses/${esc(p.license)}.html" rel="noopener">what it allows</a>` : ''}`],
|
|
218
220
|
p.attribution ? ['Credit', esc(p.attribution)] : null,
|
|
219
221
|
['Version', `${esc(p.version)}${(p.versions ?? []).length > 1 ? ` (also ${esc(p.versions.filter((v) => v !== p.version).join(', '))})` : ''}`],
|
|
@@ -228,7 +230,7 @@ export function partPage(cat, p, url) {
|
|
|
228
230
|
return layout(cat, {
|
|
229
231
|
title: `${p.name} · a game part from ${name}`, description: `${p.summary ?? ''} A ${kindOf(p).toLowerCase()} from ${name}${p.from?.name ? `'s ${p.from.name}` : ''}, shared under ${p.license ?? 'its own terms'}.`.trim(), origin: url.origin, path: `/parts/${p.id}/`, page: 'part', head: CSS,
|
|
230
232
|
image: p.preview?.image ? `${p.url}${p.preview.image}` : null,
|
|
231
|
-
main: `${head(`${kindOf(p)} ·
|
|
233
|
+
main: `${head(`${kindOf(p)} · shared part`, p.name, p.summary ?? '', `<div class="keys"><a class="ghost" href="/parts/">${icon('arrow')}<span>All parts</span></a>${(p.from?.game ?? p.from?.app) ? `<a class="ghost" href="/${esc((p.from.game ?? p.from.app))}/${p.from.app ? 'open' : 'play'}">${icon('games')}<span>${p.from.app ? 'Open' : 'Play'} ${esc(p.from.name ?? (p.from.game ?? p.from.app))}</span></a>` : ''}</div>`)}
|
|
232
234
|
<div class="wrap">
|
|
233
235
|
${preview ? `<p class="sec">Try it</p>${preview}` : ''}
|
|
234
236
|
<p class="sec">Use it in your game</p>
|
|
@@ -248,7 +250,7 @@ ${sayRow('Say', sayAdd(p))}
|
|
|
248
250
|
* build something new from them. Empty when the game shared none. It stands by itself: its own markup and styles.
|
|
249
251
|
*/
|
|
250
252
|
export function partsBand(g, parts, host) {
|
|
251
|
-
const mine = parts.filter((p) => p.from?.game === g.id);
|
|
253
|
+
const mine = parts.filter((p) => (p.from?.game ?? p.from?.app) === g.id);
|
|
252
254
|
if (!mine.length) return '';
|
|
253
255
|
const say = mine.length === 1 ? sayAdd({ ...mine[0], add: `${host}/${mine[0].id}` }) : `Make a new game in my Homie studio with these parts of ${g.name}: ${mine.slice(0, 4).map((p) => `${host}/${p.id}`).join(', ')}`;
|
|
254
256
|
return `${CSS}<section class="pband" aria-labelledby="parts-title" data-parts-band>
|
|
@@ -266,7 +268,7 @@ export async function withPartsBand(res, env, url, g) {
|
|
|
266
268
|
try {
|
|
267
269
|
if (!res || res.status !== 200 || !/text\/html/i.test(res.headers.get('content-type') ?? '')) return res;
|
|
268
270
|
const index = await partsIndex(env, url.origin);
|
|
269
|
-
const band = partsBand(g, index.parts.map((p) => ({ ...p, from: p.from?.game === g.id ? { ...p.from, name: g.name } : p.from })), url.host);
|
|
271
|
+
const band = partsBand(g, index.parts.map((p) => ({ ...p, from: (p.from?.game ?? p.from?.app) === g.id ? { ...p.from, name: g.name } : p.from })), url.host);
|
|
270
272
|
if (!band) return res;
|
|
271
273
|
const html = await res.text();
|
|
272
274
|
const at = html.lastIndexOf('</main>');
|
package/worker/room.mjs
CHANGED
|
@@ -49,13 +49,15 @@
|
|
|
49
49
|
* who came and went) and fans out each frame it hands back (`hostFrame`). Everything else here is as it was.
|
|
50
50
|
* =============================================================================
|
|
51
51
|
*/
|
|
52
|
+
import { LIMITS, capOf, RATES, EV_RATES, STATE_CAPS, rulesRates, RULES_FRAMES, takeRulesToken } from './limits.mjs';
|
|
53
|
+
export { LIMITS, GROWS, capOf } from './limits.mjs';
|
|
52
54
|
import { aiName, skillPreset, stripAi } from './agents.mjs';
|
|
53
55
|
import { DECIDE, checkArgs, checkDecide, talks, vocabularyOf } from './brain.mjs';
|
|
54
56
|
import { CHAT_LIMITS, CHAT_RATES, HELD_WORDS, allows, cleanText, floor, normalizeChat, publicChat, windowMsOf } from './chat.mjs';
|
|
55
57
|
|
|
56
58
|
export const NET_VERSION = 1;
|
|
57
59
|
/** The contract revision this relay speaks (NETPLAY.md): optional fields, frames and refusals; the wire stays `v: 1`. */
|
|
58
|
-
export const NET_REVISION =
|
|
60
|
+
export const NET_REVISION = 11;
|
|
59
61
|
/**
|
|
60
62
|
* Revision 9 (NETPLAY.md sections 22 and 23). STALL: how long a host may send no snapshot, while others are present,
|
|
61
63
|
* before the room is handed on: 1.5 s unless the game names its own (game.json `netplay.stallMs`), never under
|
|
@@ -143,30 +145,13 @@ export function medianVote(list) {
|
|
|
143
145
|
const v = [...list].filter(Number.isFinite).sort((a, b) => a - b);
|
|
144
146
|
return v.length ? v[Math.floor((v.length - 1) / 2)] : null;
|
|
145
147
|
}
|
|
148
|
+
const utf8 = new TextEncoder();
|
|
149
|
+
const byteLength = text => utf8.encode(text).length;
|
|
146
150
|
const PALETTE_SIZE = 12;
|
|
147
151
|
const DEVICE_RANK = { desk: 0, tv: 1, phone: 2 };
|
|
148
152
|
|
|
149
|
-
/** Size caps (bytes of the JSON text as received), for a room of up to 16 seats. */
|
|
150
|
-
export const LIMITS = Object.freeze({
|
|
151
|
-
hello: 2048, snap: 16384, in: 2048, ev: 4096, ckpt: 65536, state: 8192, round: 8192, roster: 4096, ping: 256, other: 512,
|
|
152
|
-
// Room chat (revision 8): a typed line (280 characters, emoji count double), a reaction.
|
|
153
|
-
say: 1536, react: 256,
|
|
154
|
-
// A game's own decision (section 20): a few typed questions about its state.
|
|
155
|
-
decide: DECIDE.bytes,
|
|
156
|
-
});
|
|
157
|
-
/**
|
|
158
|
-
* What grows with the seats (every body, every result, every slot): the checkpoint, round and roster caps double
|
|
159
|
-
* for a room of 17 to 32 (revision 3). Measured: a 32-seat courier game's checkpoint reached 56 KB of the 64 KB
|
|
160
|
-
* a 16-seat room allows. The snapshot cap does not grow: 32 bodies are about 1.5 KB.
|
|
161
|
-
*/
|
|
162
|
-
export const GROWS = Object.freeze(['ckpt', 'round', 'roster']);
|
|
163
|
-
export const capOf = (t, seats) => (LIMITS[t] ?? LIMITS.other) * (GROWS.includes(t) && seats > 16 ? 2 : 1);
|
|
164
153
|
/** Rate caps (messages per rolling second, per client). Over the cap a message is dropped, counted and reported. */
|
|
165
|
-
export
|
|
166
|
-
/** `ev` per second by the sender's role: a screen is a spectator, and the host is somebody's phone. */
|
|
167
|
-
export const EV_RATES = Object.freeze({ host: 30, replica: 10, screen: 2 });
|
|
168
|
-
/** The keyed state channel: at most this many keys and bytes per room. */
|
|
169
|
-
export const STATE_CAPS = Object.freeze({ keys: 64, bytes: 65536 });
|
|
154
|
+
export { RATES, EV_RATES, STATE_CAPS } from './limits.mjs';
|
|
170
155
|
|
|
171
156
|
/**
|
|
172
157
|
* THE OWNER'S CONTROLS (revision 4, NETPLAY.md section 15). The studio's Worker, acting for the signed-in owner,
|
|
@@ -272,7 +257,7 @@ export class NetRoom {
|
|
|
272
257
|
* each stamped `at` (ISO time) with its room; never a token, a ticket, a browser key or a frame's contents
|
|
273
258
|
*/
|
|
274
259
|
constructor({
|
|
275
|
-
code, maxPlayers = 8, maxScreens = 16, perIp = 12, stallMs = 1500, silentMs = 2500, idleMs = 10_000,
|
|
260
|
+
code, rules = false, tickHz = 20, maxPlayers = 8, maxScreens = 16, perIp = 12, stallMs = 1500, silentMs = 2500, idleMs = 10_000,
|
|
276
261
|
holdMs = 60_000, forgetMs = 60_000, persistMs = 30_000, store = null, now = Date.now, log = () => {},
|
|
277
262
|
agentsAloneMs = AGENTS_ALONE_MS, voteMs = VOTE.ms, voteCooldownMs = VOTE.cooldownMs, autoVoteMs = VOTE.autoMs,
|
|
278
263
|
}) {
|
|
@@ -294,6 +279,8 @@ export class NetRoom {
|
|
|
294
279
|
/** Revision 7: the game's agents.json (the Table reads it once), the only words an AI here may say. */
|
|
295
280
|
this.vocab = null;
|
|
296
281
|
this.code = code;
|
|
282
|
+
this.rules = rules === true;
|
|
283
|
+
this.tickHz = tickHz;
|
|
297
284
|
this.seatCap = maxPlayers;
|
|
298
285
|
this.maxPlayers = maxPlayers;
|
|
299
286
|
this.maxScreens = maxScreens;
|
|
@@ -431,12 +418,11 @@ export class NetRoom {
|
|
|
431
418
|
|
|
432
419
|
/** A seat the host runtime knows that is no longer held (its hold ran out, the owner removed its player): its body's seat is given up. */
|
|
433
420
|
syncServerSeats() {
|
|
434
|
-
if (!this.server) return;
|
|
435
421
|
for (const [seat, occ] of [...this.hosted]) {
|
|
436
422
|
const s = this.seats.get(seat);
|
|
437
423
|
if (s && (s.occ ?? null) === occ) continue;
|
|
438
424
|
this.hosted.delete(seat);
|
|
439
|
-
this.toServer({ t: 'free', seat });
|
|
425
|
+
if (this.server) this.toServer({ t: 'free', seat }); else if (this.rules && this.host()?.rules) this.send(this.host(), { t: 'free', seat });
|
|
440
426
|
}
|
|
441
427
|
}
|
|
442
428
|
|
|
@@ -458,7 +444,7 @@ export class NetRoom {
|
|
|
458
444
|
const kind = String(m.k ?? '').slice(0, 64);
|
|
459
445
|
if (SPEECH.test(kind) && (this.policy.speech === 'off' || (this.policy.speech === 'lines' && /^chat/i.test(kind)))) { this.stats.speechDrops += 1; return; }
|
|
460
446
|
if (d0(m).ai === true && !this.aiLineOk(kind, d0(m))) { this.stats.agentDrops += 1; return; }
|
|
461
|
-
if (kind === 'agent:view' && !this.agentViewOk(m, JSON.stringify(m)
|
|
447
|
+
if (kind === 'agent:view' && !this.agentViewOk(m, byteLength(JSON.stringify(m)), now, true)) return;
|
|
462
448
|
this.stats.evs += 1;
|
|
463
449
|
const out = { t: 'ev', from: Number.isInteger(m.from) ? m.from : null, k: kind, d: m.d ?? null };
|
|
464
450
|
const line = JSON.stringify(out);
|
|
@@ -476,7 +462,7 @@ export class NetRoom {
|
|
|
476
462
|
const snap = { k: Number(m.k) || 0, st: c ? this.stamp(m.st) : Number(m.st) || now, d: m.d ?? null };
|
|
477
463
|
if (Array.isArray(m.c)) snap.c = m.c.slice(0, 64);
|
|
478
464
|
// Revision 10: a rules game's snapshot names the room's epoch.
|
|
479
|
-
if (!c && Number.isFinite(m.e)) snap.e = m.e;
|
|
465
|
+
if ((!c || this.rules && c.rules) && Number.isFinite(m.e)) snap.e = m.e;
|
|
480
466
|
this.lastSnap = snap;
|
|
481
467
|
if (c) c.lastSnapAt = now;
|
|
482
468
|
this.stats.snaps += 1;
|
|
@@ -507,6 +493,7 @@ export class NetRoom {
|
|
|
507
493
|
} else {
|
|
508
494
|
if (c && ((!prev && this.state.size >= STATE_CAPS.keys) || this.stateBytes - (prev?.bytes ?? 0) + bytes > STATE_CAPS.bytes)) {
|
|
509
495
|
this.error(c, 'state-full', `state is capped at ${STATE_CAPS.keys} keys and ${STATE_CAPS.bytes} B`);
|
|
496
|
+
this.stopBrowserRules(c, 'size', `This game's rules exceeded the browser room state cap: ${STATE_CAPS.keys} keys and ${STATE_CAPS.bytes} B. Join again for a fresh room.`);
|
|
510
497
|
return;
|
|
511
498
|
}
|
|
512
499
|
this.state.set(key, { d: m.d, bytes });
|
|
@@ -515,13 +502,14 @@ export class NetRoom {
|
|
|
515
502
|
this.stats.states += 1;
|
|
516
503
|
this.persistDirty = true;
|
|
517
504
|
const out = JSON.stringify({ t: 'state', k: key, d: m.d ?? null });
|
|
518
|
-
for (const o of this.others(c)) if (!this.lite(o)) this.sendText(o.conn, out);
|
|
505
|
+
for (const o of (c && this.rules && c.rules ? this.live() : this.others(c))) if (!this.lite(o)) this.sendText(o.conn, out);
|
|
519
506
|
}
|
|
520
507
|
|
|
521
508
|
hostRound(c, m) {
|
|
522
509
|
if (!m.round || typeof m.round !== 'object') return;
|
|
510
|
+
const retime = Boolean(m.retime && (this.server || this.rules && c?.rules));
|
|
523
511
|
// The results name every AI as AI, whatever the host's game says (section 17).
|
|
524
|
-
const round = Array.isArray(m.round.results) ? { ...m.round, results: this.labelResults(m.round.results) } : m.round;
|
|
512
|
+
const round = retime && this.lastRound?.n === m.round.n && this.lastRound?.phase === m.round.phase ? { ...m.round, ...(this.lastRound.results ? { results: this.lastRound.results } : {}) } : Array.isArray(m.round.results) ? { ...m.round, results: this.labelResults(m.round.results) } : m.round;
|
|
525
513
|
const wasLive = this.lastRound && this.lastRound.phase === 'live' ? Number(this.lastRound.n) : null;
|
|
526
514
|
this.lastRound = round;
|
|
527
515
|
// The round a launch change waits for is over: everyone sees its results for a moment, then the room re-gates.
|
|
@@ -529,18 +517,22 @@ export class NetRoom {
|
|
|
529
517
|
if (this.agentsOut && round.phase === 'over' && (this.agentsOut.roundN === null || Number(round.n) >= this.agentsOut.roundN)) this.agentsOut.until = Math.min(this.agentsOut.until, this.now() + 5000);
|
|
530
518
|
this.stats.rounds += 1;
|
|
531
519
|
this.persistDirty = true;
|
|
532
|
-
for (const o of this.others(c)) this.send(o, { t: 'round', round });
|
|
520
|
+
for (const o of (c && this.rules && c.rules ? this.live() : this.others(c))) this.send(o, { t: 'round', round: retime ? m.round : round, ...(retime ? { retime: true } : {}) });
|
|
533
521
|
// A party's first live round with AI seats: the vote on how strong the AI should be (once per 30 minutes).
|
|
534
522
|
if (round.phase === 'live' && wasLive !== Number(round.n)) this.autoVote();
|
|
535
523
|
this.tellWatchers();
|
|
536
524
|
}
|
|
537
525
|
|
|
526
|
+
heldPeers() {
|
|
527
|
+
return [...this.seats].filter(([, s]) => !s.present).map(([seat, s]) => ({ id: `held-${s.occ}`, seat, occ: s.occ, name: s.name, ...(s.agent ? { agent: s.agent } : {}) }));
|
|
528
|
+
}
|
|
529
|
+
|
|
538
530
|
hostRoster(c, m) {
|
|
539
531
|
if (!Array.isArray(m.slots)) return;
|
|
540
532
|
// A host cannot hide an AI: a seat an agent holds is named and marked so, a slot with no seat is a bot.
|
|
541
533
|
this.lastRoster = this.labelRoster(m.slots.slice(0, 64));
|
|
542
534
|
this.persistDirty = true;
|
|
543
|
-
for (const o of this.others(c)) this.send(o, { t: 'roster', slots: this.lastRoster });
|
|
535
|
+
for (const o of (c && this.rules && c.rules ? this.live() : this.others(c))) this.send(o, { t: 'roster', slots: this.lastRoster });
|
|
544
536
|
this.tellWatchers();
|
|
545
537
|
}
|
|
546
538
|
|
|
@@ -646,7 +638,7 @@ export class NetRoom {
|
|
|
646
638
|
/* ------------------------------------------------------------ wire */
|
|
647
639
|
|
|
648
640
|
sendText(conn, text) {
|
|
649
|
-
try { conn.send(text); this.stats.bytesOut += text.length; } catch { /* closing */ }
|
|
641
|
+
try { conn.send(text); this.stats.bytesOut += this.rules ? byteLength(text) : text.length; } catch { /* closing */ }
|
|
650
642
|
}
|
|
651
643
|
send(c, msg) { this.sendText(c.conn, JSON.stringify(msg)); }
|
|
652
644
|
error(c, code, message, extra = {}) { this.send(c, { t: 'error', code, message, ...extra }); }
|
|
@@ -694,7 +686,7 @@ export class NetRoom {
|
|
|
694
686
|
onDecide(c, m, isHost) {
|
|
695
687
|
const n = typeof m.n === 'string' || Number.isInteger(m.n) ? String(m.n).slice(0, 16) : '';
|
|
696
688
|
const server = this.server;
|
|
697
|
-
const reply = (o) => { const frame = { t: 'decided', n, ...o }; if (c) this.send(
|
|
689
|
+
const reply = (o) => { const frame = { t: 'decided', n, ...o }; if (c) { const recipient = this.rules && isHost ? this.host() : c; if (recipient) this.send(recipient, frame); } else if (server && this.server === server) this.toServer(frame); };
|
|
698
690
|
if (!isHost || (c && this.lite(c))) return reply({ ok: false, why: 'not-host' });
|
|
699
691
|
if (typeof this.decider !== 'function') return reply({ ok: false, why: 'off' });
|
|
700
692
|
const now = this.now();
|
|
@@ -726,22 +718,55 @@ export class NetRoom {
|
|
|
726
718
|
try { c.conn.close(code, why); } catch { /* gone */ }
|
|
727
719
|
}
|
|
728
720
|
|
|
729
|
-
|
|
721
|
+
stopBrowserRules(c, why, message) {
|
|
722
|
+
if (!this.rules || !c.rules || c.id !== this.hostId) return false;
|
|
723
|
+
this.log({ ev: 'host-ended', room: this.code, why });
|
|
724
|
+
for (const peer of this.live()) {
|
|
725
|
+
this.error(peer, 'room-over', message);
|
|
726
|
+
this.clients.delete(peer.id);
|
|
727
|
+
try { peer.conn.close(1011, 'room-over'); } catch { /* gone */ }
|
|
728
|
+
}
|
|
729
|
+
this.forget();
|
|
730
|
+
return true;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
allow(c, t, bytes, view = false) {
|
|
730
734
|
// A spectator screen has little to say: its events are capped at 512 B (16 screens x 2/s x 512 B at most reach the host).
|
|
731
|
-
const cap = t === 'ev' && c.seat === null && c.id !== this.hostId ? 512 : capOf(t, this.seatCap);
|
|
732
|
-
|
|
735
|
+
const cap = t === 'ev' && c.seat === null && (view || c.id !== this.hostId) ? 512 : capOf(t, this.seatCap);
|
|
736
|
+
const rulesOutput = !view && this.rules && c.rules && c.id === this.hostId;
|
|
737
|
+
if (bytes > cap) {
|
|
738
|
+
this.stats.oversize += 1;
|
|
739
|
+
const detail = `${t} is ${bytes} B; the cap is ${cap} B`;
|
|
740
|
+
this.error(c, 'too-large', detail, view ? { view: true } : {});
|
|
741
|
+
if (rulesOutput) this.stopBrowserRules(c, 'size', `This game's rules exceeded the browser room size cap: ${detail}. Join again for a fresh room.`);
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
733
744
|
const now = this.now();
|
|
734
|
-
|
|
735
|
-
|
|
745
|
+
if (rulesOutput) {
|
|
746
|
+
c.rulesBuckets ??= new Map();
|
|
747
|
+
const rate = rulesRates(this.tickHz, this.seatCap)[t];
|
|
748
|
+
if (!takeRulesToken(c.rulesBuckets, t, rate, now)) return true;
|
|
749
|
+
this.stats.drops += 1;
|
|
750
|
+
if (t === 'snap') return false;
|
|
751
|
+
const message = `The hosting page exceeded its ${t} allowance. Moving the rules to another player.`;
|
|
752
|
+
for (const peer of this.live()) this.send(peer, { t: 'error', code: 'host-fault', message });
|
|
753
|
+
c.canHost = false;
|
|
754
|
+
if (this.elect(c.id, 'host-fault')) this.send(c, { t: 'role', role: c.seat === null ? 'screen' : 'replica', why: 'host-fault', host: this.hostRef(), peers: this.peers() });
|
|
755
|
+
else { this.hostId = c.id; this.stopBrowserRules(c, 'rate', 'No player can host this room after its host failed. Join again for a fresh room.'); }
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
const limit = t === 'ev' ? EV_RATES[view ? (c.seat === null ? 'screen' : 'replica') : this.roleOf(c)] : (RATES[t] ?? RATES.other);
|
|
759
|
+
const rateKey = view ? `${t}:view` : t;
|
|
760
|
+
const list = c.rates.get(rateKey) ?? [];
|
|
736
761
|
while (list.length && list[0] <= now - 1000) list.shift();
|
|
737
|
-
c.rates.set(
|
|
762
|
+
c.rates.set(rateKey, list);
|
|
738
763
|
if (list.length >= limit) {
|
|
739
764
|
this.stats.drops += 1;
|
|
740
765
|
c.drops.push(now);
|
|
741
766
|
while (c.drops.length && c.drops[0] <= now - 5000) c.drops.shift();
|
|
742
767
|
// Sustained abuse (20/s over a cap for 5 s) ends the socket; anything less is reported once a second.
|
|
743
768
|
if (c.drops.length > 100) { this.error(c, 'flood', 'too many messages over the rate caps'); this.kick(c, 'flood'); return false; }
|
|
744
|
-
if (now - c.errAt >= 1000) { c.errAt = now; this.send(c, { t: 'error', code: 'rate', of: t, message: `${t} over ${limit}/s was dropped
|
|
769
|
+
if (now - c.errAt >= 1000) { c.errAt = now; this.send(c, { t: 'error', code: 'rate', of: t, message: `${t} over ${limit}/s was dropped`, ...(view ? { view: true } : {}) }); }
|
|
745
770
|
return false;
|
|
746
771
|
}
|
|
747
772
|
list.push(now);
|
|
@@ -753,14 +778,27 @@ export class NetRoom {
|
|
|
753
778
|
text = String(text);
|
|
754
779
|
const now = this.now();
|
|
755
780
|
c.lastSeen = now;
|
|
756
|
-
this.
|
|
781
|
+
const bytes = this.rules ? byteLength(text) : text.length;
|
|
782
|
+
this.stats.bytesIn += bytes;
|
|
757
783
|
let m;
|
|
758
784
|
try { m = JSON.parse(text); } catch { return; }
|
|
759
785
|
if (!m || typeof m.t !== 'string') return;
|
|
760
|
-
if (!c.helloed) { if (m.t === 'hello' &&
|
|
761
|
-
|
|
762
|
-
|
|
786
|
+
if (!c.helloed) { if (m.t === 'hello' && bytes <= LIMITS.hello) this.hello(c, m); return; }
|
|
787
|
+
const view = this.rules && !(c.rules && c.id === this.hostId && m.rules === true && RULES_FRAMES.includes(m.t));
|
|
788
|
+
if (!this.allow(c, m.t, bytes, view)) return;
|
|
789
|
+
const isHost = c.id === this.hostId && !view;
|
|
763
790
|
switch (m.t) {
|
|
791
|
+
case 'rules-end': {
|
|
792
|
+
if (!isHost || !c.rules || !this.rules) return;
|
|
793
|
+
const why = ['budget', 'overrun', 'fault', 'clock', 'load', 'size', 'rate'].includes(m.why) ? m.why : 'fault';
|
|
794
|
+
for (const peer of this.live()) {
|
|
795
|
+
this.error(peer, 'room-over', `This game's rules stopped: ${why}. Join again for a fresh room.`);
|
|
796
|
+
this.clients.delete(peer.id);
|
|
797
|
+
try { peer.conn.close(1011, 'room-over'); } catch { /* gone */ }
|
|
798
|
+
}
|
|
799
|
+
this.forget();
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
764
802
|
case 'snap': {
|
|
765
803
|
if (!isHost) return;
|
|
766
804
|
return this.hostSnap(c, m, now);
|
|
@@ -778,6 +816,7 @@ export class NetRoom {
|
|
|
778
816
|
const h = this.host();
|
|
779
817
|
if (!h) return;
|
|
780
818
|
this.stats.ins += 1;
|
|
819
|
+
if (h.rules && Array.isArray(m.s)) { this.send(h, { t: 'in', from: c.seat, e: Number(m.e) || 0, k: Number(m.k) || 0, s: m.s.slice(0, 64), r: Number(m.r) || 0 }); return; }
|
|
781
820
|
const out = { t: 'in', from: c.seat, q: Number(m.q) || 0, a: m.a ?? null, h: Array.isArray(m.h) ? m.h.slice(0, 16).map((x) => String(x).slice(0, 32)) : [] };
|
|
782
821
|
if (Number.isFinite(m.r)) out.r = m.r;
|
|
783
822
|
const p = clampPresses(m.p);
|
|
@@ -793,7 +832,7 @@ export class NetRoom {
|
|
|
793
832
|
// drops all speech, whoever sends it (a host relaying a player's chat too).
|
|
794
833
|
if (SPEECH.test(kind) && (this.policy.speech === 'off' || (this.policy.speech === 'lines' && /^chat/i.test(kind)))) { this.stats.speechDrops += 1; return; }
|
|
795
834
|
// What an AI may send (section 18): its own goals and its game's lines, checked here whatever its host says.
|
|
796
|
-
if (c.agent && !this.agentEvOk(c, kind, m,
|
|
835
|
+
if (c.agent && !this.agentEvOk(c, kind, m, bytes, now)) { this.stats.agentDrops += 1; return; }
|
|
797
836
|
// An agent says at most one line every 4 s and 8 a minute.
|
|
798
837
|
if (c.agent && SPEECH.test(kind)) {
|
|
799
838
|
while (c.speechAt.length && c.speechAt[0] <= now - 60_000) c.speechAt.shift();
|
|
@@ -803,14 +842,16 @@ export class NetRoom {
|
|
|
803
842
|
// A line the host relays for an AI (`d.ai`): still only a line of the vocabulary, with fitting arguments.
|
|
804
843
|
if (isHost && d0(m).ai === true && !this.aiLineOk(kind, d0(m))) { this.stats.agentDrops += 1; return; }
|
|
805
844
|
// The host shows one AI the game (`agent:view`): to that AI's seat only, small, at most one every 2 s.
|
|
806
|
-
if (kind === 'agent:view' && !this.agentViewOk(m,
|
|
845
|
+
if (kind === 'agent:view' && !this.agentViewOk(m, bytes, now, isHost)) return;
|
|
807
846
|
this.stats.evs += 1;
|
|
808
|
-
const
|
|
847
|
+
const speaker = isHost && this.rules && c.rules ? (Number.isInteger(m.from) && this.live().some(p => p.seat === m.from && !p.agent) ? m.from : null) : c.seat;
|
|
848
|
+
const out = { t: 'ev', from: speaker, k: kind, d: m.d ?? null, ...(isHost && this.rules && c.rules ? { hosted: true } : {}) };
|
|
849
|
+
const recipients = isHost && this.rules && c.rules ? this.live() : this.others(c);
|
|
809
850
|
if (isHost) {
|
|
810
|
-
if (typeof m.to === 'string') { const o = this.clients.get(m.to); if (o && o.helloed && o !== c) this.send(o, out); }
|
|
811
|
-
else if (Number.isInteger(m.to)) { for (const o of
|
|
851
|
+
if (typeof m.to === 'string') { const o = this.clients.get(m.to); if (o && o.helloed && (o !== c || (this.rules && c.rules))) this.send(o, out); }
|
|
852
|
+
else if (Number.isInteger(m.to)) { for (const o of recipients) if (o.seat === m.to) this.send(o, out); }
|
|
812
853
|
// An agent with no game client hears only what is addressed to its seat (the lite feed), and the party's lines.
|
|
813
|
-
else for (const o of
|
|
854
|
+
else for (const o of recipients) { if (!this.lite(o) || this.liteHears(kind)) this.send(o, out); }
|
|
814
855
|
} else {
|
|
815
856
|
const h = this.host();
|
|
816
857
|
if (this.server) this.toServer({ ...out, id: c.id });
|
|
@@ -830,7 +871,7 @@ export class NetRoom {
|
|
|
830
871
|
}
|
|
831
872
|
case 'state': {
|
|
832
873
|
if (!isHost) return;
|
|
833
|
-
return this.hostState(c, m,
|
|
874
|
+
return this.hostState(c, m, bytes);
|
|
834
875
|
}
|
|
835
876
|
case 'round': {
|
|
836
877
|
if (!isHost) return;
|
|
@@ -855,15 +896,23 @@ export class NetRoom {
|
|
|
855
896
|
return;
|
|
856
897
|
}
|
|
857
898
|
case 'ping': {
|
|
899
|
+
if (this.rules && c.rules && Number.isFinite(m.readySpeed)) { c.readySpeed = Math.max(0, Math.min(1, m.readySpeed)); c.readyAt = now; }
|
|
858
900
|
const wasHidden = c.hidden;
|
|
859
901
|
c.hidden = m.hid === true;
|
|
860
902
|
this.send(c, { t: 'pong', c: m.c, st: now });
|
|
861
903
|
// A host whose tab went to the background will stop ticking: hand the round on now.
|
|
862
|
-
if (
|
|
904
|
+
if (c.id === this.hostId && c.hidden && !wasHidden) this.yieldHost(c, 'host-hidden');
|
|
863
905
|
return;
|
|
864
906
|
}
|
|
865
907
|
case 'yield': {
|
|
866
|
-
if (
|
|
908
|
+
if (c.id === this.hostId && this.rules && c.rules && m.slow === true) {
|
|
909
|
+
c.rulesSpeed = Math.max(0, Math.min(1, Number(m.speed) || 0));
|
|
910
|
+
// Both a recent event-loop measurement and any observed hosting speed must be substantially faster.
|
|
911
|
+
if (this.live().some(p => p !== c && p.canHost && this.fasterRulesHost(p, c))) this.yieldHost(c, 'host-slow');
|
|
912
|
+
} else if (c.id === this.hostId) this.yieldHost(c, 'host-yielded');
|
|
913
|
+
// A rules host holds its output from its yield on: it is told when the role stays with it (and the others,
|
|
914
|
+
// who were just told nobody hosts, are told who does).
|
|
915
|
+
if (this.rules && c.rules && c.id === this.hostId) for (const o of this.live()) this.send(o, { t: 'host', host: this.hostRef(), why: 'host-kept' });
|
|
867
916
|
return;
|
|
868
917
|
}
|
|
869
918
|
case 'bye': {
|
|
@@ -958,18 +1007,21 @@ export class NetRoom {
|
|
|
958
1007
|
}
|
|
959
1008
|
if (this.perIp && c.ip && live.filter((o) => o.ip === c.ip).length >= this.perIp) return refuse('too-many', `at most ${this.perIp} sockets per address in one room`);
|
|
960
1009
|
if (live.length >= this.maxPlayers + this.maxScreens) return refuse('room-full', 'this room is full; try another');
|
|
1010
|
+
c.rules = m.rules === true;
|
|
961
1011
|
c.device = m.device === 'phone' || m.device === 'tv' ? m.device : 'desk';
|
|
962
1012
|
// A watcher is a screen that never takes a seat, whatever else its hello says. An agent always plays.
|
|
963
1013
|
c.watch = watching;
|
|
964
1014
|
c.want = c.agent ? 'play' : watching || m.want === 'screen' ? 'screen' : 'play';
|
|
965
1015
|
// An agent with no game client never hosts; one that runs the game hosts only a room no person can (DESIGN D10).
|
|
966
1016
|
c.canHost = c.agent ? c.agent.hands === 'self' && m.canHost !== false : m.canHost !== false;
|
|
1017
|
+
// Rules output is marked since revision 11: an older page's would be taken for a player's, so it never hosts.
|
|
1018
|
+
if (this.rules && c.rules && !(Number(m.rev) >= 11)) c.canHost = false;
|
|
967
1019
|
c.game = typeof m.game === 'string' ? m.game.slice(0, 64) : '';
|
|
968
1020
|
// A person's typed name never ends in an AI or bot mark; on a kids server nobody's typed name is used (handles).
|
|
969
1021
|
c.typed = c.agent || this.policy.kids ? '' : typeof m.name === 'string' ? stripAi(m.name.replace(/\s+/g, ' ').trim()).slice(0, 24) : '';
|
|
970
1022
|
if (Array.isArray(m.caps)) for (const k of m.caps) if (CAPS.includes(k)) c.caps.add(k);
|
|
971
1023
|
// The manifest's player count, from the first visitor of an empty room (the site's Table reads the manifest).
|
|
972
|
-
if (!this.server && Number.isInteger(m.max) && !this.seats.size && !live.length) { this.askedMax = m.max; this.maxPlayers = Math.max(1, Math.min(this.seatCap, m.max)); }
|
|
1024
|
+
if (!this.server && !this.rules && Number.isInteger(m.max) && !this.seats.size && !live.length) { this.askedMax = m.max; this.maxPlayers = Math.max(1, Math.min(this.seatCap, m.max)); }
|
|
973
1025
|
this.reapSeats(now);
|
|
974
1026
|
// The verified pass names one AI. Reopening its socket resumes that seat and replaces the old socket.
|
|
975
1027
|
const agentStay = c.agent?.pass ? [...this.seats.values()].find(s => s.agent?.pass === c.agent.pass) : null;
|
|
@@ -1033,6 +1085,8 @@ export class NetRoom {
|
|
|
1033
1085
|
}
|
|
1034
1086
|
}
|
|
1035
1087
|
if (role === 'host') {
|
|
1088
|
+
// Welcome contains the current holders; old occupancy notices must precede it.
|
|
1089
|
+
this.hosted = new Map([...this.seats].map(([seat, s]) => [seat, s.occ ?? null]));
|
|
1036
1090
|
this.hostId = c.id;
|
|
1037
1091
|
this.hostSince = now;
|
|
1038
1092
|
c.lastSnapAt = 0;
|
|
@@ -1047,7 +1101,7 @@ export class NetRoom {
|
|
|
1047
1101
|
this.send(c, {
|
|
1048
1102
|
t: 'welcome', v: NET_VERSION, rev: NET_REVISION, id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
|
|
1049
1103
|
...(this.gameVer ? { ver: this.gameVer } : {}), ...(behind ? { stale: { ver: this.currentVer } } : {}), stall: this.stallMs,
|
|
1050
|
-
role, why, host: this.hostRef(), peers: this.peers(), st: now, max: this.maxPlayers,
|
|
1104
|
+
role, why, host: this.hostRef(), peers: this.peers(), ...(this.rules ? { held: this.heldPeers() } : {}), st: now, max: this.maxPlayers,
|
|
1051
1105
|
round: this.lastRound, roster: this.lastRoster, snap: lite ? null : this.lastSnap, state: lite ? {} : this.stateObject(),
|
|
1052
1106
|
...(role === 'host' ? { ckpt: this.lastCkpt } : {}),
|
|
1053
1107
|
...(full ? { full: true } : {}),
|
|
@@ -1059,7 +1113,7 @@ export class NetRoom {
|
|
|
1059
1113
|
});
|
|
1060
1114
|
for (const o of this.others(c)) this.send(o, { t: 'join', peer: this.peer(c) });
|
|
1061
1115
|
// The server host hears of every seat taken (a reconnect is the same stay: its body is back, not new).
|
|
1062
|
-
if (
|
|
1116
|
+
if (c.seat !== null) { this.syncServerSeats(); this.joinServer(c); }
|
|
1063
1117
|
// A seat taken by a browser that also watches this room: its watching tab sees the overview from now on.
|
|
1064
1118
|
this.refreshWatchers(c);
|
|
1065
1119
|
if (deposed) {
|
|
@@ -1148,7 +1202,7 @@ export class NetRoom {
|
|
|
1148
1202
|
this.stats.seated += 1;
|
|
1149
1203
|
this.send(c, { t: 'seat', seat: c.seat, token: c.token, name: c.name, colour: c.colour, role: this.roleOf(c) });
|
|
1150
1204
|
for (const o of this.others(c)) this.send(o, { t: 'join', peer: this.peer(c) });
|
|
1151
|
-
|
|
1205
|
+
this.syncServerSeats(); this.joinServer(c);
|
|
1152
1206
|
this.log({ ev: 'seated', room: this.code, id: c.id, seat: c.seat });
|
|
1153
1207
|
this.persist();
|
|
1154
1208
|
this.refreshWatchers();
|
|
@@ -1542,6 +1596,11 @@ export class NetRoom {
|
|
|
1542
1596
|
this.tellWatchers();
|
|
1543
1597
|
}
|
|
1544
1598
|
|
|
1599
|
+
fasterRulesHost(candidate, current) {
|
|
1600
|
+
return !candidate.hidden && this.now() - (candidate.readyAt ?? -Infinity) <= 5000 &&
|
|
1601
|
+
Math.min(candidate.rulesSpeed ?? 1, candidate.readySpeed ?? 0) > (current?.rulesSpeed ?? 0) * 1.5 + 0.1;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1545
1604
|
/**
|
|
1546
1605
|
* Choose a host. Visible and responsive first; then tenure in 30 s steps (a newcomer cannot jump the queue by
|
|
1547
1606
|
* calling itself a desktop); then desk > tv > phone as a tie-break; then whoever came first. The chosen browser
|
|
@@ -1549,12 +1608,12 @@ export class NetRoom {
|
|
|
1549
1608
|
*/
|
|
1550
1609
|
elect(excludeId, why) {
|
|
1551
1610
|
// The server is never elected away.
|
|
1552
|
-
if (this.server) return null;
|
|
1611
|
+
if (this.server || this.hostFailed) return null;
|
|
1553
1612
|
const now = this.now();
|
|
1554
1613
|
const silent = (c) => Number(now - c.lastSeen > this.silentMs);
|
|
1555
1614
|
const tenure = (c) => Math.floor((now - c.joinedAt) / 30_000);
|
|
1556
1615
|
const cands = this.live()
|
|
1557
|
-
.filter((c) => c.canHost && c.id !== excludeId)
|
|
1616
|
+
.filter((c) => c.canHost && c.id !== excludeId && (why !== 'host-slow' || this.fasterRulesHost(c, this.clients.get(excludeId))))
|
|
1558
1617
|
.sort((a, b) => Number(a.hidden) - Number(b.hidden)
|
|
1559
1618
|
|| silent(a) - silent(b)
|
|
1560
1619
|
// An agent hosts only when no person can (DESIGN D10); a watcher is next to last: any player comes first.
|
|
@@ -1569,6 +1628,7 @@ export class NetRoom {
|
|
|
1569
1628
|
for (const o of this.live()) this.send(o, { t: 'host', host: null, why });
|
|
1570
1629
|
return null;
|
|
1571
1630
|
}
|
|
1631
|
+
this.hosted = new Map([...this.seats].map(([seat, s]) => [seat, s.occ ?? null]));
|
|
1572
1632
|
this.hostId = next.id;
|
|
1573
1633
|
this.hostSince = now;
|
|
1574
1634
|
this.preferHost = null;
|
|
@@ -1580,7 +1640,7 @@ export class NetRoom {
|
|
|
1580
1640
|
this.stats.elections.push({ at: now, id: next.id, seat: next.seat, why });
|
|
1581
1641
|
if (this.stats.elections.length > 32) this.stats.elections.shift();
|
|
1582
1642
|
this.send(next, {
|
|
1583
|
-
t: 'role', role: 'host', why, host: this.hostRef(), peers: this.peers(),
|
|
1643
|
+
t: 'role', role: 'host', why, host: this.hostRef(), peers: this.peers(), ...(this.rules ? { held: this.heldPeers() } : {}),
|
|
1584
1644
|
ckpt: this.lastCkpt, snap: this.lastSnap, round: this.lastRound, roster: this.lastRoster, state: this.stateObject(),
|
|
1585
1645
|
});
|
|
1586
1646
|
for (const o of this.others(next)) this.send(o, { t: 'host', host: this.hostRef(), why });
|
|
@@ -1648,6 +1708,7 @@ export class NetRoom {
|
|
|
1648
1708
|
|
|
1649
1709
|
/** An EMPTY room forgets everything (after `forgetMs`, or when the Table's alarm ends a server-hosted room nobody came back to). */
|
|
1650
1710
|
forget() {
|
|
1711
|
+
this.hostId = null;
|
|
1651
1712
|
for (const c of this.live()) this.kick(c, 'agents-alone', 4001);
|
|
1652
1713
|
this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
|
|
1653
1714
|
this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null;
|
|
@@ -1747,6 +1808,16 @@ export class NetRoom {
|
|
|
1747
1808
|
|
|
1748
1809
|
labelOne(raw, result) {
|
|
1749
1810
|
if (!raw || typeof raw !== 'object') return null;
|
|
1811
|
+
if (this.rules) {
|
|
1812
|
+
if (!Number.isInteger(raw.slot) || raw.slot < 0 || raw.slot >= this.seatCap) return null;
|
|
1813
|
+
raw = { ...raw, seat: raw.slot };
|
|
1814
|
+
const holder = this.holderOf(raw.slot);
|
|
1815
|
+
if (holder.kind !== 'human' && holder.kind !== 'agent') {
|
|
1816
|
+
if (raw.slot >= this.maxPlayers - this.reserve())
|
|
1817
|
+
raw.agent = { seat: null, role: raw.slot >= this.maxPlayers - this.policy.guides ? 'guide' : 'party', hands: 'host' };
|
|
1818
|
+
else delete raw.agent;
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1750
1821
|
const out = { ...raw };
|
|
1751
1822
|
const seat = Number.isInteger(raw.seat) ? raw.seat : null;
|
|
1752
1823
|
const agentSeat = !result && raw.agent && Number.isInteger(raw.agent.seat) ? raw.agent.seat : null;
|
|
@@ -1761,7 +1832,7 @@ export class NetRoom {
|
|
|
1761
1832
|
} else if (seat !== null && who.kind === 'human') {
|
|
1762
1833
|
delete out.agent;
|
|
1763
1834
|
out.bot = false;
|
|
1764
|
-
out.name = stripAi(raw.name) || who.name;
|
|
1835
|
+
out.name = this.rules ? who.name : stripAi(raw.name) || who.name;
|
|
1765
1836
|
} else {
|
|
1766
1837
|
// No seat, or a seat nobody holds: a body the host's own bot code moves.
|
|
1767
1838
|
if (seat !== null) this.stats.labelled += 1;
|
|
@@ -1902,7 +1973,7 @@ export class NetRoom {
|
|
|
1902
1973
|
restore(saved, graceMs = 3000) {
|
|
1903
1974
|
const now = this.now();
|
|
1904
1975
|
if (!saved || saved.v !== NET_VERSION || !(now - Number(saved.savedAt) < 120_000)) return false;
|
|
1905
|
-
if (!this.server && saved.hosted !== 'server' && Number.isInteger(saved.maxPlayers)) this.maxPlayers = Math.max(1, Math.min(this.seatCap, saved.maxPlayers));
|
|
1976
|
+
if (!this.server && !this.rules && saved.hosted !== 'server' && Number.isInteger(saved.maxPlayers)) this.maxPlayers = Math.max(1, Math.min(this.seatCap, saved.maxPlayers));
|
|
1906
1977
|
if (Number.isFinite(saved.openedAt) && saved.openedAt > 0) this.openedAt = saved.openedAt;
|
|
1907
1978
|
for (const [seat, token, name, agent, occ, since] of saved.seats ?? []) this.seats.set(seat, { token, name, since: saved.durable && Number.isFinite(since) ? since : now, present: false, agent: agent && typeof agent === 'object' ? agent : null, ...(Number.isInteger(occ) ? { occ } : {}) });
|
|
1908
1979
|
// A stay's number is never given twice while a checkpoint may still name it.
|
|
@@ -1916,11 +1987,13 @@ export class NetRoom {
|
|
|
1916
1987
|
if ((saved.seats ?? []).some((s) => !s[3])) this.lastHumanAt = now;
|
|
1917
1988
|
// A server-hosted room has no browser host to hand a checkpoint to, and what its last match showed is not this one's.
|
|
1918
1989
|
const fresh = saved.hosted === 'server' && !saved.durable;
|
|
1990
|
+
if (!fresh) for (const [seat, s] of this.seats) this.hosted.set(seat, s.occ ?? null);
|
|
1919
1991
|
this.lastCkpt = fresh ? null : saved.ckpt ?? null;
|
|
1920
1992
|
this.lastRound = fresh ? null : saved.round ?? null;
|
|
1921
1993
|
this.lastRoster = fresh ? null : saved.roster ?? null;
|
|
1922
1994
|
for (const [k, d] of Object.entries(fresh ? {} : saved.state ?? {})) {
|
|
1923
|
-
const
|
|
1995
|
+
const text = JSON.stringify({ t: 'state', k, d });
|
|
1996
|
+
const bytes = this.rules ? byteLength(text) : text.length;
|
|
1924
1997
|
this.state.set(k, { d, bytes });
|
|
1925
1998
|
this.stateBytes += bytes;
|
|
1926
1999
|
}
|
|
@@ -2227,7 +2300,7 @@ export class NetRoom {
|
|
|
2227
2300
|
|
|
2228
2301
|
setSeats(n, perIp = null) {
|
|
2229
2302
|
this.seatCap = Math.max(1, Math.floor(n));
|
|
2230
|
-
this.maxPlayers = Math.max(1, Math.min(this.seatCap, this.server ? this.seatCap : this.askedMax ?? this.seatCap));
|
|
2303
|
+
this.maxPlayers = Math.max(1, Math.min(this.seatCap, (this.server || this.rules) ? this.seatCap : this.askedMax ?? this.seatCap));
|
|
2231
2304
|
if (Number.isFinite(perIp) && perIp > 0) this.perIp = perIp;
|
|
2232
2305
|
}
|
|
2233
2306
|
|
package/worker/schema.mjs
CHANGED
|
@@ -164,7 +164,7 @@ export function gameNode(cat, g, origin, { listed = true, shop = null } = {}) {
|
|
|
164
164
|
const L = g.landing ?? {};
|
|
165
165
|
const h = L.hero ?? {};
|
|
166
166
|
const page = `${origin}/${g.id}/`;
|
|
167
|
-
const play = `${origin}/${g.id}
|
|
167
|
+
const play = `${origin}/${g.id}/${g.kind === 'app' ? 'open' : 'play'}`;
|
|
168
168
|
const min = Number(g.players?.min) || 1;
|
|
169
169
|
const max = Number(g.players?.max) || 8;
|
|
170
170
|
const modes = [...(min <= 1 ? ['SinglePlayer'] : []), ...(max > 1 ? ['MultiPlayer'] : [])];
|
|
@@ -180,7 +180,7 @@ export function gameNode(cat, g, origin, { listed = true, shop = null } = {}) {
|
|
|
180
180
|
const addOn = listed ? shopOffers(shop, g, origin) : [];
|
|
181
181
|
const genre = (Array.isArray(g.genre) ? g.genre : g.genre ? [g.genre] : []).map((x) => text(x, 40)).filter(Boolean);
|
|
182
182
|
const node = {
|
|
183
|
-
'@type': ['VideoGame', 'WebApplication'],
|
|
183
|
+
'@type': g.kind === 'app' ? 'WebApplication' : ['VideoGame', 'WebApplication'],
|
|
184
184
|
'@id': `${page}#game`,
|
|
185
185
|
name: g.name,
|
|
186
186
|
...(text(g.blurb ?? L.pitch) ? { description: text(g.blurb || L.pitch) } : {}),
|
|
@@ -189,10 +189,11 @@ export function gameNode(cat, g, origin, { listed = true, shop = null } = {}) {
|
|
|
189
189
|
...(images.length ? { image: images.length === 1 ? images[0] : images } : {}),
|
|
190
190
|
...(shots.length ? { screenshot: shots } : {}),
|
|
191
191
|
...(genre.length ? { genre: genre.length === 1 ? genre[0] : genre } : {}),
|
|
192
|
-
playMode: modes.map(PLAY_MODE),
|
|
192
|
+
...(g.kind === 'app' ? {} : { playMode: modes.map(PLAY_MODE),
|
|
193
193
|
numberOfPlayers: { '@type': 'QuantitativeValue', minValue: min, maxValue: max },
|
|
194
194
|
gamePlatform: ['Web browser', 'Phone', 'Computer', ...(L.tv !== false ? ['TV'] : [])],
|
|
195
|
-
|
|
195
|
+
}),
|
|
196
|
+
applicationCategory: g.kind === 'app' ? 'BusinessApplication' : 'GameApplication',
|
|
196
197
|
operatingSystem: 'Any',
|
|
197
198
|
browserRequirements: 'A modern web browser with JavaScript',
|
|
198
199
|
isAccessibleForFree: true,
|
|
@@ -203,7 +204,7 @@ export function gameNode(cat, g, origin, { listed = true, shop = null } = {}) {
|
|
|
203
204
|
...(trailer ? { trailer: videoNode(cat, trailer, origin, { nested: true }) } : {}),
|
|
204
205
|
...(when(g.dates?.published) ? { datePublished: g.dates.published } : {}),
|
|
205
206
|
...(when(g.dates?.modified) ? { dateModified: g.dates.modified } : {}),
|
|
206
|
-
potentialAction: { '@type': 'PlayAction', target: play },
|
|
207
|
+
potentialAction: { '@type': g.kind === 'app' ? 'ViewAction' : 'PlayAction', target: play },
|
|
207
208
|
...(listed ? { offers: { '@type': 'Offer', price: 0, priceCurrency: /^[A-Z]{3}$/.test(String(shop?.currency ?? '').toUpperCase()) ? String(shop.currency).toUpperCase() : 'USD', availability: 'https://schema.org/OnlineOnly', url: play, ...(addOn.length ? { addOn } : {}) } } : {}),
|
|
208
209
|
};
|
|
209
210
|
return withExtras(node, g.schema);
|