@homie-rocks/studio 0.43.0 → 0.44.1

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/rules/types.ts CHANGED
@@ -1,5 +1,5 @@
1
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';
2
+ import type { Vec3, BodyDef, Fields, ColliderDef } from './rules.ts';
3
3
  import type { math } from './math.ts';
4
4
 
5
5
  export type Point = { readonly x: number; readonly y: number; readonly z?: number };
@@ -51,7 +51,7 @@ export type RoomEvents<E = Record<string, never>> = RoundEvents & { seatJoined:
51
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
52
  type GuideWorld<W> = Pick<W, Extract<keyof W, 'tick' | 'dt' | 'ticks' | 'math' | 'map' | 'tune' | 'stage' | 'level' | 'levelMax' | 'guideLevel' | 'guideSeats' | 'kids' | 'levelSet' | 'round' | 'shared' | 'near' | 'inBox' | 'ray'>>;
53
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;
54
+ player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot' }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef; collider?: ColliderDef;
55
55
  tick?: (world: W, self: S) => void;
56
56
  think?: (world: W, self: S) => Partial<I>;
57
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 };
@@ -60,4 +60,5 @@ export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A =
60
60
  guide?: { view: (world: GuideWorld<W>, self: ReadonlyState<Omit<S, 'input'>>) => V; floor?: (world: W, self: S, view: GuideView<V, G, A>) => D };
61
61
  };
62
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 } };
63
+ export interface MoveCollision<M> { sweep(body: MoveBody<M>, delta: Point): Hit | undefined; support(body: MoveBody<M>, distance?: number): (Hit & {dist: number}) | undefined; overlaps(body: MoveBody<M>): boolean }
64
+ export type MoveContext<T, M> = Clock & { readonly tune: ReadonlyState<T>; readonly map: MapView & MoveCollision<M>; readonly world: MoveCollision<M> };
package/rules/view.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { snapshotDecoder } from './interest.mjs';
2
+ import { collisionMap, revisionAt, type CollisionRevision } from './live.ts';
1
3
  import { rulesOutput } from '../netplay/rules-output.mjs';
2
4
  import { rulesCaps, rulesRates } from '../worker/limits.mjs';
3
5
  /*
@@ -126,7 +128,7 @@ export interface Room<R = unknown> {
126
128
  readonly __rules?: R;
127
129
  }
128
130
 
129
- interface Frame { k: number; e: number; at: number; round: [number, number, number]; ents: Map<string, Unpacked>; rows: number[][] }
131
+ interface Frame { k: number; e: number; at: number; round: [number, number, number]; ents: Map<string, Unpacked>; rows: number[][]; collision?: CollisionRevision }
130
132
  const frames = new WeakMap<object, Frame | null>();
131
133
  const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
132
134
 
@@ -162,15 +164,16 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
162
164
 
163
165
  /* ---------------------------------------------------------------- frames in */
164
166
 
167
+ const delivery = snapshotDecoder();
165
168
  function frameOf(s: Snapshot<unknown> | null): Frame | null {
166
169
  if (!s || typeof s !== 'object') return null;
167
170
  if (frames.has(s)) return frames.get(s) ?? null;
168
171
  let f: Frame | null = null;
169
- const d = s.d as [unknown, unknown] | null;
172
+ const d = delivery.decode(s)?.d as [unknown, unknown, CollisionRevision?] | null;
170
173
  if (Array.isArray(d) && Array.isArray(d[0]) && Array.isArray(d[1])) {
171
174
  const ents = new Map<string, Unpacked>();
172
175
  for (const w of d[1] as unknown[]) { const e = unpackEntity(schema.kinds, w, dims); if (e) ents.set(e.id, e); }
173
- f = { k: s.k, e: Number(s.e) || 0, at: clock(), round: [Number(d[0][0]) || 0, Number(d[0][1]) || 0, Number(d[0][2]) || 0], ents, rows: (Array.isArray(s.c) ? s.c : []) as unknown as number[][] };
176
+ f = { collision: d[2], k: s.k, e: Number(s.e) || 0, at: clock(), round: [Number(d[0][0]) || 0, Number(d[0][1]) || 0, Number(d[0][2]) || 0], ents, rows: (Array.isArray(s.c) ? s.c : []) as unknown as number[][] };
174
177
  }
175
178
  frames.set(s, f);
176
179
  return f;
@@ -247,7 +250,15 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
247
250
  const tickAt = (now: number): number => (base ? base.tick + (now - base.at) / period * speed : 0);
248
251
  /** The tick `move` is being run for: the step just taken, or the one after it while the own body is drawn between ticks. */
249
252
  let moveTick = 0;
250
- const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, shape: () => ({ shape: myKind()?.shape ?? 'sphere', radius: myKind()?.radius ?? 0, height: myKind()?.height ?? 0 }), dims });
253
+ let collisionHistory: CollisionRevision[] = [];
254
+ let cachedCollision: CollisionRevision | undefined;
255
+ let geometry = map as import('./math.ts').MapShapes;
256
+ const geometryAt = () => {
257
+ const revision = revisionAt(collisionHistory, moveTick);
258
+ if (revision !== cachedCollision) { geometry = collisionMap(map, revision?.[2] ?? []); cachedCollision = revision; }
259
+ return geometry;
260
+ };
261
+ const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, geometry: geometryAt, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, shape: () => ({ shape: myKind()?.shape ?? 'sphere', radius: myKind()?.radius ?? 0, height: myKind()?.height ?? 0 }), dims });
251
262
  function authorityRtt(): number {
252
263
  // The helper's ping ends at the relay. A browser host adds another network leg in both directions.
253
264
  // Snapshot stamps use the relay clock, so their observed age measures the complete downstream path.
@@ -297,6 +308,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
297
308
  const fn = moves[kindName];
298
309
  if (!fn) return body;
299
310
  moveTick = t;
311
+ // Like snapshot unpacking, prepare the bounded geometry outside the handler
312
+ // quota. Authority debits projection once per tick, not once per mover.
313
+ geometryAt();
300
314
  const out = 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); });
301
315
  const kind = kindOf.get(kindName), r = kind?.radius ?? 0;
302
316
  const height = kind?.height || 2 * r;
@@ -385,6 +399,12 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
385
399
  const before = latest;
386
400
  if (before && before.e === f.e && f.k <= before.k) return;
387
401
  latest = f;
402
+ if (!before || before.e !== f.e) collisionHistory = [];
403
+ const collision = f.collision;
404
+ if (collision) {
405
+ collisionHistory.push(collision);
406
+ if (collisionHistory.length > 128) collisionHistory.shift();
407
+ }
388
408
  if (before && before.e !== f.e) hostAges.length = 0;
389
409
  hostAges.push(Math.max(0, net.now() - s.st)); if (hostAges.length > 40) hostAges.shift();
390
410
  if (before && before.e !== f.e) fxQueue.length = 0;
@@ -5,15 +5,17 @@ import { defineMove, type Vec3 } from '@homie-rocks/studio/rules';
5
5
  import type { MoveBody, MoveContext } from '@homie-rocks/studio/rules/types';
6
6
 
7
7
  function slide<M>(body: MoveBody<M>, delta: Vec3, ctx: MoveContext<unknown, M>) {
8
- const before = body.pos, hit = ctx.map.sweep(body, delta);
8
+ const before = body.pos, hit = ctx.world.sweep(body, delta);
9
9
  if (!hit) return;
10
10
  const M = ctx.math, remaining = M.sub(delta, M.sub(body.pos, before));
11
11
  const into = M.dot(remaining, hit.normal);
12
- if (into < 0) ctx.map.sweep(body, M.sub(remaining, M.scale(hit.normal, into)));
12
+ if (into < 0) ctx.world.sweep(body, M.sub(remaining, M.scale(hit.normal, into)));
13
13
  }
14
14
 
15
15
  export const move = defineMove({
16
16
  runner(body, input, ctx) {
17
+ // Recheck support before jumping: a platform/cover may have moved or broken.
18
+ body.grounded = Boolean(ctx.world.support(body));
17
19
  const M = ctx.math, m = body.motion, T = ctx.tune;
18
20
  const dt = ctx.dt, knocked = m.knockAt > 0;
19
21
  const g0 = 2 * T.jumpHeight / (T.jumpRise * T.jumpRise);
@@ -32,7 +32,7 @@ export async function quoteCart(env,cat,origin,caller,input,request) {
32
32
  session = guest.session;
33
33
  caller = {...caller,id:session.player.id};
34
34
  }
35
- const quote = await customerOffer(env,'cart',{name:lines.map(l=>`${l.item.name} × ${l.quantity}`).join(', '),lines,player:caller.id??null,shop, sale:serviceTerms({scope:'cart',amount,currency:shop.currency,automaticTax:shop.automaticTax===true,taxBehavior:shop.taxBehavior,refundWindowDays:shop.refundDays,refund:`Studio refund window: ${shop.refundDays} days.`})});
35
+ const quote = await customerOffer(env,'cart',{name:lines.map(l=>`${l.item.name} × ${l.quantity}`).join(', '),lines,player:caller.id??null,shop, sale:serviceTerms({scope:'cart',amount,currency:shop.currency,automaticTax:shop.automaticTax===true,taxBehavior:shop.taxBehavior,refundWindowDays:shop.refundDays,refund:Number.isFinite(shop.refundDays)?`Studio refund window: ${shop.refundDays} days.`:undefined})});
36
36
  return {...quote,...(session?{account:{player:session.player,cookies:session.cookies}}:{})};
37
37
  }
38
38
  export async function fulfillCart(env,origin,order) {
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.43.0';
2
+ export const STUDIO_VERSION_TAG = '0.44.1';