@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.
- package/CHANGELOG.md +43 -1
- package/README.md +35 -9
- package/app-starters/welcome/app.json +8 -1
- package/app-starters/welcome/map/main.json +1 -0
- package/app-starters/welcome/src/move.ts +3 -0
- package/app-starters/welcome/src/rules.ts +21 -0
- package/app-starters/welcome/src/{main.ts → view.ts} +8 -6
- package/bin/homie-studio.mjs +3 -3
- package/lib/build.mjs +31 -12
- package/lib/codex.mjs +1 -1
- package/lib/mcp-tools.mjs +10 -7
- package/lib/mcp.mjs +2 -1
- package/lib/port-check.mjs +7 -4
- package/lib/port.mjs +2 -2
- package/lib/rules-build.mjs +6 -6
- package/lib/rules-guard.mjs +9 -3
- package/lib/scaffold.mjs +42 -12
- package/lib/studio.mjs +3 -3
- package/lib/template-history.json +149 -5
- package/lib/trailer.mjs +7 -3
- package/lib/typecheck.mjs +10 -8
- package/netplay/NETPLAY.md +8 -0
- package/netplay/netplay.ts +3 -0
- package/package.json +1 -1
- package/rules/host.ts +4 -2
- package/rules/interest.mjs +79 -0
- package/rules/rules.ts +13 -5
- package/rules/view.ts +4 -1
- package/starters/coin-dash/src/move.ts +2 -0
- package/starters/coin-dash/src/rules.ts +2 -0
- package/starters/coin-dash/src/view.ts +2 -0
- package/starters/ember-vale/src/move.ts +2 -0
- package/starters/ember-vale/src/rules.ts +2 -0
- package/starters/ember-vale/src/view.ts +2 -0
- package/starters/gem-rush/src/move.ts +2 -0
- package/starters/gem-rush/src/rules.ts +2 -0
- package/starters/gem-rush/src/view.ts +2 -0
- package/starters/gem-rush-3d/src/move.ts +2 -0
- package/starters/gem-rush-3d/src/rules.ts +2 -0
- package/starters/gem-rush-3d/src/view.ts +2 -0
- package/starters/hero-rush-3d/src/move.ts +2 -0
- package/starters/hero-rush-3d/src/rules.ts +2 -0
- package/starters/hero-rush-3d/src/view.ts +2 -0
- package/worker/room.mjs +15 -5
- 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
|
|
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(
|
|
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
|
|
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
|
+
// 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
|
+
// 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) {
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.44.0';
|