@homie-rocks/studio 0.42.0 → 0.44.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/README.md +35 -9
  3. package/app-starters/welcome/app.json +8 -1
  4. package/app-starters/welcome/map/main.json +1 -0
  5. package/app-starters/welcome/src/move.ts +3 -0
  6. package/app-starters/welcome/src/rules.ts +21 -0
  7. package/app-starters/welcome/src/{main.ts → view.ts} +8 -6
  8. package/bin/homie-studio.mjs +3 -3
  9. package/lib/build.mjs +31 -12
  10. package/lib/codex.mjs +1 -1
  11. package/lib/mcp-tools.mjs +10 -7
  12. package/lib/mcp.mjs +2 -1
  13. package/lib/port-check.mjs +7 -4
  14. package/lib/port.mjs +2 -2
  15. package/lib/rules-build.mjs +6 -6
  16. package/lib/rules-guard.mjs +9 -3
  17. package/lib/scaffold.mjs +42 -12
  18. package/lib/studio.mjs +3 -3
  19. package/lib/template-history.json +149 -5
  20. package/lib/trailer.mjs +7 -3
  21. package/lib/typecheck.mjs +10 -8
  22. package/netplay/NETPLAY.md +8 -0
  23. package/netplay/netplay.ts +3 -0
  24. package/package.json +1 -1
  25. package/rules/host.ts +4 -2
  26. package/rules/interest.mjs +79 -0
  27. package/rules/rules.ts +13 -5
  28. package/rules/view.ts +4 -1
  29. package/starters/coin-dash/src/move.ts +2 -0
  30. package/starters/coin-dash/src/rules.ts +2 -0
  31. package/starters/coin-dash/src/view.ts +2 -0
  32. package/starters/ember-vale/src/move.ts +2 -0
  33. package/starters/ember-vale/src/rules.ts +2 -0
  34. package/starters/ember-vale/src/view.ts +2 -0
  35. package/starters/gem-rush/src/move.ts +2 -0
  36. package/starters/gem-rush/src/rules.ts +2 -0
  37. package/starters/gem-rush/src/view.ts +2 -0
  38. package/starters/gem-rush-3d/src/move.ts +2 -0
  39. package/starters/gem-rush-3d/src/rules.ts +2 -0
  40. package/starters/gem-rush-3d/src/view.ts +2 -0
  41. package/starters/hero-rush-3d/src/move.ts +2 -0
  42. package/starters/hero-rush-3d/src/rules.ts +2 -0
  43. package/starters/hero-rush-3d/src/view.ts +2 -0
  44. package/worker/room.mjs +15 -5
  45. package/worker/version.mjs +1 -1
package/rules/rules.ts CHANGED
@@ -37,7 +37,7 @@ export const SAVE_REVISION = 2;
37
37
  export const REACH_M = 64;
38
38
  /** The most entities one room holds. A spawn past it throws in the handler that asked. */
39
39
  export const ENTITY_MAX = 2048;
40
- /** The most seats one room holds (worker/seats.mjs `SEAT_MAX`). */
40
+ /** The current bot-fill ceiling; public admission lives in worker/seats.mjs. */
41
41
  export const SEATS_MAX = 32;
42
42
  /** The most values one declared field may hold when it is full (`cellsOf`), and the most all the fields of one kind of entity may. */
43
43
  export const FIELD_CELLS_MAX = 16_384;
@@ -176,6 +176,8 @@ export interface RoomSettings {
176
176
  offline: boolean;
177
177
  tickHz: number;
178
178
  inputHz: number;
179
+ /** Omit the radius to send the whole room. No implicit visibility or bandwidth cap. */
180
+ view: { radiusM: number | null };
179
181
  durability: { movementSeconds: number };
180
182
  budget: { tick: number };
181
183
  predict: { catchM: number | null; catchUp: number; snapM: number | null; blendMs: number; interpMs: number | null };
@@ -191,7 +193,7 @@ export const BUDGET_TICK = 500_000;
191
193
  export const BUDGET_SECOND = 10_000_000;
192
194
  export const budgetFor = (tickHz: number): number => Math.min(BUDGET_TICK, Math.floor(BUDGET_SECOND / tickHz));
193
195
  export const ROOM_DEFAULTS: RoomSettings = deepFreeze({
194
- host: 'server', offline: true, tickHz: 20, inputHz: 20, durability: { movementSeconds: 1 }, budget: { tick: BUDGET_TICK },
196
+ host: 'server', offline: true, tickHz: 20, inputHz: 20, view: { radiusM: null }, durability: { movementSeconds: 1 }, budget: { tick: BUDGET_TICK },
195
197
  predict: { catchM: null, catchUp: 1.25, snapM: null, blendMs: 100, interpMs: null },
196
198
  });
197
199
 
@@ -212,18 +214,24 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
212
214
  if (!Number.isFinite(n) || n < 0) { problems.push(`"room.${name}" is a number, zero or more; using its default`); return d; }
213
215
  return n;
214
216
  };
215
- const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict']);
216
- for (const key of Object.keys(r)) if (!known.has(key)) problems.push(`"room.${key}" is not a setting (host, offline, tickHz, inputHz, durability, budget, predict)`);
217
+ const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict', 'view']);
218
+ for (const key of Object.keys(r)) if (!known.has(key)) problems.push(`"room.${key}" is not a setting (host, offline, tickHz, inputHz, durability, budget, predict, view)`);
217
219
  let host: 'server' | 'browser' = 'server';
218
220
  if (r.host !== undefined) { if (r.host === 'server' || r.host === 'browser') host = r.host; else problems.push('"room.host" is "server" or "browser"; using "server"'); }
219
221
  let offline = true;
220
222
  if (r.offline !== undefined) { if (typeof r.offline === 'boolean') offline = r.offline; else problems.push('"room.offline" is true or false; using true'); }
221
223
  const tickHz = whole('tickHz', r.tickHz, 1, 60, 20);
222
224
  const inputHz = whole('inputHz', r.inputHz, 1, tickHz, tickHz);
225
+ if (host === 'browser' && r.view?.radiusM != null) problems.push('"room.view.radiusM" needs room.host "server"; browser hosting sends the whole room');
226
+ if (r.view !== undefined) {
227
+ if (!r.view || typeof r.view !== 'object' || Array.isArray(r.view)) problems.push('"room.view" is an object with an optional radiusM');
228
+ else for (const key of Object.keys(r.view)) if (key !== 'radiusM') problems.push(`"room.view.${key}" is not a setting; use radiusM`);
229
+ }
223
230
  const p = r.predict && typeof r.predict === 'object' ? r.predict : {};
224
231
  return {
225
232
  settings: {
226
233
  host, offline, tickHz, inputHz,
234
+ view: { radiusM: r.view?.radiusM === null ? null : num('view.radiusM', r.view?.radiusM, null) },
227
235
  durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, 1) },
228
236
  budget: { tick: Math.max(1, Math.floor(num('budget.tick', r.budget?.tick, budgetFor(tickHz)) as number)) },
229
237
  predict: { catchM: num('predict.catchM', p.catchM, null), catchUp: num('predict.catchUp', p.catchUp, 1.25) as number, snapM: num('predict.snapM', p.snapM, null), blendMs: num('predict.blendMs', p.blendMs, 100) as number, interpMs: num('predict.interpMs', p.interpMs, null) },
@@ -540,7 +548,7 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
540
548
  if (!a.questions || typeof a.questions !== 'object') throw new Error(`asks.${key}.questions is required; for example { advance: { type: 'noul', instructions: 'Should the party advance?' } }`);
541
549
  asks[key] = { ...a, stateFields: fieldList(a.state, `asks.${key}.state`) };
542
550
  }
543
- const seats = Math.max(1, Math.min(SEATS_MAX, Math.floor(Number(env.seats)) || 8));
551
+ const seats = Math.max(1, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(Number(env.seats)) || 8));
544
552
  return {
545
553
  contract: RULES_CONTRACT, save: SAVE_REVISION, dims, kinds, kindOf, events, commands, effects, effectNames: Object.keys(effects),
546
554
  shared: fieldList(d.shared, 'shared'), view, rounds, bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
package/rules/view.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { snapshotDecoder } from './interest.mjs';
1
2
  import { rulesOutput } from '../netplay/rules-output.mjs';
2
3
  import { rulesCaps, rulesRates } from '../worker/limits.mjs';
3
4
  /*
@@ -162,11 +163,12 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
162
163
 
163
164
  /* ---------------------------------------------------------------- frames in */
164
165
 
166
+ const delivery = snapshotDecoder();
165
167
  function frameOf(s: Snapshot<unknown> | null): Frame | null {
166
168
  if (!s || typeof s !== 'object') return null;
167
169
  if (frames.has(s)) return frames.get(s) ?? null;
168
170
  let f: Frame | null = null;
169
- const d = s.d as [unknown, unknown] | null;
171
+ const d = delivery.decode(s)?.d as [unknown, unknown] | null;
170
172
  if (Array.isArray(d) && Array.isArray(d[0]) && Array.isArray(d[1])) {
171
173
  const ents = new Map<string, Unpacked>();
172
174
  for (const w of d[1] as unknown[]) { const e = unpackEntity(schema.kinds, w, dims); if (e) ents.set(e.id, e); }
@@ -672,6 +674,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
672
674
  net.expose({
673
675
  self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
674
676
  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; },
677
+ movement: () => { const kinds = schema.kinds.filter(k => k.player); return kinds.length && kinds.every(k => k.maxSpeed === 0) ? 'stationary' : 'spatial'; },
675
678
  prediction: () => ({ ...correction, observedLeadAt, lastSendAt, now: clock(), serverTick: latest?.k, tick: stepped, catchTick, lead: targetLead(), medianLead: medianLead(), rate: speed, rtt: authorityRtt(), hostAge: quantile(hostAges, 0.5), pending: pending.length }),
676
679
  scores: () => roster().map((r) => ({ seat: r.seat, score: r.score })),
677
680
  score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => solo ? 'offline' : status,
@@ -1,3 +1,5 @@
1
+ // This same guarded step runs on the server and immediately for the local player.
2
+ // Only pose/motion, input, ctx map/public tune/math/clocks: no scores, queries or random.
1
3
  /*
2
4
  * COIN DASH — how a runner moves. Its own file, because two places run it: the server, for every body it moves, and
3
5
  * each player's browser, for that player's own body. So it reads only what both have: the body's own position,
@@ -1,3 +1,5 @@
1
+ // Game truth lives here: declared fields, legal inputs, next-tick events and the server clock.
2
+ // Read the game skill RULES.md before changing the mechanic; keep presentation in view.ts.
1
3
  /*
2
4
  * COIN DASH — the rules: the example of a game whose rules run on the server.
3
5
  *
@@ -1,3 +1,5 @@
1
+ // Presentation only: openRoom gives predicted own movement and interpolated other bodies.
2
+ // Send intent; rules decide outcomes. Keep rendered obstacles aligned with room.map.
1
3
  /*
2
4
  * COIN DASH — the view: what a player sees, and what they press.
3
5
  *
@@ -1,3 +1,5 @@
1
+ // This same guarded step runs on the server and immediately for the local player.
2
+ // Only pose/motion, input, ctx map/public tune/math/clocks: no scores, queries or random.
1
3
  import { defineMove } from '@homie-rocks/studio/rules';
2
4
 
3
5
  export const move = defineMove({
@@ -1,3 +1,5 @@
1
+ // Game truth lives here: declared fields, legal inputs, next-tick events and the server clock.
2
+ // Read the game skill RULES.md before changing the mechanic; keep presentation in view.ts.
1
3
  import { defineRules, f, type GameWorld, type GameSelf } from '@homie-rocks/studio/rules';
2
4
  import { move } from './move';
3
5
 
@@ -1,3 +1,5 @@
1
+ // Presentation only: openRoom gives predicted own movement and interpolated other bodies.
2
+ // Send intent; rules decide outcomes. Keep rendered obstacles aligned with room.map.
1
3
  /** Ember Vale: server-run hunts, with the hero save and memorials owned by its player. */
2
4
  import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, stripAi, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent } from '@homie-rocks/studio/port';
3
5
  // The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
@@ -1,3 +1,5 @@
1
+ // This same guarded step runs on the server and immediately for the local player.
2
+ // Only pose/motion, input, ctx map/public tune/math/clocks: no scores, queries or random.
1
3
  import { defineMove } from '@homie-rocks/studio/rules';
2
4
 
3
5
  export const move = defineMove({
@@ -1,3 +1,5 @@
1
+ // Game truth lives here: declared fields, legal inputs, next-tick events and the server clock.
2
+ // Read the game skill RULES.md before changing the mechanic; keep presentation in view.ts.
1
3
  import { defineRules, f } from '@homie-rocks/studio/rules';
2
4
  import { move } from './move';
3
5
 
@@ -1,3 +1,5 @@
1
+ // Presentation only: openRoom gives predicted own movement and interpolated other bodies.
2
+ // Send intent; rules decide outcomes. Keep rendered obstacles aligned with room.map.
1
3
  /** Gem Rush: the canvas draws the room; rules.ts decides the game. Coordinates here are pixels (50 per metre). */
2
4
  import { openRoom, type Entity } from '@homie-rocks/studio/rules/view';
3
5
  import type rules from './rules';
@@ -1,3 +1,5 @@
1
+ // This same guarded step runs on the server and immediately for the local player.
2
+ // Only pose/motion, input, ctx map/public tune/math/clocks: no scores, queries or random.
1
3
  import { defineMove, type Vec3 } from '@homie-rocks/studio/rules';
2
4
 
3
5
  import type { MoveBody, MoveContext } from '@homie-rocks/studio/rules/types';
@@ -1,3 +1,5 @@
1
+ // Game truth lives here: declared fields, legal inputs, next-tick events and the server clock.
2
+ // Read the game skill RULES.md before changing the mechanic; keep presentation in view.ts.
1
3
  import { defineRules, f, type GameWorld } from '@homie-rocks/studio/rules';
2
4
  import { move } from './move';
3
5
 
@@ -1,3 +1,5 @@
1
+ // Presentation only: openRoom gives predicted own movement and interpolated other bodies.
2
+ // Send intent; rules decide outcomes. Keep rendered obstacles aligned with room.map.
1
3
  /** Gem Rush 3D: the room runs the rules; this view draws the meadow and its animals. */
2
4
  import {
3
5
  AmbientLight, AnimationClip, AnimationMixer, Box3, BoxGeometry, BufferAttribute, BufferGeometry, CanvasTexture, CircleGeometry, Color, ConeGeometry, CylinderGeometry,
@@ -1,3 +1,5 @@
1
+ // This same guarded step runs on the server and immediately for the local player.
2
+ // Only pose/motion, input, ctx map/public tune/math/clocks: no scores, queries or random.
1
3
  import { defineMove, type Vec3 } from '@homie-rocks/studio/rules';
2
4
 
3
5
  import type { MoveBody, MoveContext } from '@homie-rocks/studio/rules/types';
@@ -1,3 +1,5 @@
1
+ // Game truth lives here: declared fields, legal inputs, next-tick events and the server clock.
2
+ // Read the game skill RULES.md before changing the mechanic; keep presentation in view.ts.
1
3
  import { defineRules, f, type GameWorld } from '@homie-rocks/studio/rules';
2
4
  import { move } from './move';
3
5
 
@@ -1,3 +1,5 @@
1
+ // Presentation only: openRoom gives predicted own movement and interpolated other bodies.
2
+ // Send intent; rules decide outcomes. Keep rendered obstacles aligned with room.map.
1
3
  /** Hero Rush 3D: server rules, predicted jumps, and the original meadow and heroes. */
2
4
  import {
3
5
  AmbientLight, Box3, BoxGeometry, BufferAttribute, BufferGeometry, CanvasTexture, CircleGeometry, Color, ConeGeometry, CylinderGeometry,
package/worker/room.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { interestSnapshot, snapshotEncoder } from '../rules/interest.mjs';
1
2
  /*
2
3
  * room.mjs — the netplay v1 relay semantics, with no transport in it.
3
4
  * =============================================================================
@@ -460,7 +461,7 @@ export class NetRoom {
460
461
  hostSnap(c, m, now, text = null) {
461
462
  // The server's stamp is this relay's own clock; a browser's is its estimate of it, and is checked.
462
463
  const snap = { k: Number(m.k) || 0, st: c ? this.stamp(m.st) : Number(m.st) || now, d: m.d ?? null };
463
- if (Array.isArray(m.c)) snap.c = m.c.slice(0, 64);
464
+ if (Array.isArray(m.c)) snap.c = m.c.slice(0, this.server ? this.seatCap : 64);
464
465
  // Revision 10: a rules game's snapshot names the room's epoch.
465
466
  if ((!c || this.rules && c.rules) && Number.isFinite(m.e)) snap.e = m.e;
466
467
  this.lastSnap = snap;
@@ -477,10 +478,19 @@ export class NetRoom {
477
478
  if (this.lite(o)) continue; // an agent with no game client draws nothing
478
479
  // A congested socket skips a snapshot rather than queueing a stale one.
479
480
  if (o.conn.buffered && o.conn.buffered() > 256 * 1024) { this.stats.drops += 1; continue; }
480
- this.sendText(o.conn, out);
481
+ this.sendText(o.conn, this.server?.viewRadiusM != null ? JSON.stringify({ t: 'snap', from: null, ...this.playerSnapshot(o, snap) }) : out);
481
482
  }
482
483
  }
483
484
 
485
+ /** The same per-player delivery role will run in Gates. Keyframes belong to sockets, not saved seats. */
486
+ playerSnapshot(client, snap, welcome = false) {
487
+ if (!snap || this.server?.viewRadiusM == null) return snap;
488
+ const selected = interestSnapshot(snap, client.seat, this.server.viewRadiusM);
489
+ client.snapEncoder ??= snapshotEncoder(this.tickHz);
490
+ if (welcome) client.snapEncoder.reset();
491
+ return client.snapEncoder.encode(selected);
492
+ }
493
+
484
494
  /** 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. */
485
495
  hostState(c, m, bytes) {
486
496
  const key = typeof m.k === 'string' ? m.k.slice(0, 64) : '';
@@ -530,7 +540,7 @@ export class NetRoom {
530
540
  hostRoster(c, m) {
531
541
  if (!Array.isArray(m.slots)) return;
532
542
  // A host cannot hide an AI: a seat an agent holds is named and marked so, a slot with no seat is a bot.
533
- this.lastRoster = this.labelRoster(m.slots.slice(0, 64));
543
+ this.lastRoster = this.labelRoster(m.slots.slice(0, this.server ? this.seatCap : 64));
534
544
  this.persistDirty = true;
535
545
  for (const o of (c && this.rules && c.rules ? this.live() : this.others(c))) this.send(o, { t: 'roster', slots: this.lastRoster });
536
546
  this.tellWatchers();
@@ -1102,7 +1112,7 @@ export class NetRoom {
1102
1112
  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,
1103
1113
  ...(this.gameVer ? { ver: this.gameVer } : {}), ...(behind ? { stale: { ver: this.currentVer } } : {}), stall: this.stallMs,
1104
1114
  role, why, host: this.hostRef(), peers: this.peers(), ...(this.rules ? { held: this.heldPeers() } : {}), st: now, max: this.maxPlayers,
1105
- round: this.lastRound, roster: this.lastRoster, snap: lite ? null : this.lastSnap, state: lite ? {} : this.stateObject(),
1115
+ round: this.lastRound, roster: this.lastRoster, snap: lite ? null : this.playerSnapshot(c, this.lastSnap, true), state: lite ? {} : this.stateObject(),
1106
1116
  ...(role === 'host' ? { ckpt: this.lastCkpt } : {}),
1107
1117
  ...(full ? { full: true } : {}),
1108
1118
  ...(this.liveAnnouncement() ? { announce: this.liveAnnouncement() } : {}),
@@ -1803,7 +1813,7 @@ export class NetRoom {
1803
1813
 
1804
1814
  /** A round's results, labelled the same way (`agent: true` on every AI's row). */
1805
1815
  labelResults(rows) {
1806
- return rows.slice(0, 64).map((raw) => this.labelOne(raw, true)).filter(Boolean);
1816
+ return rows.slice(0, this.server ? this.seatCap : 64).map((raw) => this.labelOne(raw, true)).filter(Boolean);
1807
1817
  }
1808
1818
 
1809
1819
  labelOne(raw, result) {
@@ -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.42.0';
2
+ export const STUDIO_VERSION_TAG = '0.44.0';