@homie-rocks/studio 0.46.2 → 0.46.5

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.
@@ -1682,7 +1682,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1682
1682
  else if (m.t === 'state' && typeof m.k === 'string') applyState(m.k, m.d);
1683
1683
  else if (m.t === 'round') { const incoming = m.round as RoundInfo; roundInfo = m.retime && roundInfo?.n === incoming.n ? { ...roundInfo, ...incoming } : incoming; if (!m.retime) { emit('round', roundInfo); post?.({ what: 'round', round: roundInfo }); } }
1684
1684
  else if (m.t === 'roster' && (offline || !connected)) { slots = m.slots as Slot[]; emit('roster', slots); post?.({ what: 'roster', slots }); }
1685
- else if (m.t === 'ev' && (m.to === undefined || m.to === seat || m.to === id)) emit('event', { k: String(m.k), d: m.d, from: typeof m.from === 'number' ? m.from : null });
1685
+ else if (m.t === 'ev' && (m.to === undefined || m.to === seat || m.to === id || (m.to === -1 && seat === null))) emit('event', { k: String(m.k), d: m.d, from: typeof m.from === 'number' ? m.from : null });
1686
1686
  }
1687
1687
  let role: Role = 'replica';
1688
1688
  let roleKnown = false;
@@ -2547,6 +2547,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2547
2547
  // An AI alone in a room (nobody seated): it knocks again only after a while (section 17).
2548
2548
  if (code === 'agents-alone') aloneUntil = wall() + AGENTS_ALONE_WAIT_MS;
2549
2549
  if (code === 'host-fault') { rulesNotice(String(m.message)); return; }
2550
+ // A result the server could not keep for this player (0.46.5): said on their screen, not dropped in silence.
2551
+ if (code === 'unsaved') { rulesNotice(String(m.message)); return; }
2550
2552
  if (code === 'vote') { warnOnce('vote', m['message']); return; }
2551
2553
  if (code === 'rate' || code === 'state-full') { warnOnce(`${code}:${String(m['of'] ?? '')}`, m['message']); return; }
2552
2554
  // The room still runs another build of the game (section 23): not final. Its players were told to reload; this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.46.2",
3
+ "version": "0.46.5",
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/core.ts CHANGED
@@ -69,6 +69,10 @@ export const SEND = 16;
69
69
  export const THROWN = 512;
70
70
  /** The most effects (`world.emit`) one tick takes. */
71
71
  export const FX_MAX = 256;
72
+ /** The most entities one effect's audience names (`world.emit(effect, at, data, { to })`). */
73
+ export const FX_TO_MAX = 32;
74
+ /** Who may perceive an effect: within so many metres of where it happened (-1: no distance), the entities named, and where it happened. */
75
+ export type FxAudience = [within: number, to: string[], from: [number, number, number] | null];
72
76
  /** The most area events (`world.sendArea`) one tick takes. */
73
77
  export const AREAS_MAX = 64;
74
78
  /** What the log names when a room ends because of what it holds, not because of a handler. */
@@ -85,7 +89,7 @@ export interface StepInput { values: Record<string, unknown>; claim?: { pos: Vec
85
89
  export interface ResultRow { seat: number; id: string; driver: Driver; score: number; place: number }
86
90
  export type CoreOut =
87
91
  | { t: 'round'; n: number; phase: 'live' | 'over'; endsAt: number; startedAt: number; results?: ResultRow[] }
88
- | { t: 'fx'; tick: number; list: [number, unknown, unknown][] }
92
+ | { t: 'fx'; tick: number; list: [number, unknown, unknown, FxAudience?][] }
89
93
  | { t: 'ask'; n: string; state: unknown; questions: Record<string, unknown> }
90
94
  | { t: 'goalDone'; seat: number; ok: boolean }
91
95
  | { t: 'seats' }
@@ -162,6 +166,8 @@ export interface Core {
162
166
  /** The current round, for a host rebasing wall-clock deadlines after a pause or restore. */
163
167
  round(): Extract<CoreOut, { t: 'round' }>;
164
168
  bodies(): Body[];
169
+ /** Every person's body with where it stands: who an effect with an audience reaches (0.46.3). */
170
+ listeners(): { seat: number; id: string; pos: Vec3 }[];
165
171
  /** The body in a seat, as the host runtime needs it for the input protocol. */
166
172
  character(seat: number): import('./rules.ts').Character | null;
167
173
  bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; driver: Driver } | null;
@@ -235,7 +241,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
235
241
  let sharedROVer = -1;
236
242
  const sharedChanged = (): void => { sharedDirty = true; sharedVer += 1; };
237
243
  let out: CoreOut[] = [];
238
- let fx: [number, unknown, unknown][] = [];
244
+ let fx: [number, unknown, unknown, FxAudience?][] = [];
239
245
  /** What is left of the running tick's budget. A room's first `room.start` runs before any tick, with a whole one. */
240
246
  let left = c.settings.budget.tick;
241
247
  let trips = 0;
@@ -527,6 +533,8 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
527
533
  const world = brand({} as Record<string, unknown>);
528
534
  const getters: Record<string, () => unknown> = {
529
535
  tick: () => tick, dt: () => dt, round: () => roundApi, shared: sharedView, level: () => policy.level, levelMax: () => policy.levelMax, guideLevel: () => policy.guideLevel ?? policy.level, guideSeats: () => policy.guideSeats ?? EMPTY_GUIDE_SEATS, kids: () => Boolean(policy.kids), levelSet: () => Boolean(policy.levelSet),
536
+ // What the running handler may still spend (0.46.3): a handler that plans can stop short of its share instead of being stopped.
537
+ unitsLeft: () => Math.max(0, Math.min(2147483647, Math.floor(G.left))),
530
538
  stage: () => stage, tune: () => c.tune, math: () => math, map: () => mapApi,
531
539
  };
532
540
  for (const [name, get] of Object.entries(getters)) Object.defineProperty(world, name, { get, enumerable: true });
@@ -594,14 +602,30 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
594
602
  const wait = w >= 1 ? Math.min(w, 0x7fffffff) : 1;
595
603
  push({ due: tick + wait, to: cx.ent ? cx.ent.id : '', kind: cx.ent ? 'ev' : 'room', ev: ev as string, data: shapeData(c.events, ev, 'the event', data), at: tick });
596
604
  },
597
- emit: (effect: unknown, at: unknown, data?: unknown): void => {
605
+ emit: (effect: unknown, at: unknown, data?: unknown, audience?: unknown): void => {
598
606
  charge(10);
599
607
  if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.emit is for handlers');
600
608
  const i = typeof effect === 'string' ? c.effectNames.indexOf(effect) : -1;
601
609
  if (i < 0) throw new Error(`the effect "${said(effect)}" is not declared in shapes.effects`);
602
610
  if (fx.length >= FX_MAX) { G.note?.('effect', '', said(effect)); return; }
603
611
  const shape = c.effects[c.effectNames[i]];
604
- fx.push([i, typeof at === 'string' ? at.slice(0, 24) : packVec(V(at), dims), packFields(shape, shapeData(c.effects, effect, 'the effect', data), dims)]);
612
+ const row: [number, unknown, unknown, FxAudience?] = [i, typeof at === 'string' ? at.slice(0, 24) : packVec(V(at), dims), packFields(shape, shapeData(c.effects, effect, 'the effect', data), dims)];
613
+ // An audience (0.46.3): who may perceive this effect. Without one it is public, as before.
614
+ if (audience !== undefined && audience !== null) {
615
+ charge(8);
616
+ const to = own(audience, 'to'), within = own(audience, 'within');
617
+ const ids: string[] = [];
618
+ if (typeof to === 'string') ids.push(to.slice(0, 24));
619
+ else if (Array.isArray(to)) { if (to.length > FX_TO_MAX) throw new Error(`an effect's audience names at most ${FX_TO_MAX} entities`); for (let n = 0; n < to.length; n++) { const id = to[n]; if (typeof id === 'string') ids.push(id.slice(0, 24)); } }
620
+ else if (to !== undefined) throw new Error('an effect\'s audience is { to: id | [ids], within: metres }');
621
+ if (within !== undefined && (typeof within !== 'number' || !Number.isFinite(within) || within < 0)) throw new Error('an effect\'s audience `within` is a distance in metres');
622
+ if (typeof to === 'undefined' && within === undefined) throw new Error('an effect\'s audience is { to: id | [ids], within: metres }');
623
+ // The place it is heard from: where it was emitted, or where the entity it names stands now.
624
+ const e = typeof at === 'string' ? ents.get(at.slice(0, 24)) : undefined;
625
+ const from = typeof at === 'string' ? (e && !e.dead ? e.pos : null) : V(at);
626
+ row.push([typeof within === 'number' ? within : -1, ids, from ? [from.x, from.y, from.z] : null]);
627
+ }
628
+ fx.push(row);
605
629
  },
606
630
  spawn: (kind: unknown, at: unknown, fields?: unknown): string => {
607
631
  charge(10);
@@ -1484,6 +1508,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1484
1508
  shared: () => packFields(c.shared, shared, dims),
1485
1509
  character: (seat) => { const e = playerBodies().find(e => e.seat === seat); return e ? JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })) : null; },
1486
1510
  bodies: () => playerBodies().map((e) => ({ seat: e.seat, id: e.id, kind: e.kind.name, driver: e.driver, owner: e.owner, away: e.away, score: e.kind.score ? Number(e.f[e.kind.score]) || 0 : 0, r: e.r })),
1511
+ listeners: () => playerBodies().filter((e) => e.driver === 'person').map((e) => ({ seat: e.seat, id: e.id, pos: e.pos })),
1487
1512
  bodyOf: (seat) => { const s = seats.get(seat); const e = s?.id ? ents.get(s.id) : null; return e ? { id: e.id, kind: e.kind, r: e.r, away: e.away, driver: e.driver } : null; },
1488
1513
  drain: () => { const o = out; out = []; return o; },
1489
1514
  save,
package/rules/host.ts CHANGED
@@ -132,9 +132,12 @@ export interface Host {
132
132
  readonly core: Core;
133
133
  }
134
134
 
135
- interface Entry { at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim'] }
135
+ interface Entry { at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim']; /** How many ticks before its own tick it arrived (0.46.3): the time the input itself waited here. */ early?: number }
136
136
  interface SeatIn {
137
- pulses?: [string,number][];
137
+ /** A valued pulse, and the tick its browser predicted it on (0: none given, act at once). */
138
+ pulses?: [string,number,number?,number?][];
139
+ /** The wait of the entry now held, in ticks (see Entry.early). */
140
+ early?: number;
138
141
  held: Record<string, unknown>; claim: StepInput['claim']; entries: Entry[];
139
142
  /** The newest stamp this seat has sent, the stamps not yet acknowledged, and the newest acknowledged. */
140
143
  newest: number; stamps: number[]; ack: number;
@@ -254,7 +257,7 @@ export function createHost(o: HostOptions): Host {
254
257
  for (const [seat, q] of saved.inputs) {
255
258
  check(uint(seat) && seat < c.seats && q && valuesOk(seat, q.held) && claimOk(q.claim) && uint(q.newest) && uint(q.ack) && uint(q.lastFrameTick) && Number.isInteger(q.lead) && q.lead >= -127 && q.lead <= 127);
256
259
  check(Array.isArray(q.stamps) && q.stamps.length <= 256 && q.stamps.every(uint) && Array.isArray(q.entries) && q.entries.length <= c.settings.tickHz + 1);
257
- if(q.pulses!==undefined)check(Array.isArray(q.pulses)&&q.pulses.length<=16&&q.pulses.every(p=>Array.isArray(p)&&p.length===2&&c.kinds.some(k=>k.input.some(([n,f])=>n===p[0]&&f.t==='pulse'))&&Number.isInteger(p[1])&&p[1]>0&&p[1]<=255));
260
+ if(q.pulses!==undefined)check(Array.isArray(q.pulses)&&q.pulses.length<=16&&q.pulses.every(p=>Array.isArray(p)&&(p.length===2||p.length>=3&&p.length<=4&&uint(p[2])&&(p[3]===undefined||uint(p[3])))&&c.kinds.some(k=>k.input.some(([n,f])=>n===p[0]&&f.t==='pulse'))&&Number.isInteger(p[1])&&p[1]>0&&p[1]<=255));
258
261
  for (const e of q.entries) check(uint(e.at) && valuesOk(seat, e.values) && claimOk(e.claim) && Array.isArray(e.presses) && e.presses.every((p) => c.kinds.some((k) => k.input.some(([name, fd]) => (typeof p === 'string' ? name === p && fd.t === 'press' : Array.isArray(p) && p.length === 2 && name === p[0] && fd.t === 'pulse' && Number.isInteger(p[1]) && p[1] > 0 && p[1] <= 255)))));
259
262
  }
260
263
  }
@@ -273,7 +276,27 @@ export function createHost(o: HostOptions): Host {
273
276
  }
274
277
  },
275
278
  } : {};
276
- const core = createCore(c, { ...characterOptions, rewindTicks: seat => Math.ceil(((measuredRtt.get(seat) ?? 0) + 150 + period) / period), label: (seat,driver)=>nameOf(seat,driver), moved: o.moved, observe: o.observe, noted: o.noted, seed: Math.floor(random() * 4294967296) >>> 0, epoch: saved ? undefined : (Math.floor(random() * 4294967295) >>> 0) + 1, restore: saved?.core ?? null, restoreEpoch: o.restoreEpoch, stage: o.stage, decisions: !o.check });
279
+ /*
280
+ * How far back a person's ray may look (0.46.3). A shot is judged where the shooter saw the target, so the rewind is
281
+ * the age of that view when the input was RECEIVED, plus the time the input then waited for its tick:
282
+ *
283
+ * the view's age the relay's own measurement of this connection's round trip (a low percentile over a window of
284
+ * acknowledgements carried by the input frames themselves, worker/room.mjs), plus 150 ms of
285
+ * presentation delay. A client cannot bring the measurement under its real latency. It can
286
+ * pretend to be slower, by holding back what it sends, and then its input is late too.
287
+ * the wait whole ticks between an entry's arrival and the tick it was stamped for (at least one, at most a
288
+ * quarter of a second): tied to when the server received the input, not to what the client says.
289
+ *
290
+ * `room.rewindSeconds` is the game's hard cap on the sum. Without it the cap is `room.historySeconds`, as before.
291
+ */
292
+ const historyTicks = Math.ceil((c.historySeconds ?? 0) * tickHz);
293
+ const rewindCap = c.rewindSeconds !== undefined ? Math.ceil(c.rewindSeconds * tickHz) : null;
294
+ const rewindTicks = (seat: number): number => {
295
+ const waited = Math.max(1, Math.min(lateTicks, queues.get(seat)?.early ?? 1));
296
+ const need = Math.ceil(((measuredRtt.get(seat) ?? 0) + 150) / period) + waited;
297
+ return rewindCap === null ? need : Math.min(rewindCap, need);
298
+ };
299
+ const core = createCore(c, { ...characterOptions, rewindTicks, label: (seat,driver)=>nameOf(seat,driver), moved: o.moved, observe: o.observe, noted: o.noted, seed: Math.floor(random() * 4294967296) >>> 0, epoch: saved ? undefined : (Math.floor(random() * 4294967295) >>> 0) + 1, restore: saved?.core ?? null, restoreEpoch: o.restoreEpoch, stage: o.stage, decisions: !o.check });
277
300
  let epoch = core.epoch;
278
301
  if (saved && (o.startPaused ?? o.restoreEpoch !== undefined)) for (const body of core.bodies()) if (body.driver !== 'bot' && body.owner !== 'reserved') core.seatAway(body.seat, true);
279
302
  let lastSaveAt = o.clock.now();
@@ -415,9 +438,21 @@ export function createHost(o: HostOptions): Host {
415
438
  for (const [seat, held, claim, newest, ack] of saved?.queues ?? []) queues.set(seat, { held, claim, entries: [], newest, stamps: [], ack, lead: 127, lastFrameTick: core.tick });
416
439
  for (const [seat, q] of saved?.inputs ?? []) queues.set(seat, q);
417
440
  const neutral = (kind: KindTable): Record<string, unknown> => { const v = initFields(kind.input, c.dims); for (const [name, fd] of kind.input) if (fd.t === 'press' || fd.t === 'pulse') v[name] = fd.t === 'press' ? false : 0; return v; };
441
+ /*
442
+ * `f.held()` inputs that arrive late (0.46.3). A held value is how much of ONE tick a control was down, so the sum over
443
+ * a tap's ticks is the tap. When an entry misses its tick, that tick has already run on the value before it (a
444
+ * release that arrives late ran one more whole tick of movement), and applying the entry a tick later as well would
445
+ * count the tap twice over. So the difference between what the missed ticks ran on and what they were meant to run
446
+ * on is owed, and paid on the next ticks, inside the range the control reads in. The body then ends where the
447
+ * browser predicted it, however the frames fell. Kept beside the queues and not saved: a restart forgets a debt.
448
+ */
449
+ const heldNames = new Map<KindTable, string[]>();
450
+ const heldOf = (kind: KindTable): string[] => { let names = heldNames.get(kind); if (!names) { names = kind.input.filter(([, fd]) => fd.t === 'held').map(([name]) => name); heldNames.set(kind, names); } return names; };
451
+ const pulsesTaken = new Map<number, number>();
452
+ const heldBook = new Map<number, { ran: [number, Record<string, number>][]; debt: Record<string, number>; until: number }>();
418
453
  function queueOf(seat: number, kind: KindTable): SeatIn {
419
454
  let q = queues.get(seat);
420
- if (!q) { q = { held: neutral(kind), claim: null, entries: [], newest: 0, stamps: [], ack: 0, lead: 127, lastFrameTick: core.tick }; queues.set(seat, q); }
455
+ if (!q) { q = { held: neutral(kind), claim: null, entries: [], newest: 0, stamps: [], ack: 0, lead: 127, lastFrameTick: core.tick }; queues.set(seat, q); pulsesTaken.delete(seat); }
421
456
  return q;
422
457
  }
423
458
  const people = (): number => { let n = 0; for (const person of present.values()) if (person) n += 1; return n; };
@@ -461,9 +496,11 @@ export function createHost(o: HostOptions): Host {
461
496
  }
462
497
  const claim = kind.body?.owner && raw.length >= i + 9 ? { pos: unpackVec([raw[i], raw[i + 1], raw[i + 2]], c.dims), vel: unpackVec([raw[i + 3], raw[i + 4], raw[i + 5]], c.dims), heading: unpackVec([raw[i + 6], raw[i + 7], raw[i + 8]], c.dims), r } : null;
463
498
  q.stamps.push(j);
464
- if (j > K) { q.entries.push({ at: j, values, presses, claim }); continue; }
499
+ if (j > K) { q.entries.push({ at: j, values, presses, claim, early: Math.min(lateTicks, j - K) }); continue; }
465
500
  // Late: its values hold from the next tick, and its presses fire there if that is within a quarter of a second of its stamp.
466
501
  stats.lateEntries += 1;
502
+ const book = heldBook.get(seat as number);
503
+ if (book) for (const row of book.ran) if (row[0] >= j) { for (const name of heldOf(kind)) { const meant = Number(values[name]) || 0; book.debt[name] = (book.debt[name] ?? 0) + meant - (row[1][name] ?? 0); row[1][name] = meant; } book.until = K + 1 + lateTicks; }
467
504
  const at = (K + 1) >>> 0;
468
505
  const keep = at - j <= lateTicks ? presses : [];
469
506
  const same = q.entries.find((x) => x.at === at);
@@ -554,9 +591,15 @@ export function createHost(o: HostOptions): Host {
554
591
  if (agents && (kind === 'agent:do' || /^ask:/.test(kind) || (SPEECH.test(kind) && present.get(m.from as number) === false))) { if (agentInbox.length < 128) agentInbox.push(m); return; }
555
592
  if(kind === 'pulse') {
556
593
  const data=m.d,body=core.bodyOf(m.from as number);
557
- if(!body || !hasHands(m.from as number,body.driver) || !Array.isArray(data) || data.length!==3 || data[0]!==epoch || typeof data[1]!=='string' || !body.kind.input.some(([name,fd])=>name===data[1]&&fd.t==='pulse'))return;
594
+ if(!body || !hasHands(m.from as number,body.driver) || !Array.isArray(data) || data.length<3 || data.length>5 || data[0]!==epoch || typeof data[1]!=='string' || !body.kind.input.some(([name,fd])=>name===data[1]&&fd.t==='pulse'))return;
558
595
  const value=coerce({t:'pulse'},data[2],c.dims) as number,q=queueOf(m.from as number,body.kind);q.pulses??=[];
559
- if(value && q.pulses.length<16)q.pulses.push([data[1],value]);return;
596
+ // The browser names the tick it predicted the pulse on (0.46.3): the server acts on it in that tick, as it does for a
597
+ // stamped input, so what was predicted is what the tick's snapshot holds. A tick already run, or none: at once.
598
+ const at=Number.isInteger(data[3])&&(data[3] as number)>0?Math.min(data[3] as number,0xffffffff):0;
599
+ // Its number in this browser's own count (0.46.3): the control row says the newest one acted on, so a browser knows
600
+ // whether a pulse it predicted has happened yet, however late its message was.
601
+ const n=Number.isInteger(data[4])&&(data[4] as number)>0?Math.min(data[4] as number,0xffffffff):0;
602
+ if(value && q.pulses.length<16)q.pulses.push(n?[data[1],value,at,n]:at?[data[1],value,at]:[data[1],value]);return;
560
603
  }
561
604
  if (kind === 'cmd') { const d = m.d as unknown[]; if (Array.isArray(d) && typeof d[0] === 'string') core.command(m.from as number, d[0], d[1]); return; }
562
605
  // Speech keeps its payload, except the identity fields only the host may attach.
@@ -586,7 +629,9 @@ export function createHost(o: HostOptions): Host {
586
629
  /* ---------------------------------------------------------------- frames out */
587
630
 
588
631
  const hasHands = (seat: number, driver: Driver): boolean => driver === 'person' || (driver === 'ai' && [...peers.values()].some((p) => p.seat === seat && p.agent?.hands === 'self'));
589
- const nameOf = (seat: number, driver: Driver): string => (driver === 'bot' || (driver === 'ai' && (core.bodies().find((b) => b.seat === seat)?.owner === 'reserved' || !names.has(seat))) ? BOT_NAMES[seat % BOT_NAMES.length] : names.get(seat) || `Player ${seat + 1}`);
632
+ // The game's own names for its bots (room.bots.names, 0.46.5), else the toolkit's.
633
+ const botNames = c.botNames?.length ? c.botNames : BOT_NAMES;
634
+ const nameOf = (seat: number, driver: Driver): string => (driver === 'bot' || (driver === 'ai' && (core.bodies().find((b) => b.seat === seat)?.owner === 'reserved' || !names.has(seat))) ? botNames[seat % botNames.length] : names.get(seat) || `Player ${seat + 1}`);
590
635
  function sendRoster(): void {
591
636
  const guides = new Map(agentSlots().map((s) => [s.slot, s.agent]));
592
637
  const slots = core.bodies().sort((a, b) => a.seat - b.seat).map((b) => ({ slot: b.seat, seat: b.driver === 'bot' ? null : b.seat, name: nameOf(b.seat, b.driver), bot: b.driver === 'bot', ...(guides.has(b.seat) ? { agent: guides.get(b.seat) } : {}) }));
@@ -616,10 +661,27 @@ export function createHost(o: HostOptions): Host {
616
661
  } });
617
662
  } else if (x.t === 'ask') o.send({ t: 'decide', n: x.n, state: x.state, questions: x.questions });
618
663
  else if (x.t === 'goalDone') agents?.done(x.seat, x.ok);
619
- else if (x.t === 'fx') o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, x.list] });
664
+ else if (x.t === 'fx') {
665
+ // Effects with an audience (0.46.3) go to who may perceive them: one frame a person whose body is named or in
666
+ // range, with the tick's public effects in the order they were emitted. A watcher (no seat) and a seat with no
667
+ // body perceive the room: every effect with a range, never one addressed only `to` somebody.
668
+ // A tick with no audience at all is one frame for everybody, as before.
669
+ if (!x.list.some((row) => row[3])) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, x.list] });
670
+ else {
671
+ const all = x.list.map((row) => [row[0], row[1], row[2]]);
672
+ const bodies = new Map(core.listeners().map((l) => [l.seat, l]));
673
+ const watching = all.filter((_, n) => { const a = x.list[n][3]; return !a || a[0] >= 0; });
674
+ for (const seat of present.keys()) {
675
+ const l = bodies.get(seat);
676
+ const mine = !l ? watching : all.filter((_, n) => { const a = x.list[n][3]; return !a || a[1].includes(l.id) || (a[0] >= 0 && a[2] !== null && Math.hypot(a[2][0] - l.pos.x, a[2][1] - l.pos.y, a[2][2] - l.pos.z) <= a[0]); });
677
+ if (mine.length) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, mine], to: seat });
678
+ }
679
+ if (watching.length) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, watching], to: -1 });
680
+ }
681
+ }
620
682
  else if (x.t === 'seats') roster = true;
621
683
  else if (x.t === 'shared') o.send({ t: 'state', k: 'shared', d: core.shared() });
622
- else if (x.t === 'epoch') { forceSave = true; epoch = x.epoch; queues.clear(); roster = true; } else if (x.t === 'fail') end('budget', { kind: x.kind, handler: x.handler });
684
+ else if (x.t === 'epoch') { forceSave = true; epoch = x.epoch; queues.clear(); heldBook.clear(); pulsesTaken.clear(); roster = true; } else if (x.t === 'fail') end('budget', { kind: x.kind, handler: x.handler });
623
685
  }
624
686
  if (roster || !rosterText) sendRoster();
625
687
  }
@@ -639,15 +701,26 @@ export function createHost(o: HostOptions): Host {
639
701
  const presses: (string | [string, number])[] = [];
640
702
  while (q.entries.length && q.entries[0].at <= t) {
641
703
  const e = q.entries.shift() as Entry;
642
- q.held = e.values; q.claim = e.claim;
704
+ q.held = e.values; q.claim = e.claim; q.early = e.early ?? 1;
643
705
  if (e.at === t) for (const p of e.presses) presses.push(p);
644
706
  }
645
707
  // Silence: no frame for a second. Its input is neutral until the next entry.
646
708
  if (t - q.lastFrameTick > tickHz && !q.entries.length) { q.held = neutral(body.kind); q.claim = null; }
647
709
  while (q.stamps.length && q.stamps[0] <= t) q.ack = q.stamps.shift() as number;
648
710
  const values = { ...q.held };
711
+ const heldFields = heldOf(body.kind);
712
+ if (heldFields.length) {
713
+ let book = heldBook.get(seat);
714
+ if (!book) { book = { ran: [], debt: {}, until: 0 }; heldBook.set(seat, book); }
715
+ const level: Record<string, number> = {};
716
+ for (const name of heldFields) level[name] = Number(values[name]) || 0;
717
+ book.ran.push([t, level]); if (book.ran.length > lateTicks + 1) book.ran.shift();
718
+ if (book.until >= t) for (const name of heldFields) { const owed = book.debt[name]; if (owed) { const range = Math.max(1, Math.abs(level[name])), want = level[name] + owed, paid = Math.max(-range, Math.min(range, want)); values[name] = paid; book.debt[name] = want - paid; } }
719
+ else book.debt = {};
720
+ }
649
721
  for (const p of presses) { if (typeof p === 'string') values[p] = true; else values[p[0]] = p[1]; }
650
- const pulse=q.pulses?.shift();if(pulse)values[pulse[0]]=pulse[1];
722
+ // One pulse a tick, in order: the head waits for its own tick when that is no more than a quarter of a second ahead.
723
+ const head=q.pulses?.[0];if(head&&!((head[2]??0)>t&&(head[2] as number)-t<=Math.ceil(tickHz/4))){q.pulses!.shift();values[head[0]]=head[1];if(head[3])pulsesTaken.set(seat,head[3]);}
651
724
  inputs.set(seat, { values, claim: q.claim });
652
725
  }
653
726
  if (roundRebasePending) publishRebase(now);
@@ -677,7 +750,14 @@ export function createHost(o: HostOptions): Host {
677
750
  for (const b of core.bodies()) {
678
751
  if (!hasHands(b.seat, b.driver)) continue;
679
752
  const q = queues.get(b.seat);
680
- rows.push([b.seat, b.r, q ? q.ack : 0, q && q.lead !== 127 ? q.lead : -128]);
753
+ // A fifth number only for a seat that has sent a numbered pulse: the newest one acted on.
754
+ const taken = pulsesTaken.get(b.seat);
755
+ const row = [b.seat, b.r, q ? q.ack : 0, q && q.lead !== 127 ? q.lead : -128];
756
+ // A sixth number only in a game that rewinds (room.historySeconds, 0.46.5): how many ticks back this seat's own
757
+ // ray may look right now, so its browser can predict against the pose the server will judge, not a guess at it.
758
+ if (historyTicks) row.push(taken ?? 0, Math.min(historyTicks, rewindTicks(b.seat)));
759
+ else if (taken !== undefined) row.push(taken);
760
+ rows.push(row);
681
761
  if (q) q.lead = 127;
682
762
  }
683
763
  // A snapshot is stamped with the moment its tick was due, so ticks run in a burst to catch up are still a tick
package/rules/pack.ts CHANGED
@@ -140,6 +140,7 @@ const made = <T extends object>(fd: Field, o: T): T => { Object.freeze(o); MADE.
140
140
  export function coerce(fd: Field, v: unknown, dims: number): unknown {
141
141
  switch (fd.t) {
142
142
  case 'bit': case 'press': return v === true || v === 1;
143
+ case 'held':
143
144
  case 'fix': { const x = num(v); const n = Math.round(x * FIX_STEP) / FIX_STEP; return Number.isFinite(n) ? Math.max(-FIX_MAX, Math.min(FIX_MAX, n)) + 0 : zeroed(v, x); }
144
145
  case 'vec3': return vec3(v, dims);
145
146
  case 'dir': return dir(v, dims);
package/rules/rules.ts CHANGED
@@ -54,7 +54,7 @@ export const QUEUE_MAX = 16_384;
54
54
 
55
55
  /* ------------------------------------------------------------------ field types */
56
56
 
57
- export type FieldKind = 'u8' | 'u16' | 'u32' | 'i8' | 'i16' | 'i32' | 'bit' | 'fix' | 'vec3' | 'dir' | 'tick' | 'ticks' | 'ref' | 'text' | 'list' | 'map' | 'struct' | 'press' | 'pulse';
57
+ export type FieldKind = 'u8' | 'u16' | 'u32' | 'i8' | 'i16' | 'i32' | 'bit' | 'fix' | 'vec3' | 'dir' | 'tick' | 'ticks' | 'ref' | 'text' | 'list' | 'map' | 'struct' | 'press' | 'pulse' | 'held';
58
58
  export interface Field {
59
59
  readonly t: FieldKind;
60
60
  /** The value a new entity starts with. */
@@ -82,7 +82,8 @@ const plain = (t: FieldKind) => (o?: FieldOptions): Field => made(t, o);
82
82
  * false; `fix` is a fixed-point number in steps of 1/4096; `vec3` is a position or a velocity in metres; `dir` is a
83
83
  * unit vector in the declared dimensions; `tick` is a moment and `ticks` a length on the room's clock; `ref` is the id of an
84
84
  * entity or a player; `text`, `list` and `map` name their largest size. `press` (in `input` only) is a button that
85
- * fires once.
85
+ * fires once; `held` (in `input` only) is a number the browser averages over each tick, so a tap shorter than a tick
86
+ * moves for the part of the tick it was down.
86
87
  */
87
88
  export const f = brand(Object.freeze({
88
89
  u8: plain('u8'), u16: plain('u16'), u32: plain('u32'), i8: plain('i8'), i16: plain('i16'), i32: plain('i32'),
@@ -93,6 +94,8 @@ export const f = brand(Object.freeze({
93
94
  struct: (fields: Fields, o?: FieldOptions): Field => made('struct', o, { fields: Object.freeze({ ...fields }) }),
94
95
  press: (): Field => made('press', undefined),
95
96
  pulse: (): Field => made('pulse', undefined),
97
+ /** Input only (0.46.3): a number averaged over the tick. A key down for a third of a tick reads a third; a stick reads its mean. */
98
+ held: (): Field => made('held', undefined),
96
99
  }));
97
100
 
98
101
  /**
@@ -164,9 +167,12 @@ export interface RulesDef {
164
167
  shared?: Fields;
165
168
  room?: {
166
169
  rounds?: { seconds: number; breakSeconds: number };
167
- bots?: { keep: number };
170
+ /** `names` (0.46.5): what the game's bots are called, by seat; the toolkit's own list without it. */
171
+ bots?: { keep: number; names?: readonly string[] };
168
172
  records?: RoundRecords;
169
173
  historySeconds?: number;
174
+ /** The furthest a person's ray may be rewound, in seconds (0.46.3): a hard cap under `historySeconds`, whatever their connection measures. */
175
+ rewindSeconds?: number;
170
176
  start?: RoomHandler;
171
177
  join?: (ctx: any, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string; character?: Character | null }) => { kind: string; at: Vec3; heading?: Vec3; fields?: Record<string, unknown>; motion?: Record<string, unknown> };
172
178
  on?: Record<string, RoomHandler>;
@@ -387,8 +393,11 @@ export interface Compiled {
387
393
  view: FieldList;
388
394
  records?: RoundRecords;
389
395
  historySeconds?: number;
396
+ /** The furthest a person's ray may be rewound, in seconds (0.46.3): a hard cap under `historySeconds`, whatever their connection measures. */
397
+ rewindSeconds?: number;
390
398
  rounds: { seconds: number; breakSeconds: number } | null;
391
399
  bots: number;
400
+ botNames?: readonly string[];
392
401
  start: RoomHandler | null;
393
402
  join: NonNullable<RulesDef['room']>['join'] | null;
394
403
  roomOn: Record<string, RoomHandler>;
@@ -409,7 +418,7 @@ export const BUILT_IN_FIELDS = Object.freeze(['id', 'kind', 'pos', 'vel', 'headi
409
418
 
410
419
  const NAME = /^[a-z][A-Za-z0-9_]{0,31}$/;
411
420
  const INTS = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32']);
412
- const TYPES = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32', 'bit', 'fix', 'vec3', 'dir', 'tick', 'ticks', 'ref', 'text', 'list', 'map', 'struct', 'press', 'pulse']);
421
+ const TYPES = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32', 'bit', 'fix', 'vec3', 'dir', 'tick', 'ticks', 'ref', 'text', 'list', 'map', 'struct', 'press', 'pulse', 'held']);
413
422
 
414
423
  /**
415
424
  * One declared field, checked, and made again as the runtime's own: a frozen record of its type, its sizes and its
@@ -436,7 +445,8 @@ function checkField(fd: unknown, where: string, inInput: boolean): void {
436
445
  const x = fd as Field;
437
446
  if (!x || typeof x !== 'object' || typeof x.t !== 'string' || !TYPES.has(x.t)) throw new Error(`${where} is not a field type: declare it with f (f.u16(), f.vec3(), f.list(f.ref(), 8)…)`);
438
447
  if ((x.t === 'press' || x.t === 'pulse') && !inInput) throw new Error(`${where}: f.press() is for input only (a button that fires once)`);
439
- if (inInput && !(INTS.has(x.t) || x.t === 'bit' || x.t === 'press' || x.t === 'pulse' || x.t === 'fix')) throw new Error(`${where}: an input field is a whole number, f.fix(), f.bit() or f.press()`);
448
+ if (x.t === 'held' && !inInput) throw new Error(`${where}: f.held() is for input only (a control averaged over the tick it was held in)`);
449
+ if (inInput && !(INTS.has(x.t) || x.t === 'bit' || x.t === 'press' || x.t === 'pulse' || x.t === 'held' || x.t === 'fix')) throw new Error(`${where}: an input field is a whole number, f.fix(), f.held(), f.bit() or f.press()`);
440
450
  if (x.durable !== undefined && (typeof x.durable !== 'boolean' || inInput || !/^(entities\.[^.]+\.fields|shared)\.[^.]+$/.test(where))) throw new Error(`${where}: durable belongs on a top-level state field`);
441
451
  if (x.visibility !== undefined && !/^entities\.[^.]+\.fields\.[^.]+$/.test(where)) throw new Error(`${where}: visibility belongs on a top-level entity field`);
442
452
  if (x.visibility !== undefined && (!['owner','server','results'].includes(x.visibility) || inInput)) throw new Error(`${where}: visibility is owner, server or results on state fields only`);
@@ -628,6 +638,8 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
628
638
  }
629
639
  const keep = room.bots === undefined ? 0 : typeof room.bots?.keep === 'number' ? Math.floor(room.bots.keep) : NaN;
630
640
  if (!(keep >= 0 && keep <= SEATS_MAX)) throw new Error(`room.bots is { keep: n }, 0 to ${SEATS_MAX}`);
641
+ const botNames = (room.bots as { names?: unknown } | undefined)?.names;
642
+ if (botNames !== undefined && !(Array.isArray(botNames) && botNames.length >= 1 && botNames.length <= 64 && botNames.every((n) => typeof n === 'string' && n.trim().length >= 1 && n.length <= 24 && !/[\u0000-\u001f\u007f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/.test(n)))) throw new Error('room.bots.names is a list of 1 to 64 names, each 1 to 24 characters on one line');
631
643
  if (room.start !== undefined && typeof room.start !== 'function') throw new Error('room.start is a function');
632
644
  if (room.join !== undefined && typeof room.join !== 'function') throw new Error('room.join is a function');
633
645
  const players = kinds.filter((k) => k.player);
@@ -646,13 +658,14 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
646
658
  }
647
659
  }
648
660
  if (room.historySeconds !== undefined && (typeof room.historySeconds !== 'number' || !Number.isFinite(room.historySeconds) || room.historySeconds < 0 || room.historySeconds > 2)) throw new Error('room.historySeconds is from 0 to 2');
661
+ if (room.rewindSeconds !== undefined && (typeof room.rewindSeconds !== 'number' || !Number.isFinite(room.rewindSeconds) || room.rewindSeconds < 0 || room.rewindSeconds > (room.historySeconds ?? 0))) throw new Error('room.rewindSeconds is the most a shot is rewound for a slow connection: a number from 0 to room.historySeconds');
649
662
  const seats = Math.max(1, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(Number(env.seats)) || 8));
650
663
  const navigation:Record<string,readonly RouteNode[]> = Object.create(null);let graphNodes=0;
651
664
  if(d.navigation){const entries=Object.entries(d.navigation);if(entries.length>8)throw new Error('navigation declares at most 8 graphs');for(const [name,graph] of entries){if(!/^[A-Za-z][A-Za-z0-9_]{0,47}$/.test(name))throw new Error('navigation graph names are identifiers up to 48 characters');navigation[name]=prepareGraph(graph);graphNodes+=navigation[name].length;if(graphNodes>8192)throw new Error('navigation declares at most 8192 nodes in total');}}
652
665
  Object.freeze(navigation);
653
666
  return {
654
667
  navigation, contract: RULES_CONTRACT, save: SAVE_REVISION, dims, kinds, kindOf, events, commands, effects, effectNames: Object.keys(effects),
655
- shared: fieldList(d.shared, 'shared'), view, ...(room.records?{records:room.records}:{}), ...(room.historySeconds ? {historySeconds:room.historySeconds}:{}), rounds, bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
668
+ shared: fieldList(d.shared, 'shared'), view, ...(room.records?{records:room.records}:{}), ...(room.historySeconds ? {historySeconds:room.historySeconds}:{}), ...(room.rewindSeconds !== undefined ? {rewindSeconds:room.rewindSeconds}:{}), rounds, ...(botNames ? { botNames: Object.freeze([...(botNames as string[])]) } : {}), bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
656
669
  tune, publicTune, map, settings, seats,
657
670
  };
658
671
  }
@@ -665,6 +678,8 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
665
678
  */
666
679
  export interface Schema {
667
680
  records?: true;
681
+ /** A game that rewinds (0.46.5): its window and its cap, for a browser predicting its own shots. */
682
+ rewind?: { historySeconds: number; capSeconds?: number };
668
683
  contract: 2;
669
684
  dims: 2 | 3;
670
685
  seats: number;
@@ -674,6 +689,8 @@ export interface Schema {
674
689
  commands: Record<string, FieldList>;
675
690
  shared: FieldList;
676
691
  historySeconds?: number;
692
+ /** The furthest a person's ray may be rewound, in seconds (0.46.3): a hard cap under `historySeconds`, whatever their connection measures. */
693
+ rewindSeconds?: number;
677
694
  rounds: { seconds: number; breakSeconds: number } | null;
678
695
  settings: RoomSettings;
679
696
  }
@@ -687,7 +704,7 @@ function publicField(field: Field, hidden=false): Field {
687
704
  }
688
705
  export function schemaOf(c: Compiled): Schema {
689
706
  return {
690
- contract: RULES_CONTRACT, dims: c.dims, seats: c.seats, ...(c.records?{records:true as const}:{}),
707
+ contract: RULES_CONTRACT, dims: c.dims, seats: c.seats, ...(c.records?{records:true as const}:{}), ...(c.historySeconds ? { rewind: { historySeconds: c.historySeconds, ...(c.rewindSeconds !== undefined ? { capSeconds: c.rewindSeconds } : {}) } } : {}),
691
708
  kinds: c.kinds.map((k) => ({ ...(k.query?{query:k.query}:{}), ...(k.collider?{collider:k.collider}:{}), ...(k.body?.terrain ? {terrain:k.body.terrain} : {}), name: k.name, player: Boolean(k.player), ...(k.player?.control ? {control:k.player.control}:{}), owner: Boolean(k.body?.owner), radius: k.body?.radius ?? 0, ...(c.dims === 3 && k.body ? { shape: k.body.shape, height: k.body.height || 2 * k.body.radius } : {}), maxSpeed: k.body?.maxSpeed ?? 0, score: k.score, fields: k.fields.map(([name,field])=>[name,publicField(field)]), motion: k.motion, input: k.input })),
692
709
  effects: c.effects, effectNames: c.effectNames, commands: c.commands, shared: c.shared, rounds: c.rounds, settings: c.settings,
693
710
  };
package/rules/types.ts CHANGED
@@ -22,6 +22,7 @@ export interface RayOptions {
22
22
  export type RayHit = Hit & { readonly dist: number };
23
23
  export interface RaySnapshot {sweep(body:{pos:Point;grounded?:boolean},delta:Point,options?:{ground?:boolean}):Hit|undefined;support(body:{pos:Point},distance?:number):(Hit&{dist:number})|undefined;ray(from:Point,direction:Point,max:number):RayHit|undefined;rayAll(from:Point,direction:Point,max:number):readonly RayHit[]}
24
24
  export interface RayQueries { rays(options?:RayOptions):RaySnapshot; ray(from: Point, direction: Point, max: number, options?: RayOptions): RayHit | undefined; rayAll(from: Point, direction: Point, max: number, options?: RayOptions): readonly RayHit[] }
25
+ export interface EffectAudience { readonly to?: string | readonly string[]; readonly within?: number }
25
26
  export type Area = { sphere: { at: Point; r: number } } | { box: { min: Point; max: Point } } | { cone: { at: Point; dir: Point; r: number; angle: number } };
26
27
  export interface MapView { readonly name: string; spot(name: string): Vec3 | undefined; spots(name: string): readonly Vec3[] }
27
28
  export interface Clock { readonly tick: number; readonly dt: number; ticks(seconds: number): number; readonly math: typeof math }
@@ -30,6 +31,8 @@ export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
30
31
  readonly tune: ReadonlyState<T>; readonly map: MapView; readonly stage: string; readonly level: number; readonly levelMax: number; readonly guideLevel: number; readonly guideSeats: readonly number[]; readonly kids: boolean; readonly levelSet: boolean;
31
32
  readonly shared: Room extends true ? S : ReadonlyState<S>;
32
33
  readonly round: Round & (Room extends true ? { end(): void; restart(): void } : {});
34
+ /** Budget units the running handler may still spend (0.46.3). */
35
+ readonly unitsLeft: number;
33
36
  random(): number;
34
37
  label(player:string):string;
35
38
  send<K extends keyof E>(target: string, event: K, ...data: Payload<E[K]>): void;
@@ -38,6 +41,8 @@ export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
38
41
  sendArea<K extends keyof E>(area: Area, event: K, ...data: Payload<E[K]>): void;
39
42
  after<K extends keyof E>(ticks: number, event: K, ...data: Payload<E[K]>): void;
40
43
  emit<K extends keyof F>(effect: K, at: Point | string, ...data: Payload<F[K]>): void;
44
+ /** With an audience (0.46.3): only the people driving the entities named in `to`, and those whose own body is within `within` metres, receive it. A watcher receives the ones with a range, never one addressed only `to` somebody. */
45
+ emit<K extends keyof F>(effect: K, at: Point | string, data: WriteState<F[K]>, audience: EffectAudience): void;
41
46
  spawn<K extends keyof C>(kind: K, at: Point, fields?: Partial<WriteState<C[K]>>): string;
42
47
  near<K extends keyof V>(at: Point, radius: number, kind: K): readonly Query<V[K]>[];
43
48
  near(at: Point, radius: number): readonly Query<V[keyof V]>[];