@homie-rocks/studio 0.50.7 → 0.51.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/view.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { correctionPath } from './correction-path.ts';
1
2
  import { disposeMoveContext, sweepMap } from './math.ts';
2
3
  import { boundPosition, periodicDelta, blendPosition, relativePoint, wrapPoint } from './topology.mjs';
3
4
  import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
@@ -108,6 +109,14 @@ export interface OpenRoomOptions {
108
109
  predict?: Record<string, Predictor>;
109
110
  /** Camera axes on the rules' x/y plane for the testing tools. Default: x right, y up. */
110
111
  screenBasis?: PortProbeOptions['basis'];
112
+ /** Horizontal intent for box-bodied 2D visual corrections: -1 left, 0 neutral, +1 right. Does not affect simulation. */
113
+ correctionDirection?: (input: Readonly<Record<string, unknown>>, velocity: Vec3) => number;
114
+ /**
115
+ * After this browser's connection comes back, the server hands the own body back where it has it (a new placement
116
+ * revision). A box-bodied 2D runner is then drawn on from where it was shown, not cut across. Say here when that
117
+ * revision is a real placement made while the player was away (a respawn, a new round): it is then drawn at once.
118
+ */
119
+ respawned?: (before: Entity, after: Entity) => boolean;
111
120
  /** Tests: a clock in ms and a way to run without timers. */
112
121
  now?: () => number;
113
122
  timers?: boolean;
@@ -224,6 +233,15 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
224
233
  /* ---------------------------------------------------------------- frames in */
225
234
 
226
235
  const delivery = snapshotDecoder();
236
+ /** Until when a revision of the own body counts as the server handing it back after a reconnect. */
237
+ let returningUntil = 0;
238
+ let unpackedRows = new Map<string, {wire: unknown; value: Unpacked}>();
239
+ const sameRow = (a: unknown, b: unknown): boolean => {
240
+ if (a === b) return true;
241
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
242
+ for (let i = 0; i < a.length; i++) if (!sameRow(a[i], b[i])) return false;
243
+ return true;
244
+ };
227
245
  function frameOf(s: Snapshot<unknown> | null): Frame | null {
228
246
  if (!s || typeof s !== 'object') return null;
229
247
  if (frames.has(s)) return frames.get(s) ?? null;
@@ -231,13 +249,29 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
231
249
  const d = delivery.decode(s)?.d as [unknown, unknown, CollisionRevision?] | null;
232
250
  if (Array.isArray(d) && Array.isArray(d[0]) && Array.isArray(d[1])) {
233
251
  const ents = new Map<string, Unpacked>();
234
- for (const w of d[1] as unknown[]) { const e = unpackEntity(schema.kinds, w, dims); if (e) ents.set(e.id, e); }
252
+ // Delta rows and repeated keyframe rows often describe unchanged scenery.
253
+ // Compare the immutable wire arrays before allocating typed vectors/fields.
254
+ // Retain only this frame's rows, not a growing history of transient keys.
255
+ const nextRows = new Map<string, {wire: unknown; value: Unpacked}>();
256
+ for (const w of d[1] as unknown[]) {
257
+ const id = Array.isArray(w) && typeof w[0] === 'string' ? w[0] : '';
258
+ const prior = unpackedRows.get(id);
259
+ const e = prior && sameRow(prior.wire, w) ? prior.value : unpackEntity(schema.kinds, w, dims);
260
+ if (e) { nextRows.set(e.id, prior?.value === e ? prior : {wire:w,value:e}); ents.set(e.id, e); }
261
+ }
262
+ unpackedRows = nextRows;
235
263
  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[][] };
236
264
  }
237
265
  frames.set(s, f);
238
266
  return f;
239
267
  }
240
- const entityOf = (u: Unpacked, mine = false): Entity => Object.freeze({ id: u.id, kind: u.kind, pos: u.pos, vel: u.vel, heading: u.heading, grounded: u.grounded, ...(u.seat !== undefined ? { seat: u.seat, owner: u.owner, driver: u.driver, away: u.away, goal: u.goal } : {}), ...(mine ? { mine: true } : {}), ...u.fields, motion: Object.freeze({ ...u.motion }) }) as Entity;
268
+ const entities = new WeakMap<Unpacked, Entity>();
269
+ const entityOf = (u: Unpacked, mine = false, cache = !mine): Entity => {
270
+ if (cache && entities.has(u)) return entities.get(u)!;
271
+ const entity = Object.freeze({ id: u.id, kind: u.kind, pos: u.pos, vel: u.vel, heading: u.heading, grounded: u.grounded, ...(u.seat !== undefined ? { seat: u.seat, owner: u.owner, driver: u.driver, away: u.away, goal: u.goal } : {}), ...(mine ? { mine: true } : {}), ...u.fields, motion: Object.freeze({ ...u.motion }) }) as Entity;
272
+ if (cache) entities.set(u, entity);
273
+ return entity;
274
+ };
241
275
 
242
276
  /* ---------------------------------------------------------------- the own body, on this browser's copy of the clock */
243
277
 
@@ -281,7 +315,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
281
315
  let offsets: { delta: Vec3; at: number; path: Vec3; fade: number; last: number; end?: number }[] = [];
282
316
  const predict = schema.settings.predict;
283
317
  const quantile = (list: number[], q: number): number => [...list].sort((a, b) => a - b)[Math.floor((list.length - 1) * q)] ?? 0;
284
- const targetLead = (): number => 0.5 + Math.max(0, quantile(leads, 0.5) - quantile(leads, 0.05));
318
+ let leadTarget = .5, leadMedian = 0, hostMedian = 0;
319
+ const targetLead = (): number => leadTarget;
285
320
  const copyMine = (m: Mine): Mine => ({ ...m, motion: motionOf(m.kind, m.motion) });
286
321
  const distance = (a: Vec3, b: Vec3): number => Math.hypot(periodicDelta(b.x,a.x,map.wrap?.x),periodicDelta(b.y,a.y,map.wrap?.y),periodicDelta(b.z,a.z,map.wrap?.z));
287
322
  const correction = { count: 0, max: 0, last: 0, catches: 0, snaps: 0, rebases: 0, rebaseReason: '', rebaseError: 0 };
@@ -357,14 +392,14 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
357
392
  // The helper's ping ends at the relay. A browser host adds another network leg in both directions.
358
393
  // Snapshot stamps use the relay clock, so their observed age measures the complete downstream path.
359
394
  const relayRtt = net.stats().rtt ?? 100;
360
- return net.offline ? 0 : Math.max(relayRtt, 2 * quantile(hostAges, 0.5));
395
+ return net.offline ? 0 : Math.max(relayRtt, 2 * hostMedian);
361
396
  }
362
397
  function phaseAt(at: number): number {
363
398
  let p = phases[0];
364
399
  for (let i = phases.length - 1; i >= 0; i--) if (phases[i].at <= at) { p = phases[i]; break; }
365
400
  return p ? p.tick + (at - p.at) / period * p.rate - at / period : 0;
366
401
  }
367
- const medianLead = (): number => leads.length ? quantile(leads.slice(-8), 0.5) + phaseAt(clock()) : 0;
402
+ const medianLead = (): number => leads.length ? leadMedian + phaseAt(clock()) : 0;
368
403
  function steer(now: number): void {
369
404
  if (!base || !leads.length) return;
370
405
  base = { tick: tickAt(now), at: now };
@@ -384,11 +419,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
384
419
  const rtt = authorityRtt(); calibratedRtt = rtt;
385
420
  // Lead samples refer to the clock which stamped them. Do not mix its offset with this clock's jitter.
386
421
  const target = targetLead();
387
- leads.length = 0;
422
+ leads.length = 0; leadTarget = .5; leadMedian = 0;
388
423
  leadAfter = clock() + 2 * rtt + period;
389
424
  base = { tick: continuousTick ?? k + Math.min(tickHz, Math.ceil(rtt / period + target) + sendEvery - 1), at: clock() };
390
425
  stepped = Math.max(k, Math.floor(base.tick) - (continuousTick === null ? 1 : 0));
391
- entries = []; sentHistory = []; controlChanges = []; pending = []; history.clear(); replayHeld = {}; serverHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Map(); pulseNumber = new Map(); heldTrack.clear(); if (reason !== 'lead') dropPredictions();
426
+ entries = []; sentHistory = []; controlChanges = []; pending = []; history.clear(); replayHeld = {}; serverHeld = {}; held = {}; lastSent = ''; leads.length = 0; leadTarget = .5; leadMedian = 0; pressed = new Map(); pulseNumber = new Map(); heldTrack.clear(); if (reason !== 'lead') dropPredictions();
392
427
  speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; headingBlend = null; correction.rebases += 1;
393
428
  const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
394
429
  if (mine) {
@@ -419,22 +454,35 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
419
454
  /** `motion` as this browser's own `move` may change it in place: what a snapshot carries is frozen, so every list, map and struct in it is copied. */
420
455
  const motionOf = (kindName: string, motion: Record<string, unknown>): Record<string, unknown> => thawFields(kindOf.get(kindName)?.motion ?? [], motion);
421
456
  function adopt(u: Unpacked): void {
457
+ presentedEntity = null;
422
458
  mine = { id: u.id, kind: u.kind, r: u.r, pos: u.pos, vel: u.vel, heading: u.heading, grounded: u.grounded, motion: motionOf(u.kind, u.motion), fields: predictors[u.kind] ? thawFields(kindOf.get(u.kind)?.fields ?? [], u.fields) : u.fields };
423
459
  }
424
460
  /** The game's guarded `move` for one tick, counted as the server counts it, with the result rounded as the server rounds it. */
425
461
  const substepKinds = new Set<string>();
462
+ const movementCache = new Map<string, {scene: unknown; value: ReturnType<typeof stepMove>}>();
426
463
  function runMove(kindName: string, body: { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }, input: Readonly<Record<string, unknown>>, t: number): typeof body & {steps?: readonly import('./substeps.ts').StepPose[]} {
427
464
  const fn = moves[kindName];
428
465
  if (!fn || (kindOf.get(kindName)?.control && !body.motion[kindOf.get(kindName)!.control!])) return body;
429
466
  moveTick = t;
430
467
  // Like snapshot unpacking, prepare the bounded geometry outside the handler
431
468
  // quota. Authority debits projection once per tick, not once per mover.
432
- geometryAt();
433
- 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); });
469
+ const scene = geometryAt();
470
+ // Reconciliation often replays the exact same deterministic movement. Cache
471
+ // its full arguments, including tick, self, motion and collision revision.
472
+ // Stateful physics worlds must execute their operations and are excluded.
473
+ const key = map.physics ? '' : JSON.stringify([mine?.id,kindName,t,body.pos,body.vel,body.heading,body.grounded,body.motion,input]);
474
+ const cached = key ? movementCache.get(key) : undefined;
475
+ if (cached?.scene === scene) return {...cached.value,motion:motionOf(kindName,cached.value.motion)};
476
+ let failed = false;
477
+ const out = stepMove(fn, body, input, moveCtx, Math.max(1, Math.floor(schema.settings.budget.tick / 4)), kindOf.get(kindName)?.motion ?? [], dims, (err) => { failed = true; if (!(err instanceof BudgetError)) console.warn('[room] move', err); });
434
478
  if (out.steps?.length) substepKinds.add(kindName);
435
479
  const kind = kindOf.get(kindName), r = kind?.radius ?? 0;
436
480
  const height = kind?.height || 2 * r;
437
481
  out.pos = vec3(boundPosition(out.pos,map,r,height,dims), dims);
482
+ if (key && !failed) {
483
+ movementCache.set(key,{scene,value:{...out,motion:motionOf(kindName,out.motion)}});
484
+ if(movementCache.size>128)movementCache.delete(movementCache.keys().next().value!);
485
+ }
438
486
  return out;
439
487
  }
440
488
  /* ---------------------------------------------------------------- the own actions, predicted (0.46.3) */
@@ -705,8 +753,10 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
705
753
  }
706
754
  if (before && before.e !== f.e) hostAges.length = 0;
707
755
  hostAges.push(Math.max(0, net.now() - s.st)); if (hostAges.length > 40) hostAges.shift();
756
+ hostMedian = quantile(hostAges, .5);
708
757
  if (before && before.e !== f.e) fxQueue.length = 0;
709
758
  const changedEpoch = f.e !== epoch;
759
+ if (changedEpoch) { presented=null; routing=null; route=[]; }
710
760
  epoch = f.e;
711
761
  const seat = net.seat;
712
762
  let me: Unpacked | null = null;
@@ -716,6 +766,15 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
716
766
  const row = f.rows.find((x) => x[0] === seat);
717
767
  if(changedEpoch)applications.clear();
718
768
  if(row?.[6]===471){for(let i=7;i+1<row.length;i+=2)applications.set(row[i],row[i+1]);while(applications.size>128)applications.delete(applications.keys().next().value!);}
769
+ // A return hands the body back at the server's pose with a new revision. It is the same body, still on the
770
+ // screen: keep drawing it from where it is shown unless the game says it was really placed meanwhile.
771
+ const prior = held2.at(-2)?.ents.get(me.id) ?? null;
772
+ if (placed && !changedEpoch && returningUntil > clock() && mine?.id === me.id && mine.r !== me.r && presented?.id === me.id && presented.r === mine.r) {
773
+ let respawn = false;
774
+ if (opts.respawned && prior) { try { respawn = Boolean(opts.respawned(entityOf(prior, true, false), entityOf(me, true, false))); } catch (err) { console.warn('[room] respawned', err); } }
775
+ if (!respawn) { presented.r = me.r; presented.at = clock(); }
776
+ returningUntil = 0;
777
+ }
719
778
  if (placed || !base) {
720
779
  adopt(me); rebase(f.k, changedEpoch ? 'epoch' : 'placement');
721
780
  if (placed) emit('placed', entityOf(me, true));
@@ -830,6 +889,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
830
889
  // our own +/-5% steering inflates the 40-sample jitter window and winds the clock ever further ahead.
831
890
  observedLeadAt = now;
832
891
  leads.push(row[3] / 16 - phaseAt(now - rtt)); if (leads.length > 40) leads.shift();
892
+ leadTarget = .5 + Math.max(0, quantile(leads, .5) - quantile(leads, .05));
893
+ leadMedian = quantile(leads.slice(-8), .5);
833
894
  const med = medianLead(), target = targetLead();
834
895
  // A changing jitter estimate must not discard already stamped input. Slew the
835
896
  // clock; late movement is settled by the server's retained history.
@@ -876,7 +937,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
876
937
  net.on('snapshot', onSnapshot);
877
938
  net.on('role', (e) => { if (e.snap) onSnapshot(e.snap); if (net.offline && !net.rulesHosting) setStatus(net.closedWhy ? 'closed' : 'offline'); });
878
939
  net.on('link', (e) => {
879
- if (e.state === 'online') { base = null; if (status !== 'playing') setStatus('connecting'); } else if (e.state === 'closed') setStatus('closed'); else if (e.state === 'alone' || e.state === 'offline') setStatus('offline');
940
+ if (e.state === 'online') { returningUntil = clock() + 3000; base = null; if (status !== 'playing') setStatus('connecting'); } else if (e.state === 'closed') setStatus('closed'); else if (e.state === 'alone' || e.state === 'offline') setStatus('offline');
880
941
  });
881
942
  net.on('round', (r) => { if (r.results) { results = r.results; if (round) { round = { ...round, results }; emit('round', roundNow()); } } });
882
943
  net.on('event', (e) => {
@@ -923,7 +984,15 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
923
984
  * the two, by how much of the tick has passed, and the copy is thrown away. So the stick answers on the next
924
985
  * drawn frame, not on the next tick.
925
986
  */
926
- function meNow(): Entity | null {
987
+ let routing: ReturnType<typeof correctionPath> | null = null;
988
+ let route: Vec3[] = [];
989
+ let routeDirection = 0;
990
+ let presentedEntity: Entity | null = null;
991
+ let presentationQueued = false;
992
+ let forecast: { mine: Mine; pos: Vec3; motion: Record<string, unknown>; tick: number; key: string; geometry: unknown; next: ReturnType<typeof runMove> } | null = null;
993
+ let presented: { id: string; r: number; pos: Vec3; vel: Vec3; at: number } | null = null;
994
+ function meNow(present = false): Entity | null {
995
+ if (present && presentedEntity) return presentedEntity;
927
996
  if (predicting) { const u = mine && predictionFrame?.ents.get(mine.id); return u ? entityOf(u, true) : null; }
928
997
  if (solo) return solo.me;
929
998
  if (!mine || !latest) return null;
@@ -946,7 +1015,10 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
946
1015
  // A recorded substep path needs the whole tick's forecast instead: include
947
1016
  // the remaining duration of controls held now, then sample its actual steps.
948
1017
  for (const [name, fd] of kind.input) now[name] = (fd.t === 'press' || fd.t === 'pulse') ? (pressed.get(name) ?? (fd.t === 'pulse' && pulseQueue[0]?.name===name ? pulseQueue[0].value : fd.t === 'press' ? false : 0)) : fd.t === 'held' ? coerce(fd, heldMean(name, fd.init, Math.min(clock(), edgeOf(stepped + 1)), false, substepKinds.has(kind.name) ? edgeOf(stepped + 1) : Math.min(clock(), edgeOf(stepped + 1))), dims) : coerce(fd, sample[name] ?? fd.init, dims);
949
- const next = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: motionOf(mine.kind, mine.motion) }, Object.freeze(now), stepped + 1);
1018
+ const forecastKey = JSON.stringify(now), scene = geometryAt();
1019
+ const reuse = !map.physics && substepKinds.has(kind.name) && forecast && forecast.mine === mine && forecast.pos === mine.pos && forecast.motion === mine.motion && forecast.tick === stepped && forecast.key === forecastKey && forecast.geometry === scene;
1020
+ const next = reuse ? forecast!.next : runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: motionOf(mine.kind, mine.motion) }, Object.freeze(now), stepped + 1);
1021
+ forecast = {mine, pos:mine.pos, motion:mine.motion, tick:stepped, key:forecastKey, geometry:scene, next};
950
1022
  // A landing does not take the whole tick (0.46.5): the body met the ground part way through it. How far through is
951
1023
  // read from the fall it would have made at the speed it entered the tick with; it is drawn down to the ground by
952
1024
  // then and standing from then, not sinking until the tick's end.
@@ -995,26 +1067,115 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
995
1067
  // permission to cross a floor. Sweep from a valid movement pose, never feed
996
1068
  // the clipped drawing back into prediction or authority.
997
1069
  const constrain = (from: Vec3, to: Vec3): Vec3 => {
998
- if (dims !== 2 || !kind || kind.owner) return to;
1070
+ if (dims !== 2 || !kind || kind.owner || distance(from, to) < 1e-7) return to;
999
1071
  const body = {pos: from};
1000
1072
  sweepMap(geometryAt(), body, {x:periodicDelta(from.x,to.x,map.wrap?.x),y:periodicDelta(from.y,to.y,map.wrap?.y),z:periodicDelta(from.z,to.z,map.wrap?.z)}, kind.radius, dims,
1001
1073
  {shape:kind.shape ?? 'circle',radius:kind.radius,height:kind.height || 2*kind.radius,terrain:kind.terrain});
1002
1074
  return body.pos;
1003
1075
  };
1004
- pos = constrain(safeOrigin, pos);
1076
+ // An established box cursor is swept below from the actual displayed side.
1077
+ // Sweeping its target from a second origin first duplicates the same query
1078
+ // and does not add safety. New placements and other body types keep it.
1079
+ const hasCursor = present && dims === 2 && kind?.shape === 'box' && !kind.owner && presented?.id === mine.id && presented.r === mine.r;
1080
+ if (!hasCursor) pos = constrain(safeOrigin, pos);
1005
1081
  const offset = visualOffset(clock(), pos, settled);
1006
- pos = constrain(pos, wrapPoint({ x: pos.x + offset.x, y: pos.y + offset.y, z: pos.z + offset.z },map.wrap));
1082
+ const offsetTarget = wrapPoint({ x: pos.x + offset.x, y: pos.y + offset.y, z: pos.z + offset.z },map.wrap);
1083
+ pos = hasCursor ? offsetTarget : constrain(pos, offsetTarget);
1084
+ if (present && dims === 2 && kind?.shape === 'box' && !kind.owner) {
1085
+ const now = clock();
1086
+ if (presented && presented.id === mine.id && presented.r === mine.r) {
1087
+ const from = presented.pos;
1088
+ let forward = velocity.x * presented.vel.x >= 0 ? Math.sign(velocity.x) : 0;
1089
+ if (opts.correctionDirection) {
1090
+ try {
1091
+ const intent = opts.correctionDirection(sample, mine.vel);
1092
+ // An explicit intent must not be overridden by stale replay velocity.
1093
+ // A game can return neutral during genuine braking/reversal inertia.
1094
+ if (Number.isFinite(intent)) forward = Math.sign(intent);
1095
+ }
1096
+ catch (err) { console.warn('[room] correctionDirection', err); }
1097
+ }
1098
+ if ((routing || route.length) && forward !== routeDirection) { routing=null; route=[]; }
1099
+ routeDirection = forward;
1100
+ const routeGoal = routing?.goal ?? route.at(-1);
1101
+ if (routeGoal && distance(routeGoal, pos) > Math.max(2,kind.radius*4)) { routing=null; route=[]; }
1102
+ if (route[0] && forward * periodicDelta(from.x,route[0].x,map.wrap?.x) < -.001) { routing=null; route=[]; }
1103
+ if (route.length) {
1104
+ while(route.length && distance(from,route[0])<.01)route.shift();
1105
+ }
1106
+ const destination = route[0] ?? pos;
1107
+ const elapsed = Math.max(0, now - presented.at);
1108
+ // Bound presentation by time, including expiring offsets and changed catch-up paths.
1109
+ const length = distance(from, destination);
1110
+ // Falling cannot spend a horizontal correction. Corrections add only
1111
+ // blend speed to the actual movement, not a body's declared speed cap.
1112
+ const blendSpeed = kind.radius / Math.max(.1,predict.blendMs/1000);
1113
+ // Include the whole recorded tick's velocities: a takeoff/landing can
1114
+ // change velocity abruptly between samples without being a correction.
1115
+ let verticalSpeed = Math.max(Math.abs(velocity.y),Math.abs(presented.vel.y),Math.abs(mine.vel.y),Math.abs(preview?.vel.y ?? 0));
1116
+ const recorded = preview?.steps;
1117
+ if (recorded) for (let i=0;i<recorded.length;i++) verticalSpeed = Math.max(verticalSpeed,Math.abs(recorded[i].vel.y));
1118
+ // Leave headroom for a substep entered part-way through a render interval.
1119
+ verticalSpeed *= 1.5;
1120
+ const advance = (axis: 'x' | 'y') => {
1121
+ const delta = periodicDelta(from[axis],destination[axis],map.wrap?.[axis]);
1122
+ // A landing can set velocity to zero before the sampled pose reaches
1123
+ // contact. The adjacent movement velocities retain that real travel.
1124
+ const travel = axis === 'y' ? verticalSpeed : Math.max(Math.abs(velocity.x),Math.abs(presented!.vel.x));
1125
+ const allowance = (travel + blendSpeed) * elapsed / 1000;
1126
+ return from[axis]+Math.max(-allowance,Math.min(allowance,delta));
1127
+ };
1128
+ const target = {...wrapPoint({x:advance('x'),y:advance('y'),z:0},map.wrap)};
1129
+ // A route is a correction too: it cannot pull against continuing motion.
1130
+ if (forward * periodicDelta(from.x,target.x,map.wrap?.x) < 0) target.x = from.x;
1131
+ // A stalled frame cannot spend its whole elapsed-time allowance in one
1132
+ // visible jump. Keep backlog travel below 1.4 body widths per drawing;
1133
+ // authority/prediction keep their real clock and placements bypass this.
1134
+ const frameDistance = distance(from,target), frameLimit = Math.max(.1,kind.radius*2.8);
1135
+ if (frameDistance > frameLimit) {
1136
+ const fraction = frameLimit / frameDistance;
1137
+ target.x = from.x + periodicDelta(from.x,target.x,map.wrap?.x)*fraction;
1138
+ target.y = from.y + periodicDelta(from.y,target.y,map.wrap?.y)*fraction;
1139
+ }
1140
+ let next = constrain(from, target);
1141
+ // Slide the residual along the contact, starting on the side actually drawn.
1142
+ if (distance(next, target) > .002) {
1143
+ next = constrain(next, {x:target.x,y:next.y,z:0});
1144
+ next = constrain(next, {x:next.x,y:target.y,z:0});
1145
+ }
1146
+ if (!route.length && distance(next, target) > .01 && distance(from,next) < .01 && length > .1) {
1147
+ routing ??= correctionPath(from,pos,Math.max(.25,2*kind.radius));
1148
+ const found=routing.advance((a,b)=>forward * periodicDelta(a.x,b.x,map.wrap?.x) >= -.001 && distance(constrain(a,b),b)<.002);
1149
+ if(found) { route=[...found]; routing=null; }
1150
+ }
1151
+ pos = next;
1152
+ }
1153
+ if (!presented || presented.id !== mine.id || presented.r !== mine.r) { routing=null;route=[]; }
1154
+ presented = {id:mine.id,r:mine.r,pos,vel:velocity,at:now};
1155
+ }
1007
1156
  if (headingBlend) {
1008
1157
  const t = predict.blendMs > 0 ? Math.min(1, (clock() - headingBlend.at) / predict.blendMs) : 1;
1009
1158
  heading = blendHeading(headingBlend.from, heading, t, dims);
1010
1159
  if (t >= 1) headingBlend = null;
1011
1160
  }
1012
- return entityOf({ ...server, ...(kind && predictors[kind.name] ? { fields: (base ? previewFields : null) ?? mine.fields } : {}), pos, vel: velocity, heading, grounded, motion }, true);
1161
+ const result = entityOf({ ...server, ...(kind && predictors[kind.name] ? { fields: (base ? previewFields : null) ?? mine.fields } : {}), pos, vel: velocity, heading, grounded, motion }, true);
1162
+ // Synchronous readers (HUD, scene, probes) share one presentation. Clear at
1163
+ // the microtask boundary: an rAF clear can run after the next drawing callback,
1164
+ // reusing an old pose and then spending two callbacks' travel at once.
1165
+ // Inputs and snapshots still invalidate immediately.
1166
+ if (present && typeof window !== 'undefined') {
1167
+ presentedEntity = result;
1168
+ if (!presentationQueued) {
1169
+ presentationQueued = true;
1170
+ const clear = () => { presentedEntity = null; presentationQueued = false; };
1171
+ queueMicrotask(clear);
1172
+ }
1173
+ }
1174
+ return result;
1013
1175
  }
1014
- function drawn(id: string): Entity | null {
1176
+ function drawn(id: string, s = net.sample()): Entity | null {
1015
1177
  if (solo) return solo.get(id);
1016
- if (mine && id === mine.id) return meNow();
1017
- const s = net.sample();
1178
+ if (mine && id === mine.id) return meNow(true);
1018
1179
  const a = s ? frameOf(s.a) : latest;
1019
1180
  const b = s ? frameOf(s.b) : latest;
1020
1181
  const ea = a?.ents.get(id); const eb = b?.ents.get(id);
@@ -1022,8 +1183,14 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1022
1183
  // A placed body jumps: nobody's view glides across a placement.
1023
1184
  if (ea.r !== eb.r || a?.e !== b?.e || !s) return entityOf(eb);
1024
1185
  const t = s.alpha;
1186
+ // Static pickups and scenery need no new vectors or wrappers for every HUD,
1187
+ // scene and probe traversal. Fields still follow the sampled snapshot.
1188
+ if (ea.grounded === eb.grounded && ['pos','vel','heading'].every(key => {
1189
+ const x=ea[key as 'pos'], y=eb[key as 'pos']; return x.x===y.x && x.y===y.y && x.z===y.z;
1190
+ })) return entityOf(t < 1 ? ea : eb);
1025
1191
  const h = blendHeading(ea.heading, eb.heading, t, dims);
1026
- return entityOf({ ...(t < 1 ? ea : eb), grounded: t >= 1 ? eb.grounded : ea.grounded && (t === 0 || eb.grounded), pos: vec3(blendPosition(ea.pos, eb.pos, t, map.wrap), dims), vel: vec3({ x: lerp(ea.vel.x, eb.vel.x, t), y: lerp(ea.vel.y, eb.vel.y, t), z: lerp(ea.vel.z, eb.vel.z, t) }, dims), heading: h });
1192
+ // This interpolated key exists for one read only; caching it retains useless weak entries.
1193
+ return entityOf({ ...(t < 1 ? ea : eb), grounded: t >= 1 ? eb.grounded : ea.grounded && (t === 0 || eb.grounded), pos: vec3(blendPosition(ea.pos, eb.pos, t, map.wrap), dims), vel: vec3({ x: lerp(ea.vel.x, eb.vel.x, t), y: lerp(ea.vel.y, eb.vel.y, t), z: lerp(ea.vel.z, eb.vel.z, t) }, dims), heading: h }, false, false);
1027
1194
  }
1028
1195
  function get(id: string): Entity | null { const g = ghosts.get(id); return g ? ghostEntity(g) : drawn(id); }
1029
1196
  function each(kind: string, fn: (e: Entity) => void): void {
@@ -1036,7 +1203,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1036
1203
  // Until the newer snapshot is reached, what the older one holds is still drawn; what only the newer one holds waits for it.
1037
1204
  for (const f of [a, s && s.alpha >= 1 ? b : null, a ? null : b]) if (f) for (const u of f.ents.values()) if (u.kind === kind) ids.add(u.id);
1038
1205
  if (mine && mine.kind === kind) ids.add(mine.id);
1039
- for (const id of ids) { const e = drawn(id); if (e) fn(e); }
1206
+ for (const id of ids) { const e = drawn(id, s); if (e) fn(e); }
1040
1207
  // A confirmed ghost stands in until the server's own entity is drawn (it arrives a moment behind, interpolated).
1041
1208
  for (const g of [...ghosts.values()]) if (g.kind === kind) { if (g.real && ids.has(g.real)) { ghosts.delete(g.id); emit('leave', ghostEntity(g)); } else fn(ghostEntity(g)); }
1042
1209
  }
@@ -1076,25 +1243,25 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1076
1243
  net.line(net.full ? 'This room is full · playing on your own until a seat is free' : 'Playing on your own until the next round');
1077
1244
  emit('status', 'offline', true);
1078
1245
  }
1079
- let extra: Record<string, () => unknown> = {};
1246
+ const extra: Record<string, () => unknown> = {};
1080
1247
  const myScore = (): number | null => { if (solo) { const k = schema.kinds.find(k => k.player); return k?.score && solo.me ? Number(solo.me[k.score]) || 0 : null; } const k = myKind(); const u = mine && latest ? latest.ents.get(mine.id) : null; return k?.score && u ? Number(u.fields[k.score]) || 0 : null; };
1081
1248
  const busy = (): boolean => solo ? !solo.me : status !== 'playing' || !mine || latest?.ents.get(mine.id)?.away === true;
1082
1249
  // The probes the testing tools read (`check`, `shoot`, `perf`, the playtest judge): the same two objects a game written the old way publishes.
1083
1250
  net.expose({
1084
- self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
1251
+ self: () => { const m = meNow(true); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
1085
1252
  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; },
1086
1253
  movement: () => { const kinds = schema.kinds.filter(k => k.player); return kinds.length && kinds.every(k => k.maxSpeed === 0) ? 'stationary' : 'spatial'; },
1087
- 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, applications: [...applications] }),
1254
+ prediction: () => ({ ...correction, observedLeadAt, lastSendAt, now: clock(), serverTick: latest?.k, tick: stepped, catchTick, lead: targetLead(), medianLead: medianLead(), rate: speed, rtt: authorityRtt(), hostAge: hostMedian, pending: pending.length, applications: [...applications] }),
1088
1255
  scores: () => roster().map((r) => ({ seat: r.seat, score: r.score })),
1089
1256
  score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => solo ? 'offline' : status,
1090
1257
  });
1091
1258
  if (typeof window !== 'undefined') {
1092
1259
  exposePort(net as Netplay<unknown, unknown, unknown>, {
1093
1260
  view: 'top', size: schema.kinds.find((k) => k.player)?.radius ?? 0.5,
1094
- self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y } : null; },
1261
+ self: () => { const m = meNow(true); return m ? { x: m.pos.x, y: m.pos.y } : null; },
1095
1262
  basis: opts.screenBasis ?? (() => ({ right: [1, 0], up: [0, 1] })),
1096
1263
  score: myScore, busy: () => (typeof extra.busy === 'function' ? Boolean(extra.busy()) : busy()),
1097
- extra: new Proxy({}, { get: (_t, key) => (typeof key === 'string' ? extra[key] : undefined), ownKeys: () => Object.keys(extra), getOwnPropertyDescriptor: (_t, key) => (typeof key === 'string' && extra[key] ? { enumerable: true, configurable: true, value: extra[key] } : undefined) }) as Record<string, () => unknown>,
1264
+ extra,
1098
1265
  });
1099
1266
  }
1100
1267
  const timer = opts.timers === false ? null : setInterval(pump, Math.max(8, period / 2));
@@ -1143,7 +1310,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1143
1310
  get viewTick() { return solo ? solo.viewTick : viewTickNow(); },
1144
1311
  get status() { return solo ? 'offline' : status; },
1145
1312
  get seat() { return solo ? solo.seat : net.seat; },
1146
- get me() { pump(); return meNow(); },
1313
+ get me() { pump(); return meNow(true); },
1147
1314
  each, get,
1148
1315
  ray: (from,direction,max,options) => solo ? solo.ray(from,direction,max,options) : viewRay(from,direction,max,options)[0],
1149
1316
  rays: options => solo ? solo.rays(options) : raySnapshot(mapAt(latest?.k??0),viewTargets(atTickOf(options)),dims,options,mine?.id),
@@ -1180,6 +1347,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1180
1347
  if (changed) pump();
1181
1348
  const shown = changed ? meNow()?.pos : null;
1182
1349
  for (const [name, fd] of kind?.input ?? []) if ((fd.t === 'press' || fd.t === 'pulse') && coerce(fd,s[name],dims)) { const value=coerce(fd,s[name],dims) as number | boolean; if(fd.t==='pulse'){pulseCount+=1;const target=Math.max(stepped+1,(pulseQueue.at(-1)?.t??stepped)+1);pulseQueue.push({name,value:Number(value),n:pulseCount,t:target});net.send('pulse',[epoch,name,value,target,pulseCount]);} else pressed.set(name,value); }
1350
+ if (changed) presentedEntity = null;
1183
1351
  sample = next;
1184
1352
  // A discrete action belongs to the present, even while an unseen impulse is
1185
1353
  // being drawn from history. Resume the live prediction and let the existing
@@ -1204,7 +1372,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1204
1372
  get round() { return roundNow(); },
1205
1373
  get roster() { return roster(); },
1206
1374
  follow(id) { const e = id ? latest?.ents.get(id) : null; net.follow(e && e.seat !== undefined ? e.seat : null); },
1207
- probe(x) { extra = { ...extra, ...x }; },
1375
+ probe(x) { Object.assign(extra, x); },
1208
1376
  ask(id, askId, args = {}) { if (solo) return; const e = latest?.ents.get(id); if (e && e.seat !== undefined) agents?.ask(e.seat, askId, args); },
1209
1377
  askButtons(id, offer = {}) { if (solo) return []; const e = latest?.ents.get(id); return e && e.seat !== undefined ? agents?.askButtons(e.seat, offer) ?? [] : []; },
1210
1378
  tune, get map(){return solo ? solo.map : mapAt(latest?.k??0);}, net: net as Netplay,
package/shop/SELLING.md CHANGED
@@ -15,7 +15,7 @@ A player can refund items within a window you set; a tip stays given unless you
15
15
  Stripe handles payments, receipts, payment limits, held refunds and disputes. Stripe Tax is optional;
16
16
  Managed Payments is another selectable integration. Your office links Stripe's own pages.
17
17
 
18
- `homie-studio shop connect` opens Stripe's approval page if needed, then your AI syncs your products, prices, payment links and webhook. You never handle a key. The shop sends buyers to Stripe, where they confirm quantity or choose a tip; your Worker holds only the webhook signing secret and link settings. Buy different items separately. Refund in Stripe or ask your connected AI; signed events update your shop. A missing payment stays pending until Stripe resends its event. Free orders need no Stripe.
18
+ `homie-studio shop connect` opens Stripe's approval page if needed, then your AI syncs your products, prices, payment links and webhook. You never handle a key. The shop sends buyers to Stripe, where they confirm quantity or choose a tip; your Worker holds only the webhook signing secret and link settings. Menus let guests choose several items and quantities on Stripe. Paid bookings use a capacity-limited link for each slot: opening is pending, payment secures the seat. Monthly presets charge through recurring Stripe Prices, with a Stripe login link to manage or cancel. Refund in Stripe or ask your connected AI; signed events update your shop. A missing payment stays pending until Stripe resends its event. Free orders need no Stripe.
19
19
 
20
- The fuller connection adds combined carts, spending limits, custom checkout expiry, automatic recovery and refunds in the office. An existing Worker key selects it; your AI explains it only if you ask for those features. The optional manual setup remains available, but is not the default.
20
+ The optional fuller connection adds exact app-built carts, spending reservations, exclusive unpaid holds, custom expiry, automatic recovery and office refunds. It needs explicit session mode as well as an existing Worker key; a key alone never selects it. The optional manual setup remains available, but is not the default.
21
21
  The scaffold has no policy, cap or refund window. Test mode is available before choosing live mode.
package/shop/SHOP.md CHANGED
@@ -108,7 +108,7 @@ If the studio is not deployed yet, approval is retained: deploy with `studio_dep
108
108
  `cloudflare_login` first if needed), then rerun connect. Webhooks require the deployed HTTPS address.
109
109
 
110
110
  **The default is keyless.** Connect syncs one Product and Price for each shop.json item and one
111
- Payment Link for each paid item. Tip Prices use custom amounts; free orders stay local. It creates
111
+ Payment Link for each paid item, adjustable-quantity menus, and one capacity-limited link per published booking slot. Tip Prices use custom amounts; free orders stay local. It creates
112
112
  an account webhook at `<site>/api/shop/hook`, captures its signing secret without displaying it,
113
113
  and installs `STRIPE_WEBHOOK_SECRET` and `STRIPE_SHOP_LINKS` through Wrangler stdin. No Stripe
114
114
  API key goes into the Worker. Repeat connect after editing shop.json: changed prices get new
@@ -132,7 +132,7 @@ items; failed refunds restore the financial booking. A lost dispute revokes good
132
132
 
133
133
  | Need | Keyless behavior / fuller connection |
134
134
  |---|---|
135
- | Several different items in one payment | Buy separately. Stripe supports static multi-item links (up to 20 fixed-price lines), but they are not an arbitrary buyer cart. Dynamic carts use the keyed path. |
135
+ | Several different items in one payment | A synced menu opens several fixed-price lines; guests choose quantities on Stripe, including zero. Stripe limits Payment Links to 20 lines; larger catalogues use separate menus. Custom amounts and subscriptions keep separate links. |
136
136
  | Reserve an optional spending allowance | Requires keyed Checkout; a reusable public link cannot atomically reserve this studio's local cap. No silent weakening of a configured cap. |
137
137
  | Expire an abandoned session | Stripe controls the lifetime; deactivating a link prevents new sessions, not existing sessions. Custom expiry/cancellation requires the keyed path. |
138
138
  | Refund in one tap / player self-refund | Owner uses Stripe Dashboard or asks their Stripe-connected AI. Signed events update books. Office shows guidance instead of a paid-order refund button. Free refunds remain local. |
@@ -142,8 +142,7 @@ items; failed refunds restore the financial booking. A lost dispute revokes good
142
142
  | Managed Payments | Payment Links support managed_payments.enabled; connect uses it when the studio selects Stripe as seller, subject to Stripe eligibility and terms. |
143
143
  | Currency display | Payment Links always enable Stripe Adaptive Pricing. Bookkeeping uses integration currency; presentment details describe what the customer saw. |
144
144
 
145
- An existing `STRIPE_KEY` selects the fuller path automatically. Only if requested, explain:
146
- “The fuller connection adds combined carts, spending limits, automatic recovery and refunds here.”
145
+ A key alone never selects the fuller path. It requires explicit `STRIPE_CHECKOUT_MODE=sessions` as well as a Worker key. Only if requested, explain: “The optional session connection adds exact app-built carts, spending reservations, expiring holds and runtime refunds.”
147
146
  The explicit `shop connect --manual` path remains available, including `--live`, but requires the
148
147
  owner's existing Dashboard key step; it does not satisfy one approval. Never choose it automatically.
149
148
  Running default connect again saves links, removes the API key, and keeps signed-event fulfillment.
@@ -202,7 +201,7 @@ the full amount toward a cap. The additive schema preserves its original rows an
202
201
 
203
202
  ## Shop button on the screen
204
203
 
205
- An open shop with items for this game or app shows **Shop** above its play/open screen. The shell reserves 52 pixels plus the safe area above the content, so the button never covers its controls. In `game.json` or `app.json`, set `"screen": { "shop": false }` to hide this button (merge with any existing screen settings). The room panel and your own shop buttons still work. Closed shops, kids policies and an empty selection show no button.
204
+ An open shop with items for this game or app shows **Shop** above its play/open screen. The shell reserves 52 pixels plus the safe area above the content, so the button never covers its controls. In `game.json` or `app.json`, set `"screen": { "shop": false }` to disable the experience’s shop (merge with any existing screen settings). Keep it true or omitted when your app uses its own `createShop` buttons; false also refuses their checkout bridge requests. Closed shops, kids policies and an empty selection show no button.
206
205
 
207
206
  ```ts
208
207
  import { createShop } from '@homie-rocks/studio/shop';
@@ -218,3 +217,119 @@ These calls open the same shop in games and apps. Inside a player card, purchase
218
217
  Create the booking/ticket/gift intention first. `shop.buy('full-tune', 1, undefined, { reference: booking.id })` or `shop.checkout({ reference: booking.id })` sends the app's own reference through checkout. Existing boolean returns are unchanged: checkout opened is not paid. The verified durable `order.paid` event carries `data.order`, `data.reference` (null if omitted) and `data.app`. Reopened Payment Links retain the reference. A reference is buyer-supplied correlation, not authorization: verify the record, app, amount and currency, deduplicate by event/order ID, then fulfil it. Keep personal data out of references. Before Stripe is connected, local prices and unpaid requests can be demonstrated; actual payment and recurring collection remain unverified. Test events must be labelled simulations.
219
218
 
220
219
  For referrals, use Stripe Connect to split payments or a Stripe-based affiliate service; Homie builds none.
220
+
221
+ ## Keyless menus, paid seats and monthly giving (0.51.0)
222
+
223
+ Use `shop connect` by default. All objects belong to the owner's Stripe account and
224
+ are created through its approved CLI session. Homie has no platform key, OAuth
225
+ backend, payment intermediary or cut. A desktop is needed to sync, not to take money.
226
+
227
+ **A round to table 7:** declare fixed-price drinks in shop.json and run connect.
228
+
229
+ ```ts
230
+ shop.add('negroni', 2);
231
+ shop.add('spritz', 1);
232
+ await shop.checkout({ reference: 'table-7' });
233
+ ```
234
+
235
+ The default opens the synced Stripe menu containing those items. The guest chooses
236
+ final quantities **on Stripe**, starting at one for each menu line; the local cart's
237
+ quantities are not prefilled. Adjustable quantity minimum is zero (Stripe prevents
238
+ removing the last item). Payment Links support at most 20 lines. Keyless sync splits catalogues into menus of ten to fit signed invoice snapshots without authenticated pagination. Split larger menus;
239
+ if selected items span menus, the app receives `menu` and should offer separate
240
+ checkouts. Custom amounts cannot be mixed into this menu; offer a separate tip link.
241
+ Stripe invoice creation is enabled for menus (Stripe's invoice pricing applies).
242
+ Only signed paid invoice lines supply `order.paid.data.lines`, including actual
243
+ item, quantity and amount; no quantity is guessed from the total. Incomplete invoice
244
+ snapshots remain pending for recovery rather than granting a guessed order.
245
+
246
+ The opaque local order ID is `client_reference_id`; its durable reference maps back
247
+ to `table-7` in `order.paid.data.reference` and the buyer's order response. No personal
248
+ data goes in that reference. A return URL is never proof of payment.
249
+
250
+ **Book a tune; the seat is held only when paid:** declare a staff-written `slots`
251
+ collection with integer `capacity` and a server-managed `bookings` collection with
252
+ `"booking":{"slots":"slots","item":"full-tune"}`. Publish slot records, then have
253
+ the owner's AI write the complete manifest to `.studio/booking-slots.json` and run
254
+ `shop connect`, or call local MCP `stripe_bookings_sync` with the same `slots` array:
255
+
256
+ ```json
257
+ [{"app":"tune","collection":"bookings","slots":"slots","slot":"sat-0900","item":"full-tune","capacity":1}]
258
+ ```
259
+
260
+ Sync after publishing or changing slots. Removed or inactive slots deactivate their
261
+ links. Each link has `restrictions.completed_sessions.limit = capacity`, with one
262
+ fixed service and one seat per checkout. Re-sync preserves Stripe's lifetime completed
263
+ count. Free slots stay local and use separate slot records from paid links. Never increment capacity just to hide a full-link error.
264
+ Refunds do not decrement Stripe's completed-session counter; the owner's AI must
265
+ review availability and resync explicitly. A changed price should use a new slot;
266
+ do not replace a sold link and accidentally reset its capacity counter.
267
+
268
+ ```ts
269
+ const result = await shop.book('full-tune', {collection:'bookings',slot:'sat-0900'});
270
+ // result.booking is the tracking record ID.
271
+ ```
272
+
273
+ Opening the link creates local `pending` state, best effort. It does **not** block
274
+ other guests. Confirmed payment creates `confirmed`; expiry/failure/refund releases
275
+ the local record. The app must say “Pay to secure your seat,” not promise an exclusive
276
+ unpaid hold. Slot reads expose `record.booking.full`, `available` and `synced` for the
277
+ UI; Stripe's full-link update and verified confirmations make the app show Full.
278
+ Events include `booking.pending`, `.confirmed`, `.released`, `.moved`; free and
279
+ explicit keyed bookings also use `.held`. Before staff moves, the owner's AI syncs **both affected slots** with `active:false`;
280
+ sync deactivates their links and expires open sessions. Wait for signed payment events,
281
+ then staff makes the versioned `{slot:...}` update. Moving into an unpaused paid target
282
+ is refused. Read fresh confirmed booking counts and sync each affected slot with
283
+ `confirmed:<count>` and `active:true`. Sync adjusts the lifetime completion budget to
284
+ `Stripe completed + capacity - confirmed` (initial publication uses capacity exactly).
285
+ This also reopens refunded availability after an explicit review. Never use a stale
286
+ confirmed count or skip pausing: local staff movement cannot mutate Stripe by itself.
287
+ An explicit keyed session connection retains exclusive expiring unpaid holds.
288
+
289
+ **Give $20 monthly:** `shop init --giving --currency CAD` includes fixed suggested
290
+ monthly gifts. Declare a recurring item, then run connect:
291
+
292
+ ```json
293
+ {"id":"monthly-gift","kind":"donation","name":"Give $20 monthly","price":2000,"interval":"month","gives":[]}
294
+ ```
295
+
296
+ `shop.buy('monthly-gift',1,undefined,{reference:'gift-20'})` opens a recurring Price
297
+ on a Payment Link (subscription mode). The giver chooses among the preset options.
298
+ Arbitrary custom monthly amounts require the explicitly selected keyed connection;
299
+ never silently request it or ask the person to find/paste a key. One-time custom
300
+ amounts remain keyless. `giving.receiptText` is the owner's receipt wording.
301
+
302
+ Signed `subscription.started`, `.renewed`, `.failed`, `.cancelled` events drive
303
+ membership and giving. Membership access ends at the paid invoice line's period end;
304
+ failed invoices cannot extend it and cancellation ends access. Unknown Prices grant
305
+ nothing. Keyless portal configuration supports cancellation, payment-method updates
306
+ and invoice history. `shop connect` enables `login_page.enabled` through the CLI API
307
+ and stores Stripe's no-code login URL; **no Dashboard toggle is required**. Givers
308
+ verify their email with Stripe to manage/cancel. The buyer-owned `/api/shop/portal`
309
+ returns this URL. Plan changes require deliberate portal configuration and fulfilment
310
+ mapping; the optional keyed path retains current-provider reconciliation for upgrades.
311
+
312
+ For offline development, import `startStripeFixture` from the installed toolkit's
313
+ `lib/dev-stripe-fixture.mjs`, start it with `{origin:'http://127.0.0.1:8787'}`, and
314
+ write its `bindings` as local Wrangler `.dev.vars` in a disposable studio. Keep it
315
+ running alongside `homie-studio dev`. The checkout page explicitly says simulation;
316
+ it takes no card and no money. Call `close()` and delete the disposable studio when
317
+ finished. This tests signed webhook plumbing, not Stripe account permissions.
318
+
319
+ The giving starter now reads the actual shop donation options with `shop.catalog()`.
320
+ `shop init --giving --currency CAD` includes $20 monthly; `app new giving --from giving`
321
+ opens Checkout for the selected amount and frequency. A disconnected shop displays
322
+ a disabled preview. No intention record or unverified collected total is created.
323
+ Use the Manage my gifts link for the buyer-owned Stripe portal in the shop.
324
+
325
+ For real sandbox acceptance without copying any API key, import `startKeylessStripeSandbox`
326
+ from `lib/dev-stripe-sandbox.mjs` and pass `{origin, profile, shop, bookingSlots}` with an explicit
327
+ sandbox CLI profile and loopback studio origin. It drives only that profile's
328
+ Stripe CLI, starts `stripe listen --forward-to` with the required events, and
329
+ returns local dev `bindings` and `close()`. Keep bindings private: the webhook
330
+ signing secret is real even in test mode. The keyless helper encodes link configuration for safe .dev.vars transport; do not decode or rewrite that binding. Use the helper’s `links` property for native link IDs when testing. `await helper.sync(manifest)` updates its `links` and `bindings`; save the new bindings and restart dev after a slot sync. `close()` deactivates its sandbox links and stops forwarding. Supply them to the local dev harness
331
+ only, never source control or logs. The local webhook forwarding listener needs the desktop; production uses the signed public webhook directly. No API adapter or key is installed in the keyless Worker. It never exports CLI credentials.
332
+ Use Stripe's published CLI test fixtures/tokens to complete Checkout via API;
333
+ never type card details into a form. Stop on an expired profile and ask the owner
334
+ to approve `stripe login --project-name <profile>` once. Stop the adapter and remove
335
+ dev bindings and disposable studios when finished.