@homie-rocks/studio 0.35.1 → 0.36.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 +24 -0
- package/agents/agents.ts +8 -6
- package/bin/homie-studio.mjs +9 -6
- package/lab/lab.ts +15 -0
- package/lib/build.mjs +20 -15
- package/lib/dev.mjs +16 -1
- package/lib/lab-check.mjs +18 -3
- package/lib/lab.mjs +21 -7
- package/lib/mcp-tools.mjs +2 -1
- 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/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +6 -2
- package/lib/template-history.json +44 -1
- package/lib/typecheck.mjs +52 -3
- package/netplay/NETPLAY.md +123 -6
- package/netplay/netplay.ts +289 -53
- package/netplay/rules-output.mjs +85 -0
- package/package.json +3 -2
- 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/standalone/shell/shell.js +8 -6
- package/worker/brain.mjs +2 -1
- package/worker/hosted.mjs +1 -1
- package/worker/index.mjs +7 -11
- package/worker/limits.mjs +41 -0
- package/worker/pages.mjs +4 -2
- package/worker/room.mjs +136 -63
- package/worker/version.mjs +1 -1
package/rules/types.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** The build specialises these faces from a game's declarations; the runtime still checks every write. */
|
|
2
|
+
import type { Vec3, BodyDef, Fields } from './rules.ts';
|
|
3
|
+
import type { math } from './math.ts';
|
|
4
|
+
|
|
5
|
+
export type Point = { readonly x: number; readonly y: number; readonly z?: number };
|
|
6
|
+
export type ReadonlyState<T> = T extends object ? { readonly [K in keyof T]: ReadonlyState<T[K]> } : T;
|
|
7
|
+
export type WriteState<T> = T extends Vec3 ? keyof T extends keyof Vec3 ? Point : { [K in keyof T]: WriteState<T[K]> } : T extends object ? { [K in keyof T]: WriteState<T[K]> } : T;
|
|
8
|
+
type Payload<T> = {} extends T ? [data?: WriteState<T>] : [data: WriteState<T>];
|
|
9
|
+
type Query<T> = ReadonlyState<Omit<T, 'input'>>;
|
|
10
|
+
export interface Goal { readonly goal: string; readonly args: Readonly<Record<string, unknown>>; readonly from: 'brain' | 'floor'; readonly at: number; readonly asked: boolean; readonly state: 'active' | 'done' | 'failed' }
|
|
11
|
+
export interface GuideDecision { goal?: string | null; args?: Record<string, unknown>; say?: string | null; sayArgs?: Record<string, unknown> }
|
|
12
|
+
export interface GuideRequest { readonly k: string; readonly args: Readonly<Record<string, unknown>>; readonly from: number; readonly at: number }
|
|
13
|
+
export type GuideView<V, G = Goal, A = GuideRequest> = ReadonlyState<V> & { readonly goal: G | null; readonly asks: readonly A[] };
|
|
14
|
+
export interface Player<G = Goal> { readonly seat: number; readonly owner: string; readonly driver: 'person' | 'bot' | 'ai'; readonly away: boolean; readonly goal: G | null }
|
|
15
|
+
export type Self<F, M, I, K extends string, P extends boolean, G = Goal> = F & {
|
|
16
|
+
readonly id: string; readonly kind: K; readonly pos: Vec3; get vel(): Vec3; set vel(value: Point); get heading(): Vec3; set heading(value: Point); readonly grounded: boolean;
|
|
17
|
+
readonly motion: M; readonly input: ReadonlyState<I>;
|
|
18
|
+
} & (P extends true ? Player<G> : {});
|
|
19
|
+
export interface Hit { readonly entity?: string; readonly at: Point; readonly normal: Vec3; readonly dist?: number }
|
|
20
|
+
export type Area = { sphere: { at: Point; r: number } } | { box: { min: Point; max: Point } } | { cone: { at: Point; dir: Point; r: number; angle: number } };
|
|
21
|
+
export interface MapView { readonly name: string; spot(name: string): Vec3 | undefined; spots(name: string): readonly Vec3[] }
|
|
22
|
+
export interface Clock { readonly tick: number; readonly dt: number; ticks(seconds: number): number; readonly math: typeof math }
|
|
23
|
+
export interface Round { readonly n: number; readonly phase: 'live' | 'over'; readonly endsAt: number }
|
|
24
|
+
export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
|
|
25
|
+
readonly tune: ReadonlyState<T>; readonly map: MapView; readonly stage: string; readonly level: number; readonly levelMax: number;
|
|
26
|
+
readonly shared: Room extends true ? S : ReadonlyState<S>;
|
|
27
|
+
readonly round: Round & (Room extends true ? { end(): void } : {});
|
|
28
|
+
random(): number;
|
|
29
|
+
send<K extends keyof E>(target: string, event: K, ...data: Payload<E[K]>): void;
|
|
30
|
+
sendRoom<K extends keyof E>(event: K, ...data: Payload<E[K]>): void;
|
|
31
|
+
sendArea<K extends keyof E>(area: Area, event: K, ...data: Payload<E[K]>): void;
|
|
32
|
+
after<K extends keyof E>(ticks: number, event: K, ...data: Payload<E[K]>): void;
|
|
33
|
+
emit<K extends keyof F>(effect: K, at: Point | string, ...data: Payload<F[K]>): void;
|
|
34
|
+
spawn<K extends keyof C>(kind: K, at: Point, fields?: Partial<WriteState<C[K]>>): string;
|
|
35
|
+
near<K extends keyof V>(at: Point, radius: number, kind: K): readonly Query<V[K]>[];
|
|
36
|
+
near(at: Point, radius: number): readonly Query<V[keyof V]>[];
|
|
37
|
+
inBox<K extends keyof V>(box: { min: Point; max: Point }, kind: K): readonly Query<V[K]>[];
|
|
38
|
+
inBox(box: { min: Point; max: Point }): readonly Query<V[keyof V]>[];
|
|
39
|
+
ray(from: Point, direction: Point, max: number): (Hit & { readonly dist: number }) | undefined;
|
|
40
|
+
ask<K extends keyof A>(name: K, state: WriteState<A[K]>): boolean;
|
|
41
|
+
} & (Room extends true ? { announce<K extends keyof E>(event: K, ...data: Payload<E[K]>): void; finish(): void } : {
|
|
42
|
+
goalDone(ok?: boolean): void;
|
|
43
|
+
despawn(self: Exclude<V[keyof V], Player>): void;
|
|
44
|
+
place(self: V[keyof V], at: Point, options?: { vel?: Point; heading?: Point }): void;
|
|
45
|
+
sweep(self: V[keyof V], delta: Point, options?: { ignore?: readonly string[] }): Hit | undefined;
|
|
46
|
+
});
|
|
47
|
+
export type EventData<T> = ReadonlyState<T>;
|
|
48
|
+
export type Undeliverable<E> = { [K in keyof E]: { to: string; event: K; data: E[K] } }[keyof E];
|
|
49
|
+
export type BuiltIns<E = Record<string, never>> = { arrive: { why: 'join' | 'spawn' }; leave: {}; takeover: {}; undeliverable: Undeliverable<E>; answer: { ask: string; by: string; picks: Readonly<Record<string, unknown>>; why?: string } };
|
|
50
|
+
export type RoomEvents<E = Record<string, never>> = RoundEvents & { seatJoined: { seat: number; id: string; driver: 'person' | 'bot' | 'ai'; owner: string; took: boolean }; seatAway: { seat: number; id: string; away: boolean }; seatLeft: { seat: number; id: string }; answer: BuiltIns['answer']; undeliverable: BuiltIns<E>['undeliverable'] };
|
|
51
|
+
export type RoundEvents = { roundStart: { n: number }; roundOver: { n: number; results: readonly { seat: number; id: string; driver: 'person' | 'bot' | 'ai'; score: number; place: number }[] } };
|
|
52
|
+
type GuideWorld<W> = Pick<W, Extract<keyof W, 'tick' | 'dt' | 'ticks' | 'math' | 'map' | 'tune' | 'stage' | 'level' | 'levelMax' | 'round' | 'shared' | 'near' | 'inBox' | 'ray'>>;
|
|
53
|
+
export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A = GuideRequest, D = GuideDecision> = {
|
|
54
|
+
player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot' }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef;
|
|
55
|
+
tick?: (world: W, self: S) => void;
|
|
56
|
+
think?: (world: W, self: S) => Partial<I>;
|
|
57
|
+
on?: { [K in keyof (Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })]?: (world: W, self: S, event: EventData<(Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })[K]>) => void };
|
|
58
|
+
commands?: { [K in keyof C]?: (world: W, self: S, event: EventData<C[K]>) => void };
|
|
59
|
+
onRoom?: { [K in keyof (E & RoundEvents)]?: (world: W, self: S, event: EventData<(E & RoundEvents)[K]>) => void };
|
|
60
|
+
guide?: { view: (world: GuideWorld<W>, self: ReadonlyState<Omit<S, 'input'>>) => V; floor?: (world: W, self: S, view: GuideView<V, G, A>) => D };
|
|
61
|
+
};
|
|
62
|
+
export interface MoveBody<M> { get pos(): Vec3; set pos(value: Point); get vel(): Vec3; set vel(value: Point); get heading(): Vec3; set heading(value: Point); grounded: boolean; readonly motion: M }
|
|
63
|
+
export type MoveContext<T, M> = Clock & { readonly tune: ReadonlyState<T>; readonly map: MapView & { sweep(body: MoveBody<M>, delta: Point): Hit | undefined } };
|
package/rules/view.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { rulesOutput } from '../netplay/rules-output.mjs';
|
|
2
|
+
import { rulesCaps, rulesRates } from '../worker/limits.mjs';
|
|
1
3
|
/*
|
|
2
4
|
* view.ts — what a rules game's view imports: the room, as a browser sees it.
|
|
3
5
|
* =============================================================================
|
|
@@ -6,10 +8,10 @@
|
|
|
6
8
|
* import type rules from './rules';
|
|
7
9
|
* const room = openRoom<typeof rules>();
|
|
8
10
|
*
|
|
9
|
-
* A game is rules plus view. The rules run
|
|
11
|
+
* A game is rules plus view. The rules run in one host runtime; the view draws what it is told and sends what the player
|
|
10
12
|
* presses. `openRoom` starts the netplay helper (netplay/netplay.ts), joins the room the play page names and returns
|
|
11
13
|
* at once: `room.status` moves from `connecting` to `playing`. The rules are imported for their types only, so none
|
|
12
|
-
* of their code is
|
|
14
|
+
* of their code is imported by the game's view; the build supplies declarations and an optional local host loader, and it
|
|
13
15
|
* unpacks every frame from them. A view never packs or unpacks state.
|
|
14
16
|
*
|
|
15
17
|
* room.me the player's own body
|
|
@@ -39,19 +41,20 @@ import type { useAgents, Vocabulary, AskButton } from '../agents/agents.ts';
|
|
|
39
41
|
let agentFactory: typeof useAgents | null = null;
|
|
40
42
|
export function setAgentFactory(factory: typeof useAgents): void { agentFactory = factory; }
|
|
41
43
|
import { createNetplay } from '../netplay/netplay.ts';
|
|
42
|
-
import type { Netplay, NetplayOptions, RoundInfo, Snapshot, StepEntry } from '../netplay/netplay.ts';
|
|
44
|
+
import type { Netplay, NetplayOptions, RulesHostFactory, RoundInfo, Snapshot, StepEntry } from '../netplay/netplay.ts';
|
|
45
|
+
import { lab } from '../lab/lab.ts';
|
|
43
46
|
import { exposePort } from '../port/probe.ts';
|
|
44
|
-
import { BudgetError
|
|
45
|
-
import {
|
|
46
|
-
import { coerce,
|
|
47
|
+
import { BudgetError } from './guard.ts';
|
|
48
|
+
import { moveContext } from './math.ts';
|
|
49
|
+
import { coerce, dir, stepMove, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.ts';
|
|
47
50
|
import type { Unpacked } from './pack.ts';
|
|
48
51
|
import type { FieldList, MoveFn, Schema, Vec3 } from './rules.ts';
|
|
49
52
|
|
|
50
53
|
/** What the build hands the view library for one game: its declarations, its public tunables and its map. No code. */
|
|
51
54
|
export interface GameData { vocab?: Vocabulary; id: string; schema: Schema; tune: Record<string, unknown>; map: { name?: string; bounds: { min: unknown; max: unknown }; boxes?: unknown[]; circles?: unknown[]; spots?: Record<string, unknown[]> } }
|
|
52
|
-
let current: { game: GameData; move: Record<string, MoveFn> } | null = null;
|
|
55
|
+
let current: { game: GameData; move: Record<string, MoveFn>; load?: () => Promise<RulesHostFactory> } | null = null;
|
|
53
56
|
/** Called by the build's own two lines at the top of a view's bundle, before the view's code runs. */
|
|
54
|
-
export function setGame(game: GameData, move: Record<string, MoveFn> = {}): void { current = { game, move }; }
|
|
57
|
+
export function setGame(game: GameData, move: Record<string, MoveFn> = {}, load?: () => Promise<RulesHostFactory>): void { current = { game, move, load }; }
|
|
55
58
|
|
|
56
59
|
/** An entity as a view reads it: the built-in fields, the kind's declared fields by name, and its `motion`. */
|
|
57
60
|
export interface Entity {
|
|
@@ -82,7 +85,7 @@ export interface Room<R = unknown> {
|
|
|
82
85
|
follow(id: string | null): void;
|
|
83
86
|
/** Extra values for the testing tools' receipts (`window.__homiePort`). */
|
|
84
87
|
probe(extra: Record<string, () => unknown>): void;
|
|
85
|
-
/** A guide's ask buttons and a person's ask
|
|
88
|
+
/** A guide's ask buttons and a person's ask, handled by the room's rules host. */
|
|
86
89
|
ask(id: string, askId: string, args?: Record<string, unknown>): void;
|
|
87
90
|
askButtons(id: string, offer?: Record<string, unknown>): AskButton[];
|
|
88
91
|
readonly tune: Readonly<Record<string, unknown>>;
|
|
@@ -110,8 +113,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
110
113
|
const period = 1000 / tickHz;
|
|
111
114
|
const sendEvery = Math.ceil(tickHz / Math.max(1, schema.settings.inputHz));
|
|
112
115
|
const clock = opts.now ?? ((): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()));
|
|
113
|
-
const net = createNetplay<unknown, unknown, unknown>({ game: game.id, maxPlayers: schema.seats, snapshotHz: tickHz, ...opts.net, rules: true });
|
|
114
|
-
const agents = game.vocab && agentFactory ? agentFactory(net, game.vocab, { roles: ['guide', 'party'], view: () => ({}), manual: true }) : null;
|
|
116
|
+
const net = createNetplay<unknown, unknown, unknown>({ game: game.id, maxPlayers: schema.seats, snapshotHz: tickHz, heartbeatMs: 0, rulesHost: { mode: schema.settings.host, offline: schema.settings.offline, load: current?.load }, ...opts.net, rules: true, rulesLimits: { output: rulesOutput, bytes: rulesCaps(schema.seats), rates: rulesRates(tickHz, schema.seats) } });
|
|
117
|
+
const agents = game.vocab && agentFactory ? agentFactory(net, game.vocab, { roles: ['guide', 'party'], view: () => ({}), manual: true, viewOnly: true }) : null;
|
|
115
118
|
const kindOf = new Map(schema.kinds.map((k) => [k.name, k]));
|
|
116
119
|
const listeners = new Map<string, Set<(e: any) => void>>();
|
|
117
120
|
const emit = (name: string, e: unknown): void => { for (const fn of listeners.get(name) ?? []) { try { fn(e); } catch (err) { console.warn(`[room] on('${name}')`, err); } } };
|
|
@@ -119,7 +122,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
119
122
|
const map = { bounds: { min: vecOf(game.map.bounds.min), max: vecOf(game.map.bounds.max) }, boxes: (game.map.boxes ?? []).map((b: any) => ({ min: vecOf(b.min), max: vecOf(b.max) })), circles: (game.map.circles ?? []).map((c: any) => ({ at: vecOf(c.at), r: Number(c.r) })) };
|
|
120
123
|
const spots: Record<string, readonly Vec3[]> = {};
|
|
121
124
|
for (const [key, list] of Object.entries(game.map.spots ?? {})) spots[key] = Object.freeze(list.map(vecOf));
|
|
122
|
-
const tune = Object.freeze({ ...game.tune });
|
|
125
|
+
const tune = Object.freeze({ ...(lab.rulesTune({ public: game.tune }).public as Record<string, unknown>) });
|
|
123
126
|
|
|
124
127
|
let status: RoomStatus = 'connecting';
|
|
125
128
|
let latest: Frame | null = null;
|
|
@@ -167,12 +170,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
167
170
|
const tickAt = (now: number): number => (base ? base.tick + (now - base.at) / period : 0);
|
|
168
171
|
/** The tick `move` is being run for: the step just taken, or the one after it while the own body is drawn between ticks. */
|
|
169
172
|
let moveTick = 0;
|
|
170
|
-
const moveCtx =
|
|
171
|
-
get tick() { return moveTick; }, dt: 1 / tickHz, tune, math,
|
|
172
|
-
// As the server's `ctx.ticks` reads it: a plain number, or nothing.
|
|
173
|
-
ticks: (seconds: unknown): number => { const s = num(seconds); const n = Math.round(s * tickHz); return s > 0 && Number.isFinite(n) ? Math.max(1, n) : 0; },
|
|
174
|
-
map: brand(Object.freeze({ name: game.map.name ?? 'main', spot: (name: string) => spots[name]?.[0], spots: (name: string) => spots[name] ?? Object.freeze([]), sweep: (body: any, delta: unknown) => sweepMap(map, body, delta, myKind()?.radius ?? 0, dims) })),
|
|
175
|
-
}));
|
|
173
|
+
const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, dims });
|
|
176
174
|
function rebase(k: number): void {
|
|
177
175
|
const rtt = net.stats().rtt ?? 100;
|
|
178
176
|
base = { tick: k + Math.ceil(rtt / period + TARGET_LEAD) + sendEvery - 1, at: clock() };
|
|
@@ -190,12 +188,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
190
188
|
const fn = moves[kindName];
|
|
191
189
|
if (!fn) return body;
|
|
192
190
|
moveTick = t;
|
|
193
|
-
|
|
194
|
-
try { fn(body, input, moveCtx); } catch (err) { if (!(err instanceof BudgetError)) console.warn('[room] move', err); }
|
|
195
|
-
G.left = Infinity;
|
|
196
|
-
// Rounded to 32-bit floats and held to the declared shapes, as the server does it: both hold the same numbers.
|
|
197
|
-
const list = kindOf.get(kindName)?.motion ?? [];
|
|
198
|
-
return { pos: vec3(body.pos, dims), vel: vec3(body.vel, dims), heading: dir(body.heading, dims), grounded: body.grounded === true, motion: thawFields(list, coerceFields(list, body.motion, dims)) };
|
|
191
|
+
return stepMove(fn, body, input, moveCtx, Math.max(1, Math.floor(schema.settings.budget.tick / 4)), kindOf.get(kindName)?.motion ?? [], dims, (err) => { if (!(err instanceof BudgetError)) console.warn('[room] move', err); });
|
|
199
192
|
}
|
|
200
193
|
/** The input values the last step held (a press is never held). */
|
|
201
194
|
let held: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
@@ -243,12 +236,12 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
243
236
|
}
|
|
244
237
|
function pump(): void {
|
|
245
238
|
if (closed) return;
|
|
246
|
-
if (net.offline) setStatus(net.closedWhy ? 'closed' : 'offline');
|
|
239
|
+
if (net.offline && !net.rulesHosting) setStatus(net.closedWhy ? 'closed' : 'offline');
|
|
247
240
|
const now = clock();
|
|
248
241
|
const hidden = typeof document !== 'undefined' && document.hidden === true;
|
|
249
242
|
// Effects play for everyone who draws the room: a watcher has no body and still sees them.
|
|
250
243
|
fire();
|
|
251
|
-
if (!mine || !base || net.seat === null || !net.connected) return;
|
|
244
|
+
if (!mine || !base || net.seat === null || (!net.connected && !net.rulesHosting)) return;
|
|
252
245
|
if (hidden) {
|
|
253
246
|
// A hidden tab's timers are too slow to keep input alive, and a body must not run on without its player:
|
|
254
247
|
// one neutral entry at once (every field at its init, no press), then no more steps until the tab shows again.
|
|
@@ -306,7 +299,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
306
299
|
function roundNow(): RoomRound | null {
|
|
307
300
|
if (!round || !latest) return null;
|
|
308
301
|
// The room's clock now, from the newest snapshot: a view never turns a tick into time itself.
|
|
309
|
-
const serverTick = latest.k + (net.connected ? (clock() - latest.at) / period : 0);
|
|
302
|
+
const serverTick = latest.k + ((net.connected || net.rulesHosting) ? (clock() - latest.at) / period : 0);
|
|
310
303
|
return { ...round, secondsLeft: round.endsAt ? Math.max(0, (round.endsAt - serverTick) / tickHz) : Infinity, ...(results ? { results } : {}) };
|
|
311
304
|
}
|
|
312
305
|
/** Effects wait for the tick they were emitted on to be drawn (an effect on the player's own body plays at once). */
|
|
@@ -323,7 +316,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
323
316
|
}
|
|
324
317
|
|
|
325
318
|
net.on('snapshot', onSnapshot);
|
|
326
|
-
net.on('role', (e) => { if (e.snap) onSnapshot(e.snap); if (net.offline) setStatus(net.closedWhy ? 'closed' : 'offline'); });
|
|
319
|
+
net.on('role', (e) => { if (e.snap) onSnapshot(e.snap); if (net.offline && !net.rulesHosting) setStatus(net.closedWhy ? 'closed' : 'offline'); });
|
|
327
320
|
net.on('link', (e) => {
|
|
328
321
|
if (e.state === 'online') { base = null; if (status !== 'playing') setStatus('connecting'); } else if (e.state === 'closed') setStatus('closed'); else if (e.state === 'alone' || e.state === 'offline') setStatus('offline');
|
|
329
322
|
});
|
|
@@ -335,10 +328,20 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
335
328
|
return;
|
|
336
329
|
}
|
|
337
330
|
// A player's speech and emotes, and a guide's lines and goals, as the relay carries them today.
|
|
338
|
-
if (/^(?:say|chat|emote)/i.test(e.k) && !(agents && (e.d as { ai?: boolean })?.ai))
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
331
|
+
if (/^(?:say|chat|emote)/i.test(e.k) && !(agents && (e.d as { ai?: boolean })?.ai)) {
|
|
332
|
+
const data = e.d && typeof e.d === 'object' ? e.d as Record<string, unknown> : { text: e.d };
|
|
333
|
+
emit('say', { ...data, kind: e.k, seat: e.from, slot: -1, line: typeof data.line === 'string' ? data.line : undefined, text: typeof data.text === 'string' ? data.text : '', args: {} });
|
|
334
|
+
}
|
|
335
|
+
else if (e.k === 'agent:goal' && e.from === null) {
|
|
336
|
+
const data = e.d as import('../agents/agents.ts').GoalEvent;
|
|
337
|
+
const goal = (g: import('../agents/agents.ts').Goal) => ({ ...g, asked: g.asked === true });
|
|
338
|
+
emit('goal', { ...data, goal: goal(data.goal), prev: data.prev ? goal(data.prev) : null });
|
|
339
|
+
}
|
|
340
|
+
else if (e.k === 'agent:ask' && e.from === null) emit('ask', e.d);
|
|
341
|
+
else if (/^ask:/.test(e.k)) {
|
|
342
|
+
const data = e.d && typeof e.d === 'object' ? e.d as Record<string, unknown> : {};
|
|
343
|
+
emit('ask', { ...data, ask: e.k.slice(4), k: e.k.slice(4), slot: typeof data.slot === 'number' ? data.slot : -1, from: e.from, at: clock(), args: data.args && typeof data.args === 'object' ? data.args : {} });
|
|
344
|
+
}
|
|
342
345
|
});
|
|
343
346
|
|
|
344
347
|
agents?.on('say', (e) => emit('say', e));
|
|
@@ -406,7 +409,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
406
409
|
self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
|
|
407
410
|
peer: (seat: number) => { if (!latest) return null; for (const u of latest.ents.values()) if (u.seat === seat) return { x: u.pos.x, y: u.pos.y, z: u.pos.z }; return null; },
|
|
408
411
|
scores: () => roster().map((r) => ({ seat: r.seat, score: r.score })),
|
|
409
|
-
score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => '
|
|
412
|
+
score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => status,
|
|
410
413
|
});
|
|
411
414
|
if (typeof window !== 'undefined') {
|
|
412
415
|
exposePort(net as Netplay<unknown, unknown, unknown>, {
|
|
@@ -84,20 +84,22 @@
|
|
|
84
84
|
/** The room's address on the studio's site: a friend opens it in any browser and lands in this room. */
|
|
85
85
|
function linkOf(room) { return site + '/' + game + '/play?room=' + encodeURIComponent(room); }
|
|
86
86
|
|
|
87
|
+
var canOffline = !app.room || app.room.host === 'browser' || app.room.offline !== false;
|
|
88
|
+
noticeEl.querySelector('[data-notice-offline]').hidden = !canOffline;
|
|
87
89
|
// What the button and its sheet say: the room, or plainly that this copy plays offline.
|
|
88
90
|
function paint() {
|
|
89
91
|
var alone = !state.online || (state.link && state.link.state === 'alone');
|
|
90
|
-
var word = !state.starts ? 'Finding a room…' : alone ? 'Playing offline · Try again' : state.link && state.link.state === 'reconnecting' ? 'Reconnecting…' : labelOf(state.room);
|
|
92
|
+
var word = !state.starts ? 'Finding a room…' : alone ? (canOffline ? 'Playing offline · Try again' : 'Connection needed · Try again') : state.link && state.link.state === 'reconnecting' ? 'Reconnecting…' : labelOf(state.room);
|
|
91
93
|
ui.querySelector('[data-room-code]').textContent = word;
|
|
92
94
|
ui.querySelector('[data-room-label]').textContent = state.online ? labelOf(state.room) : 'Offline';
|
|
93
95
|
ui.querySelector('[data-room-state]').textContent = !site ? 'This copy has no online address' : alone ? 'No room answered' : '';
|
|
94
96
|
// A copy the site has outgrown cannot invite anybody: a friend's browser would land on the newer version, which
|
|
95
97
|
// this copy cannot join. The line about updating stands where the invite was.
|
|
96
|
-
ui.querySelector('[data-room-link]').textContent = state.outdated ? STALE : state.online ? linkOf(state.room).replace(/^https?:\/\//, '') : 'Your game and its bots are on this device.';
|
|
98
|
+
ui.querySelector('[data-room-link]').textContent = state.outdated ? STALE : state.online ? linkOf(state.room).replace(/^https?:\/\//, '') : (canOffline ? 'Your game and its bots are on this device.' : 'This game needs a connection.');
|
|
97
99
|
ui.querySelector('[data-invite]').hidden = !state.online || state.outdated;
|
|
98
100
|
ui.querySelector('[data-quick]').textContent = alone ? 'Try again' : 'Quick play';
|
|
99
101
|
if (alone) ui.querySelector('[data-quick]').classList.add('primary'); else ui.querySelector('[data-quick]').classList.remove('primary');
|
|
100
|
-
ui.querySelector('[data-offline]').hidden = !state.online;
|
|
102
|
+
ui.querySelector('[data-offline]').hidden = !state.online || !canOffline;
|
|
101
103
|
wake();
|
|
102
104
|
tellRects();
|
|
103
105
|
}
|
|
@@ -113,7 +115,7 @@
|
|
|
113
115
|
try { if (typeof AbortController === 'function') { var ac = new AbortController(); opts.signal = ac.signal; setTimeout(function () { try { ac.abort(); } catch (e) {} }, 6000); } } catch (e) {}
|
|
114
116
|
return fetch(site + '/' + game + '/api/lobby' + (q.length ? '?' + q.join('&') : ''), opts)
|
|
115
117
|
.then(function (r) { return r.json(); })
|
|
116
|
-
.then(function (j) { return j && typeof j.room === 'string' && ROOM.test(j.room) ? j.room : null; })
|
|
118
|
+
.then(function (j) { if (j && j.error === 'stale') { state.outdated = true; flash(STALE); } return j && typeof j.room === 'string' && ROOM.test(j.room) ? j.room : null; })
|
|
117
119
|
.catch(function () { return null; });
|
|
118
120
|
}
|
|
119
121
|
|
|
@@ -165,7 +167,7 @@
|
|
|
165
167
|
lobby().then(function (room) {
|
|
166
168
|
state.finding = false;
|
|
167
169
|
if (state.starts !== asked) return;
|
|
168
|
-
if (room) start(room); else { flash('No room answered: still playing offline.'); paint(); }
|
|
170
|
+
if (room) start(room); else { flash(state.outdated ? STALE : canOffline ? 'No room answered: still playing offline.' : 'This game needs a connection.'); paint(); }
|
|
169
171
|
});
|
|
170
172
|
return;
|
|
171
173
|
}
|
|
@@ -184,7 +186,7 @@
|
|
|
184
186
|
function go() { if (state.finding) return; var kept = remembered(); hide(); if (kept && site) start(kept); else find(); }
|
|
185
187
|
function join(code) {
|
|
186
188
|
if (!ROOM.test(code)) { flash('A room code is 1 to 32 letters, digits, - or _.'); return false; }
|
|
187
|
-
if (!site) { flash('This copy has no online address: it plays offline.'); return false; }
|
|
189
|
+
if (!site) { flash(canOffline ? 'This copy has no online address: it plays offline.' : 'This game needs an online address and a connection.'); return false; }
|
|
188
190
|
remember(code); hide(); open(false); start(code);
|
|
189
191
|
return true;
|
|
190
192
|
}
|
package/worker/brain.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LIMITS } from './limits.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* BRAINS (@homie-rocks/studio 0.17.0, NETPLAY.md section 18): what an AI guide decides, a few seconds at a time, and
|
|
3
4
|
* only ever in the game's own words. Pure: no Worker import and no Node built-in, so the relay (room.mjs), the Table's
|
|
@@ -649,7 +650,7 @@ export async function clefDecide(env, { vocab, ctx = {}, model, ms = 8000, local
|
|
|
649
650
|
* 8 questions; Choice 2 to 26 options, Score 2 to 10 levels, a yes/no; short words
|
|
650
651
|
* picksOf(answers, questions) the answers as the game uses them: { tactic: 'surround', wave: false, pressure: 2.6 }
|
|
651
652
|
*/
|
|
652
|
-
export const DECIDE = Object.freeze({ questions: 8, options: 26, levels: 10, words: 160, option: 40, stateBytes: 2048, bytes:
|
|
653
|
+
export const DECIDE = Object.freeze({ questions: 8, options: 26, levels: 10, words: 160, option: 40, stateBytes: 2048, bytes: LIMITS.decide, gapMs: 3000, perMinute: 20, inFlight: 2, ms: 2500 });
|
|
653
654
|
const QID = /^[A-Za-z][A-Za-z0-9_.-]{0,39}$/;
|
|
654
655
|
const OPT = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,39}$/;
|
|
655
656
|
|
package/worker/hosted.mjs
CHANGED
|
@@ -49,7 +49,7 @@ export function compiledFor(id) {
|
|
|
49
49
|
*/
|
|
50
50
|
export function startHost(game, { send, log, onPause, onResume, onEnd, ...recovery }) {
|
|
51
51
|
return createHost({
|
|
52
|
-
game, build: games.get(game)?.build ?? undefined, compiled: compiledFor(game), send, log, onPause, onResume, onEnd,
|
|
52
|
+
enforceOverrun: true, game, build: games.get(game)?.build ?? undefined, compiled: compiledFor(game), send, log, onPause, onResume, onEnd,
|
|
53
53
|
clock: { now: () => Date.now(), setTimer: (fn, ms) => setTimeout(fn, ms), clearTimer: (h) => clearTimeout(h) },
|
|
54
54
|
...recovery,
|
|
55
55
|
});
|
package/worker/index.mjs
CHANGED
|
@@ -1148,7 +1148,7 @@ async function route(request, env, ctx) {
|
|
|
1148
1148
|
const cur = versionOf(meta?.netplay?.version) ?? '';
|
|
1149
1149
|
const gv = versionOf(url.searchParams.get('gv')) ?? '';
|
|
1150
1150
|
const stall = Number(meta?.netplay?.stallMs) || 0;
|
|
1151
|
-
const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}&cur=${encodeURIComponent(cur)}${gv ? `&gv=${encodeURIComponent(gv)}` : ''}${stall ? `&stall=${stall}` : ''}${meta?.room?.host === 'server' ? '&host=server' : ''}`;
|
|
1151
|
+
const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}&cur=${encodeURIComponent(cur)}${gv ? `&gv=${encodeURIComponent(gv)}` : ''}${stall ? `&stall=${stall}` : ''}${meta?.room?.contract ? `&rules=1&hz=${meta.room.tickHz ?? 20}` : ''}${meta?.room?.host === 'server' ? '&host=server' : ''}`;
|
|
1152
1152
|
// The room's own failure to answer is told apart from the browser going away while it was connecting (a closed
|
|
1153
1153
|
// tab, a reload racing its own socket): the second is a departure, said in one line with its room, never an
|
|
1154
1154
|
// uncaught error with nothing on it.
|
|
@@ -1388,7 +1388,7 @@ export class Table {
|
|
|
1388
1388
|
send: (m, text) => room.hostFrame(m, text),
|
|
1389
1389
|
log: (line) => { if (line.ev === 'persist-failed') this.storageProblem(new Error(line.error)); else this.say(line); },
|
|
1390
1390
|
onPause: () => { this.pauseFromRestore = false; this.pausedAt ??= Date.now(); this.armRoom(this.pausedAt + ROOM_PAUSE_MS); },
|
|
1391
|
-
onResume: () => { this.pauseFromRestore = false; this.pausedAt = null; if (this.hostRt) { try { this.saveRoom(this.hostRt.save()); } catch { /* retry already scheduled */ } } this.
|
|
1391
|
+
onResume: () => { this.pauseFromRestore = false; this.pausedAt = null; if (this.hostRt) { try { this.saveRoom(this.hostRt.save()); } catch { /* retry already scheduled */ } } this.armRoom(Date.now() + ROOM_ALARM_MS); },
|
|
1392
1392
|
onEnd: (why) => this.endHosted(why),
|
|
1393
1393
|
});
|
|
1394
1394
|
room.setServerHost(this.hostRt);
|
|
@@ -1396,7 +1396,6 @@ export class Table {
|
|
|
1396
1396
|
room.setCurrent(build.build);
|
|
1397
1397
|
if (pauseWriteFailed) { try { this.saveRoom(this.hostRt.save()); } catch { /* the save retry retains the original absence */ } }
|
|
1398
1398
|
if (saved) {
|
|
1399
|
-
this.retimeRound();
|
|
1400
1399
|
room.hostFrame({ t: 'state', k: 'shared', d: this.hostRt.core.shared() });
|
|
1401
1400
|
room.hostFrame({ t: 'snap', from: null, e: epoch, k: this.hostRt.tick, st: Date.now(), d: this.hostRt.core.snapshot(), c: [] });
|
|
1402
1401
|
}
|
|
@@ -1446,13 +1445,6 @@ export class Table {
|
|
|
1446
1445
|
}
|
|
1447
1446
|
}
|
|
1448
1447
|
|
|
1449
|
-
retimeRound() {
|
|
1450
|
-
if (!this.hostRt) return;
|
|
1451
|
-
const r = this.hostRt.core.save().round;
|
|
1452
|
-
const period = 1000 / hostedBuild(this.game).settings.tickHz;
|
|
1453
|
-
this.room.hostFrame({ t: 'round', round: { ...this.room.lastRound, n: r[0], phase: r[1] ? 'live' : 'over', endsAt: Date.now() + (r[2] - this.hostRt.tick) * period, startedAt: Date.now() + (r[3] - this.hostRt.tick) * period } });
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
1448
|
storageProblem(error) {
|
|
1457
1449
|
this.storageHealth = { ok: false, message: String(error?.message ?? error).slice(0, 160), since: this.storageHealth?.ok === false ? this.storageHealth.since : Date.now() };
|
|
1458
1450
|
if (Date.now() < (this.storageLogAt ?? 0)) return;
|
|
@@ -1908,8 +1900,12 @@ export class Table {
|
|
|
1908
1900
|
this.report();
|
|
1909
1901
|
return json(res);
|
|
1910
1902
|
}
|
|
1903
|
+
room.rules = url.searchParams.get('rules') === '1';
|
|
1904
|
+
// Any tick rate a game may declare (1 to 60): the relay's allowances are the page's, from the same table.
|
|
1905
|
+
const hz = Number(url.searchParams.get('hz'));
|
|
1906
|
+
room.tickHz = Number.isInteger(hz) && hz >= 1 && hz <= 60 ? hz : 20;
|
|
1911
1907
|
// The owner's room size: a room already open takes it from the next visitor on.
|
|
1912
|
-
if (room.seatCap !== max) room.setSeats(max, perAddress(max));
|
|
1908
|
+
if (room.seatCap !== max || room.rules && room.maxPlayers !== max) room.setSeats(max, perAddress(max));
|
|
1913
1909
|
// Revision 9: the game's own stall time and the build that is live now, the Worker's word with every socket.
|
|
1914
1910
|
const said = url.searchParams.has('cur');
|
|
1915
1911
|
if (said) { room.setStall(url.searchParams.get('stall')); room.setCurrent(url.searchParams.get('cur') || null); }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Size caps for up to 16 seats: UTF-8 bytes in rules rooms, JSON string length in legacy rooms. */
|
|
2
|
+
export const LIMITS = Object.freeze({
|
|
3
|
+
hello: 2048, snap: 16384, in: 2048, ev: 4096, ckpt: 65536, state: 8192, round: 8192, roster: 4096, ping: 256, other: 512,
|
|
4
|
+
// Room chat (revision 8): a typed line (280 characters, emoji count double), a reaction.
|
|
5
|
+
say: 1536, react: 256,
|
|
6
|
+
// A game's own decision (section 20): a few typed questions about its state.
|
|
7
|
+
decide: 6144,
|
|
8
|
+
});
|
|
9
|
+
/**
|
|
10
|
+
* What grows with the seats (every body, every result, every slot): the checkpoint, round and roster caps double
|
|
11
|
+
* for a room of 17 to 32 (revision 3). Measured: a 32-seat courier game's checkpoint reached 56 KB of the 64 KB
|
|
12
|
+
* a 16-seat room allows. The snapshot cap does not grow: 32 bodies are about 1.5 KB.
|
|
13
|
+
*/
|
|
14
|
+
export const GROWS = Object.freeze(['ckpt', 'round', 'roster']);
|
|
15
|
+
export const capOf = (t, seats) => (LIMITS[t] ?? LIMITS.other) * (GROWS.includes(t) && seats > 16 ? 2 : 1);
|
|
16
|
+
|
|
17
|
+
export const RATES = Object.freeze({ snap: 30, in: 60, ev: 30, ckpt: 4, state: 64, round: 4, roster: 8, ping: 8, other: 8, say: 4, react: 10, decide: 2 });
|
|
18
|
+
/** `ev` per second by the sender's role: a screen is a spectator, and the host is somebody's phone. */
|
|
19
|
+
export const EV_RATES = Object.freeze({ host: 30, replica: 10, screen: 2 });
|
|
20
|
+
/** The keyed state channel: at most this many keys and bytes per room. */
|
|
21
|
+
export const STATE_CAPS = Object.freeze({ keys: 64, bytes: 65536 });
|
|
22
|
+
/** Runtime output only. Player allowances above remain unchanged. */
|
|
23
|
+
export const RULES_FRAMES = Object.freeze(['snap', 'ckpt', 'state', 'ev', 'round', 'roster', 'decide', 'caps', 'rules-end']);
|
|
24
|
+
export const rulesRates = (tickHz = 20, seats = 8) => ({
|
|
25
|
+
// A snapshot a tick, as the server sends, and a quarter over for the ticks a late timer runs together.
|
|
26
|
+
snap: Math.max(30, Math.ceil(tickHz * 1.25)), ckpt: 4, state: tickHz + 64,
|
|
27
|
+
ev: Math.ceil((tickHz + seats * EV_RATES.replica + 32) / 0.8), round: 8,
|
|
28
|
+
// A decision a tick is what one declared ask can make (the next waits for the answer), as on a server host.
|
|
29
|
+
roster: seats * 2 + 8, decide: Math.ceil((tickHz + seats * 5 + 8) / 0.8), caps: 4, 'rules-end': 4,
|
|
30
|
+
});
|
|
31
|
+
export const rulesCaps = seats => Object.fromEntries(RULES_FRAMES.map(t => [t, capOf(t, seats)]));
|
|
32
|
+
/** Two seconds of headroom absorb timer stalls and transport batching. */
|
|
33
|
+
export function takeRulesToken(buckets, kind, rate, now, burst = rate * 2) {
|
|
34
|
+
let b = buckets.get(kind);
|
|
35
|
+
if (!b) { b = { tokens: burst, at: now }; buckets.set(kind, b); }
|
|
36
|
+
b.tokens = Math.min(burst, b.tokens + Math.max(0, now - b.at) * rate / 1000);
|
|
37
|
+
b.at = now;
|
|
38
|
+
if (b.tokens + 1e-9 < 1) return Math.ceil((1 - b.tokens) * 1000 / rate);
|
|
39
|
+
b.tokens = Math.max(0, b.tokens - 1);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
package/worker/pages.mjs
CHANGED
|
@@ -565,7 +565,8 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
565
565
|
if (m.what === 'roster') state.roster = m.slots;
|
|
566
566
|
// Stopped for good. Removed, or the room closed: the notice. A room with no place left at all: the same notice
|
|
567
567
|
// with its own words, because "try another room" is the only thing a player can do about it.
|
|
568
|
-
if (m.what === '
|
|
568
|
+
if (m.what === 'line') state.gameLine = typeof m.text === 'string' ? m.text.slice(0, 200) : null;
|
|
569
|
+
if (m.what === 'closed') { state.closed = m.why; if (typeof m.message === 'string') state.gameLine = m.message.slice(0, 200); if (m.why === 'kicked' || m.why === 'room-closed' || m.why === 'agents-off') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); else if (m.why === 'room-full' || m.why === 'too-many') notice('full', m); }
|
|
569
570
|
// A game's net.pickPlayer(seat): only an owner's page listens (its overlay opens that player's card).
|
|
570
571
|
if (m.what === 'pick' && (m.seat === null || typeof m.seat === 'number')) { try { window.dispatchEvent(new CustomEvent('homie-pick', { detail: { seat: m.seat } })); } catch (e) {} }
|
|
571
572
|
paint();
|
|
@@ -775,7 +776,8 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
775
776
|
if (f && f.counts && f.counts.watchers) bits.push(f.counts.watchers + ' watching');
|
|
776
777
|
if (state.full) bits.push('waiting for a seat');
|
|
777
778
|
// Stopped for good: nothing is knocking any more, so it never says "reconnecting". What is true, in a word.
|
|
778
|
-
if (state.
|
|
779
|
+
if (state.gameLine) bits.push(state.gameLine);
|
|
780
|
+
else if (state.closed) bits.push({ replaced: 'opened in another tab', 'room-full': 'room full', 'too-many': 'room full', stale: 'updating…', kicked: 'removed from this room', 'room-closed': 'room closed', 'agents-off': 'room closed' }[state.closed] || 'disconnected: reload to play');
|
|
779
781
|
// The link (section 22): cut off from a room it was in and knocking again ("reconnecting" is true only then), or
|
|
780
782
|
// playing by itself because the room never answered, or did not come back in time.
|
|
781
783
|
else if (state.link && state.link.state === 'reconnecting') bits.push('reconnecting');
|