@homie-rocks/studio 0.43.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 +17 -1
- package/README.md +14 -0
- package/lib/template-history.json +41 -0
- package/netplay/netplay.ts +3 -0
- package/package.json +1 -1
- package/rules/host.ts +2 -0
- package/rules/interest.mjs +79 -0
- package/rules/rules.ts +13 -5
- package/rules/view.ts +3 -1
- package/worker/room.mjs +15 -5
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,9 +17,25 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.44.0] - 2026-10-10
|
|
21
|
+
|
|
22
|
+
**Plugin 0.44.0** · [#83](https://github.com/homie-rocks/homie/pull/83)
|
|
23
|
+
|
|
24
|
+
Send each player the part of the room near their character.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Server-hosted rules games can choose `room.view.radiusM` in game.json. Views receive nearby entities and their own body, with compact state updates and fresh state on reconnect. Omit it to keep the whole-room view.
|
|
29
|
+
- Seeded tests cover spatial visibility, delta recovery and 300 simulated clients through the host and relay. The longer capacity workload runs in the extended suite.
|
|
30
|
+
|
|
31
|
+
### Upgrade notes
|
|
32
|
+
|
|
33
|
+
- `room.each`, `enter` and `leave` describe visible entities when a radius is set. Dispose of departed meshes and recreate them when they return. Rules and movement need no changes; existing starters keep their whole-room views.
|
|
34
|
+
- This is the first bigger-rooms slice. The public build/join path still has 32 seats until Gates ship in the next slice. The 300-client proof is local, not a Cloudflare capacity measurement. Interest filtering does not hide secrets; private fields and tells follow later.
|
|
35
|
+
|
|
20
36
|
## [0.43.0] - 2026-10-09
|
|
21
37
|
|
|
22
|
-
**Plugin 0.43.0**
|
|
38
|
+
**Plugin 0.43.0** · [release-2026-10-10-studio-0.43.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-10-studio-0.43.0)
|
|
23
39
|
|
|
24
40
|
Ask for a game and its rules run on your server.
|
|
25
41
|
|
package/README.md
CHANGED
|
@@ -1259,3 +1259,17 @@ Homie as a short note you see and send yourself (`homie_feedback`, above).
|
|
|
1259
1259
|
## License
|
|
1260
1260
|
|
|
1261
1261
|
Apache-2.0. See `LICENSE` and `NOTICE`.
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
### Spatial state delivery
|
|
1265
|
+
|
|
1266
|
+
Server-hosted rules games may set `room.view.radiusM` in game.json to send each
|
|
1267
|
+
player nearby entities and its own body. Omitted or null sends the whole room.
|
|
1268
|
+
Rules and movement stay the same; the view's `each`, `enter` and `leave` describe
|
|
1269
|
+
the visible set. Dispose of meshes on leave and recreate them on enter.
|
|
1270
|
+
Watchers, shared state, effects and rosters remain public. This is bandwidth
|
|
1271
|
+
filtering, not private state. Browser-hosted rooms send the whole room.
|
|
1272
|
+
|
|
1273
|
+
0.44.0 delivers this first part of bigger rooms. Public admission above 32 seats
|
|
1274
|
+
arrives with Gates in the next slice. See [the milestone 2 notes](../../docs/rooms-milestone-2-notes.md)
|
|
1275
|
+
for the remaining slices and the local 300-client proof.
|
|
@@ -2385,6 +2385,47 @@
|
|
|
2385
2385
|
}
|
|
2386
2386
|
}
|
|
2387
2387
|
}
|
|
2388
|
+
},
|
|
2389
|
+
"0.44.0": {
|
|
2390
|
+
"files": {
|
|
2391
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2392
|
+
"README.md": "684b3cca8001673c",
|
|
2393
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2394
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2395
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2396
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2397
|
+
"posts/README.md": "37841aae476bd540",
|
|
2398
|
+
"site/README.md": "732894273a32dc3f",
|
|
2399
|
+
"site/src/worker.mjs": "34444a6ca4b14437"
|
|
2400
|
+
},
|
|
2401
|
+
"sections": {
|
|
2402
|
+
"AGENTS.md": {
|
|
2403
|
+
"(intro)": "214aa46eb3a895d5",
|
|
2404
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2405
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2406
|
+
"## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
|
|
2407
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2408
|
+
"## Making games": "9e44559de535d842",
|
|
2409
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2410
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2411
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2412
|
+
"## Servers and AI seats": "d0028361fe567add",
|
|
2413
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2414
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2415
|
+
"## Rules": "2e6b29afb85dcf7e",
|
|
2416
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2417
|
+
"## Beta": "9d02638207a26e73"
|
|
2418
|
+
}
|
|
2419
|
+
},
|
|
2420
|
+
"named": {
|
|
2421
|
+
"Homie Arcade": {
|
|
2422
|
+
"sections": {
|
|
2423
|
+
"AGENTS.md": {
|
|
2424
|
+
"## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2388
2429
|
}
|
|
2389
2430
|
}
|
|
2390
2431
|
}
|
package/netplay/netplay.ts
CHANGED
|
@@ -2628,6 +2628,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2628
2628
|
setRole(next, wasOffline && roleKnown ? 'reconnected' : String(m['why'] ?? 'welcome'), { ckpt, snap, round: roundInfo, roster: slots });
|
|
2629
2629
|
if (next === 'host' && snap) tick = Math.max(tick, snap.k);
|
|
2630
2630
|
}
|
|
2631
|
+
// A reconnect to the same role emits no role event, but its welcome is a new
|
|
2632
|
+
// connection's keyframe. Rules views must adopt it before the first delta.
|
|
2633
|
+
if (rulesGame && snap && next !== 'host') emit('snapshot', snap);
|
|
2631
2634
|
if (continuing) { rulesHost?.sync(rulesPeers()); reannounce(); catchUp(had, hadSeat); }
|
|
2632
2635
|
if (link === 'online') paintLink(); else setLink('online', 'welcome');
|
|
2633
2636
|
// A build older than the live one, let in to its own room (section 23).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/rules/host.ts
CHANGED
|
@@ -99,6 +99,7 @@ export interface Host {
|
|
|
99
99
|
readonly running: boolean;
|
|
100
100
|
readonly paused: boolean;
|
|
101
101
|
readonly people: number;
|
|
102
|
+
readonly viewRadiusM: number | null;
|
|
102
103
|
frame(m: Record<string, unknown>): void;
|
|
103
104
|
/** Start the tick loop (the first person has joined). */
|
|
104
105
|
start(): void;
|
|
@@ -767,6 +768,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
767
768
|
}
|
|
768
769
|
|
|
769
770
|
return {
|
|
771
|
+
get viewRadiusM() { return c.settings.view?.radiusM ?? null; },
|
|
770
772
|
get tick() { return core.tick; },
|
|
771
773
|
get epoch() { return epoch; },
|
|
772
774
|
get running() { return running; },
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Per-connection state delivery. Pure data, shared by the relay and the view.
|
|
2
|
+
* Deltas name a keyframe, never an unacknowledged previous delta. Losing a delta
|
|
3
|
+
* therefore loses no baseline. A new keyframe bounds recovery to one second.
|
|
4
|
+
* No history grows with time: one keyframe per connection, no per-frame queue.
|
|
5
|
+
*/
|
|
6
|
+
export function interestSnapshot(snap, seat, radiusM) {
|
|
7
|
+
const [round, entities] = snap.d;
|
|
8
|
+
const own = seat === null ? null : entities.find(e => e[9] === seat && e[10] === 0);
|
|
9
|
+
const visible = own && radiusM !== null ? entities.filter(e => {
|
|
10
|
+
const p = e[3], q = own[3];
|
|
11
|
+
return e === own || (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + ((p[2] ?? 0) - (q[2] ?? 0)) ** 2 <= radiusM ** 2;
|
|
12
|
+
}) : seat === null ? entities : own ? entities : [];
|
|
13
|
+
return { ...snap, d: [round, visible], c: (snap.c ?? []).filter(row => row[0] === seat) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function snapshotEncoder(keyframeTicks) {
|
|
17
|
+
let base = null;
|
|
18
|
+
let rows = new Map();
|
|
19
|
+
return {
|
|
20
|
+
reset() { base = null; rows.clear(); },
|
|
21
|
+
encode(snap) {
|
|
22
|
+
if (!base || snap.e !== base.e || snap.k <= base.k || snap.k - base.k >= keyframeTicks) {
|
|
23
|
+
base = snap;
|
|
24
|
+
rows = new Map(snap.d[1].map(e => [e[0], e.map(v => JSON.stringify(v))]));
|
|
25
|
+
return snap;
|
|
26
|
+
}
|
|
27
|
+
const present = new Set(), changed = [];
|
|
28
|
+
for (const entity of snap.d[1]) {
|
|
29
|
+
const id = entity[0]; present.add(id);
|
|
30
|
+
const before = rows.get(id);
|
|
31
|
+
if (!before) { changed.push(entity); continue; }
|
|
32
|
+
let mask = 0; const values = [];
|
|
33
|
+
for (let i = 1; i < entity.length; i++) if (JSON.stringify(entity[i]) !== before[i]) { mask |= 1 << i; values.push(entity[i]); }
|
|
34
|
+
if (mask) changed.push([id, mask, values]);
|
|
35
|
+
}
|
|
36
|
+
return { ...snap, d: { base: base.k, round: snap.d[0], changed, removed: [...rows.keys()].filter(id => !present.has(id)) } };
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function snapshotDecoder() {
|
|
42
|
+
let base = null;
|
|
43
|
+
let rows = new Map();
|
|
44
|
+
return {
|
|
45
|
+
reset() { base = null; rows.clear(); },
|
|
46
|
+
decode(snap) {
|
|
47
|
+
if (!snap || typeof snap !== 'object') return null;
|
|
48
|
+
if (Array.isArray(snap.d)) {
|
|
49
|
+
if (!Array.isArray(snap.d[0]) || !Array.isArray(snap.d[1]) ||
|
|
50
|
+
!snap.d[1].every(e => Array.isArray(e) && e.length >= 9 && typeof e[0] === 'string')) return null;
|
|
51
|
+
// A late duplicate keyframe must not replace the newer baseline.
|
|
52
|
+
if (!base || snap.e !== base.e || snap.k >= base.k) {
|
|
53
|
+
base = snap; rows = new Map(snap.d[1].map(e => [e[0], e]));
|
|
54
|
+
}
|
|
55
|
+
return snap;
|
|
56
|
+
}
|
|
57
|
+
const d = snap.d;
|
|
58
|
+
if (!base || !d || d.base !== base.k || snap.e !== base.e || snap.k <= base.k) return null;
|
|
59
|
+
if (!Array.isArray(d.round) || !Array.isArray(d.removed) || !Array.isArray(d.changed)) return null;
|
|
60
|
+
const next = new Map(rows);
|
|
61
|
+
for (const id of d.removed) next.delete(id);
|
|
62
|
+
for (const change of d.changed) {
|
|
63
|
+
if (!Array.isArray(change) || typeof change[0] !== 'string') return null;
|
|
64
|
+
if (change.length !== 3) {
|
|
65
|
+
if (change.length < 9) return null;
|
|
66
|
+
next.set(change[0], change); continue;
|
|
67
|
+
}
|
|
68
|
+
if (!Number.isInteger(change[1]) || change[1] < 0 || !Array.isArray(change[2])) return null;
|
|
69
|
+
const old = rows.get(change[0]);
|
|
70
|
+
if (!old) return null;
|
|
71
|
+
const entity = old.slice(); let j = 0;
|
|
72
|
+
for (let i = 1; i < entity.length; i++) if (change[1] & (1 << i)) entity[i] = change[2][j++];
|
|
73
|
+
if (j !== change[2].length) return null;
|
|
74
|
+
next.set(entity[0], entity);
|
|
75
|
+
}
|
|
76
|
+
return { ...snap, d: [d.round, [...next.values()]] };
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
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); }
|
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';
|