@homie-rocks/studio 0.39.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +11 -0
  3. package/bin/homie-studio.mjs +8 -0
  4. package/functions/FUNCTIONS.md +99 -0
  5. package/functions/functions.ts +13 -0
  6. package/lib/build.mjs +2 -0
  7. package/lib/cloudflare.mjs +9 -9
  8. package/lib/dev.mjs +2 -1
  9. package/lib/functions-build.mjs +36 -0
  10. package/lib/functions-cli.mjs +23 -0
  11. package/lib/port-check.mjs +6 -3
  12. package/lib/purchase-links.mjs +8 -0
  13. package/lib/rules-build.mjs +8 -1
  14. package/lib/rules-check-worker.mjs +3 -3
  15. package/lib/rules-check.mjs +3 -1
  16. package/lib/scaffold.mjs +16 -3
  17. package/lib/shop.mjs +1 -1
  18. package/lib/template-history.json +85 -0
  19. package/lib/tools-build.mjs +2 -0
  20. package/lib/upgrade.mjs +2 -0
  21. package/lib/worker-build.mjs +8 -20
  22. package/lib/worker-config.mjs +28 -0
  23. package/netplay/NETPLAY.md +74 -2
  24. package/package.json +7 -2
  25. package/rules/collision.ts +115 -0
  26. package/rules/core.ts +65 -25
  27. package/rules/host.ts +5 -1
  28. package/rules/math.ts +15 -5
  29. package/rules/rules.ts +33 -10
  30. package/rules/types.ts +2 -2
  31. package/rules/view.ts +126 -32
  32. package/shop/SHOP.md +4 -3
  33. package/starters/ember-vale/game.json +16 -4
  34. package/starters/ember-vale/map/main.json +66 -0
  35. package/starters/ember-vale/src/move.ts +31 -0
  36. package/starters/ember-vale/src/rules.ts +244 -0
  37. package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
  38. package/starters/ember-vale/tunables.json +100 -11
  39. package/starters/gem-rush/src/move.ts +9 -1
  40. package/starters/gem-rush/src/view.ts +2 -1
  41. package/starters/gem-rush-3d/game.json +23 -5
  42. package/starters/gem-rush-3d/map/main.json +153 -0
  43. package/starters/gem-rush-3d/src/move.ts +37 -0
  44. package/starters/gem-rush-3d/src/rules.ts +133 -0
  45. package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
  46. package/starters/gem-rush-3d/tunables.json +98 -11
  47. package/starters/hero-rush-3d/game.json +23 -5
  48. package/starters/hero-rush-3d/map/main.json +153 -0
  49. package/starters/hero-rush-3d/src/move.ts +49 -0
  50. package/starters/hero-rush-3d/src/rules.ts +200 -0
  51. package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
  52. package/starters/hero-rush-3d/tunables.json +260 -30
  53. package/tools/TOOLS.md +42 -2
  54. package/tools/tools.ts +3 -0
  55. package/worker/customer-mcp.mjs +77 -0
  56. package/worker/customer-resources.mjs +53 -0
  57. package/worker/customer-shop.mjs +54 -0
  58. package/worker/extensions.mjs +1 -0
  59. package/worker/functions-schema.mjs +55 -0
  60. package/worker/functions.mjs +93 -0
  61. package/worker/index.mjs +37 -2
  62. package/worker/machine-payments.mjs +1 -1
  63. package/worker/mcp-store.mjs +3 -2
  64. package/worker/mcp-tools.mjs +16 -4
  65. package/worker/mcp.mjs +31 -15
  66. package/worker/office-page.mjs +1 -1
  67. package/worker/players.mjs +5 -2
  68. package/worker/purchase-delivery.mjs +1 -0
  69. package/worker/purchase-hosted-readiness.mjs +0 -1
  70. package/worker/purchase-links.mjs +1 -1
  71. package/worker/purchase-machine.mjs +1 -0
  72. package/worker/purchase-office.mjs +1 -1
  73. package/worker/purchase-owner.mjs +0 -2
  74. package/worker/purchase-reconciliation.mjs +1 -0
  75. package/worker/purchase-routes.mjs +11 -17
  76. package/worker/purchase-store.mjs +1 -0
  77. package/worker/selling/runtime.mjs +4 -1
  78. package/worker/service-run.mjs +25 -0
  79. package/worker/service-terms.mjs +2 -0
  80. package/worker/shop-rules.mjs +9 -8
  81. package/worker/shop.mjs +22 -9
  82. package/worker/tool-webhook.mjs +1 -0
  83. package/worker/version.mjs +1 -1
package/rules/view.ts CHANGED
@@ -46,10 +46,28 @@ import { BudgetError } from './guard.ts';
46
46
  import { moveContext } from './math.ts';
47
47
  import { coerce, dir, stepMove, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.ts';
48
48
  import type { Unpacked } from './pack.ts';
49
+ import { compileMap } from './rules.ts';
49
50
  import type { FieldList, MoveFn, Schema, Vec3 } from './rules.ts';
50
51
 
52
+ /** Unit headings take the shortest arc, including an exact half turn. Linear
53
+ * interpolation passes through zero at a half turn and visibly flips a character. */
54
+ export function blendHeading(a: Vec3, b: Vec3, t: number, dims: 2 | 3): Vec3 {
55
+ if (t <= 0) return a;
56
+ if (t >= 1) return b;
57
+ const dot = Math.max(-1, Math.min(1, a.x * b.x + a.y * b.y + a.z * b.z));
58
+ if (dot > 0.9995) return dir({ x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t), z: lerp(a.z, b.z, t) }, dims);
59
+ if (dot < -0.9995) {
60
+ const side = Math.abs(a.z) < 0.9 ? dir({ x: -a.y, y: a.x, z: 0 }, 3) : dir({ x: a.z, y: 0, z: -a.x }, 3);
61
+ const c = Math.cos(Math.PI * t), s = Math.sin(Math.PI * t);
62
+ return dir({ x: a.x * c + side.x * s, y: a.y * c + side.y * s, z: a.z * c + side.z * s }, dims);
63
+ }
64
+ const angle = Math.acos(dot), scale = Math.sin(angle);
65
+ const from = Math.sin((1 - t) * angle) / scale, to = Math.sin(t * angle) / scale;
66
+ return dir({ x: a.x * from + b.x * to, y: a.y * from + b.y * to, z: a.z * from + b.z * to }, dims);
67
+ }
68
+
51
69
  /** What the build hands the view library for one game: its declarations, its public tunables and its map. No code. */
52
- export interface GameData { vocab?: Vocabulary; id: string; schema: Schema; tune: Record<string, unknown>; map: { name?: string; bounds: { min: unknown; max: unknown }; boxes?: unknown[]; circles?: unknown[]; spots?: Record<string, unknown[]> } }
70
+ export interface GameData { vocab?: Vocabulary; id: string; schema: Schema; tune: Record<string, unknown>; map: { name?: string; bounds: { min: unknown; max: unknown }; boxes?: unknown[]; circles?: unknown[]; spheres?: unknown[]; capsules?: unknown[]; heightTiles?: unknown[]; spots?: Record<string, unknown[]> } }
53
71
  let current: { game: GameData; move: Record<string, MoveFn>; load?: () => Promise<RulesHostFactory> } | null = null;
54
72
  /** Called by the build's own two lines at the top of a view's bundle, before the view's code runs. */
55
73
  export function setGame(game: GameData, move: Record<string, MoveFn> = {}, load?: () => Promise<RulesHostFactory>): void { current = { game, move, load }; }
@@ -67,6 +85,8 @@ export type RoomStatus = 'connecting' | 'playing' | 'offline' | 'closed';
67
85
  export interface RoomRound { n: number; phase: 'live' | 'over'; endsAt: number; secondsLeft: number; results?: RoundInfo['results'] }
68
86
  export interface RosterRow { seat: number; name: string; driver: 'person' | 'bot' | 'ai'; score: number; me: boolean }
69
87
  export interface OpenRoomOptions {
88
+ /** Play a private rules round while an online room has no body for this player. */
89
+ fallback?: 'solo';
70
90
  game?: GameData;
71
91
  move?: Record<string, MoveFn>;
72
92
  net?: NetplayOptions;
@@ -97,7 +117,7 @@ export interface Room<R = unknown> {
97
117
  askButtons(id: string, offer?: Record<string, unknown>): AskButton[];
98
118
  readonly tune: Readonly<Record<string, unknown>>;
99
119
  /** The game's static map: its edges, its solid shapes and its named spots, in metres. */
100
- readonly map: { readonly bounds: { min: Vec3; max: Vec3 }; readonly boxes: readonly { min: Vec3; max: Vec3 }[]; readonly circles: readonly { at: Vec3; r: number }[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> };
120
+ readonly map: { readonly bounds: { min: Vec3; max: Vec3 }; readonly boxes: readonly { min: Vec3; max: Vec3 }[]; readonly circles: readonly { at: Vec3; r: number }[]; readonly heightTiles: readonly import('./rules.ts').MapHeightTile[]; readonly spheres: readonly { at: Vec3; r: number }[]; readonly capsules: readonly { at: Vec3; r: number; height: number }[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> };
101
121
  readonly net: Netplay;
102
122
  /** Run the room's clock up to now (called by itself on a timer and whenever the view reads the room). */
103
123
  pump(): void;
@@ -124,11 +144,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
124
144
  const agents = game.vocab && agentFactory ? agentFactory(net, game.vocab, { roles: ['guide', 'party'], view: () => ({}), manual: true, viewOnly: true }) : null;
125
145
  const kindOf = new Map(schema.kinds.map((k) => [k.name, k]));
126
146
  const listeners = new Map<string, Set<(e: any) => void>>();
127
- const emit = (name: string, e: unknown): void => { for (const fn of listeners.get(name) ?? []) { try { fn(e); } catch (err) { console.warn(`[room] on('${name}')`, err); } } };
128
- const vecOf = (a: unknown): Vec3 => { const p = Array.isArray(a) ? { x: a[0], y: a[1], z: a[2] ?? 0 } : a; return vec3(p, dims); };
129
- const map = { bounds: { min: vecOf(game.map.bounds.min), max: vecOf(game.map.bounds.max) }, boxes: (game.map.boxes ?? []).map((b: any) => ({ min: vecOf(b.min), max: vecOf(b.max) })), circles: (game.map.circles ?? []).map((c: any) => ({ at: vecOf(c.at), r: Number(c.r) })) };
130
- const spots: Record<string, readonly Vec3[]> = {};
131
- for (const [key, list] of Object.entries(game.map.spots ?? {})) spots[key] = Object.freeze(list.map(vecOf));
147
+ let solo: Room<R> | null = null, noBodyMs = 0, checkedAt = clock();
148
+ const soloListeners = new Map<string, () => void>();
149
+ const emit = (name: string, e: unknown, local = false): void => { if (Boolean(solo) !== local) return; for (const fn of listeners.get(name) ?? []) { try { fn(e); } catch (err) { console.warn(`[room] on('${name}')`, err); } } };
150
+ const map = compileMap(game.map, game.map.name ?? 'main');
151
+ const spots = map.spots;
132
152
  const tune = Object.freeze({ ...(lab.rulesTune({ public: game.tune }).public as Record<string, unknown>) });
133
153
 
134
154
  let status: RoomStatus = 'connecting';
@@ -183,6 +203,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
183
203
  let observedLeadAt = 0;
184
204
  let catchTick: number | null = null;
185
205
  let catchAt = 0;
206
+ let headingBlend: { from: Vec3; at: number } | null = null;
186
207
  let offsets: { delta: Vec3; at: number; path: Vec3; fade: number; last: number }[] = [];
187
208
  const predict = schema.settings.predict;
188
209
  const quantile = (list: number[], q: number): number => [...list].sort((a, b) => a - b)[Math.floor((list.length - 1) * q)] ?? 0;
@@ -199,6 +220,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
199
220
  if (path) {
200
221
  // Loss can make even a small correction longer than the distance travelled in
201
222
  // 100 ms. Bound its fade by actual progress, or it pulls steady movement backwards.
223
+ // A forced knock can travel much faster than running. It must not also
224
+ // erase a large offset at that speed and almost double the drawn impulse.
202
225
  // Only one queued fade advances at a time. A stopped body finishes its correction.
203
226
  if (waiting) o.at = now;
204
227
  else {
@@ -224,7 +247,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
224
247
  const tickAt = (now: number): number => (base ? base.tick + (now - base.at) / period * speed : 0);
225
248
  /** The tick `move` is being run for: the step just taken, or the one after it while the own body is drawn between ticks. */
226
249
  let moveTick = 0;
227
- const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, dims });
250
+ const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, shape: () => ({ shape: myKind()?.shape ?? 'sphere', radius: myKind()?.radius ?? 0, height: myKind()?.height ?? 0 }), dims });
228
251
  function authorityRtt(): number {
229
252
  // The helper's ping ends at the relay. A browser host adds another network leg in both directions.
230
253
  // Snapshot stamps use the relay clock, so their observed age measures the complete downstream path.
@@ -257,7 +280,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
257
280
  base = { tick: k + Math.ceil(rtt / period + target) + sendEvery - 1, at: clock() };
258
281
  stepped = Math.floor(base.tick) - 1;
259
282
  entries = []; pending = []; history.clear(); replayHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Set();
260
- speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; correction.rebases += 1;
283
+ speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; headingBlend = null; correction.rebases += 1;
261
284
  const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
262
285
  if (mine) {
263
286
  history.set(stepped, copyMine(mine));
@@ -275,8 +298,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
275
298
  if (!fn) return body;
276
299
  moveTick = t;
277
300
  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); });
278
- const r = kindOf.get(kindName)?.radius ?? 0;
279
- out.pos = vec3({ x: Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, out.pos.x)), y: Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, out.pos.y)), z: out.pos.z }, dims);
301
+ const kind = kindOf.get(kindName), r = kind?.radius ?? 0;
302
+ const height = kind?.height || 2 * r;
303
+ out.pos = vec3({ x: Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, out.pos.x)), y: Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, out.pos.y)), z: dims === 3 ? Math.max(map.bounds.min.z, Math.min(map.bounds.max.z - height, out.pos.z)) : 0 }, dims);
280
304
  return out;
281
305
  }
282
306
  /** The input values the last step held (a press is never held). */
@@ -329,6 +353,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
329
353
  if (closed) return;
330
354
  if (net.offline && !net.rulesHosting) setStatus(net.closedWhy ? 'closed' : 'offline');
331
355
  const now = clock();
356
+ checkSolo(now);
332
357
  const hidden = typeof document !== 'undefined' && document.hidden === true;
333
358
  // Effects play for everyone who draws the room: a watcher has no body and still sees them.
334
359
  fire();
@@ -378,7 +403,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
378
403
  mine!.motion = motionOf(me.kind, me.motion);
379
404
  } else {
380
405
  const previous = mine!.pos;
381
- const shown = meNow()?.pos ?? previous;
406
+ const shownBody = meNow();
407
+ const shown = shownBody?.pos ?? previous;
408
+ const oldHeading = mine!.heading;
382
409
  const oldOffset = visualOffset(clock()), oldOffsets = offsets;
383
410
  adopt(me);
384
411
  history.set(f.k, copyMine(mine!));
@@ -402,6 +429,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
402
429
  history.set(t, copyMine(mine!));
403
430
  }
404
431
  pending = pending.filter(e => e.t > f.k || e.t > ack && f.k + 1 - e.t <= Math.ceil(tickHz / 4));
432
+ if (shownBody && distance(oldHeading, mine!.heading) > 0.0001) headingBlend = { from: shownBody.heading, at: clock() };
405
433
  const error = distance(previous, mine!.pos);
406
434
  correction.last = error;
407
435
  if (error > 0.00001) { correction.count++; correction.max = Math.max(correction.max, error); }
@@ -466,6 +494,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
466
494
  setStatus(seat === null || me ? 'playing' : status);
467
495
  }
468
496
  function roundNow(): RoomRound | null {
497
+ if (solo) return solo.round;
469
498
  if (!round || !latest) return null;
470
499
  // The room's clock now, from the newest snapshot: a view never turns a tick into time itself.
471
500
  const serverTick = latest.k + ((net.connected || net.rulesHosting) ? (clock() - latest.at) / period : 0);
@@ -527,21 +556,26 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
527
556
  * drawn frame, not on the next tick.
528
557
  */
529
558
  function meNow(): Entity | null {
559
+ if (solo) return solo.me;
530
560
  if (!mine || !latest) return null;
531
561
  const server = latest.ents.get(mine.id);
532
562
  if (!server) return null;
533
563
  const kind = kindOf.get(mine.kind);
534
564
  let pos = mine.pos;
535
565
  let heading = mine.heading;
566
+ let velocity = mine.vel, grounded = mine.grounded, motion = mine.motion;
536
567
  let settled = true;
537
- let preview: { pos: Vec3; heading: Vec3 } | null = null;
568
+ let preview: ReturnType<typeof runMove> | null = null;
538
569
  if (kind && base) {
539
570
  const a = Math.max(0, Math.min(1, tickAt(clock()) - stepped));
540
571
  const now: Record<string, unknown> = {};
541
572
  for (const [name, fd] of kind.input) now[name] = fd.t === 'press' ? pressed.has(name) : coerce(fd, sample[name] ?? fd.init, dims);
542
573
  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);
543
574
  pos = vec3({ x: lerp(mine.pos.x, next.pos.x, a), y: lerp(mine.pos.y, next.pos.y, a), z: lerp(mine.pos.z, next.pos.z, a) }, dims);
544
- heading = next.heading;
575
+ heading = blendHeading(mine.heading, next.heading, a, dims);
576
+ velocity = vec3({ x: lerp(mine.vel.x, next.vel.x, a), y: lerp(mine.vel.y, next.vel.y, a), z: lerp(mine.vel.z, next.vel.z, a) }, dims);
577
+ // A takeoff is airborne as soon as it rises; a landing waits for its contact.
578
+ grounded = mine.grounded && (a === 0 || next.grounded);
545
579
  preview = next;
546
580
  settled = distance(next.pos, mine.pos) < 0.00001;
547
581
  }
@@ -551,17 +585,25 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
551
585
  if (catchTick >= tickAt(now)) catchTick = null;
552
586
  else {
553
587
  // Catch-up can reach the fractional current tick before its next whole step exists.
554
- // Use the same preview as normal drawing, rather than hold and jump when that step arrives.
588
+ // Use the normal drawing preview, including its 3D pose, instead of holding then jumping.
555
589
  const t = Math.floor(catchTick), a = history.get(t), b = history.get(t + 1) ?? (t === stepped ? preview : null);
556
- if (a && b) { const f = catchTick - t; pos = { x: lerp(a.pos.x, b.pos.x, f), y: lerp(a.pos.y, b.pos.y, f), z: lerp(a.pos.z, b.pos.z, f) }; heading = b.heading; }
557
- else if (a) pos = a.pos;
590
+ if (a && b) { const f = catchTick - t; pos = { x: lerp(a.pos.x, b.pos.x, f), y: lerp(a.pos.y, b.pos.y, f), z: lerp(a.pos.z, b.pos.z, f) }; heading = blendHeading(a.heading, b.heading, f, dims);
591
+ velocity = vec3({ x: lerp(a.vel.x, b.vel.x, f), y: lerp(a.vel.y, b.vel.y, f), z: lerp(a.vel.z, b.vel.z, f) }, dims);
592
+ grounded = a.grounded && (f === 0 || b.grounded); motion = a.motion;
593
+ } else if (a) { pos = a.pos; heading = a.heading; velocity = a.vel; grounded = a.grounded; motion = a.motion; }
558
594
  }
559
595
  }
560
596
  const offset = visualOffset(clock(), pos, settled);
561
597
  pos = { x: pos.x + offset.x, y: pos.y + offset.y, z: pos.z + offset.z };
562
- return entityOf({ ...server, pos, vel: mine.vel, heading, grounded: mine.grounded, motion: mine.motion }, true);
598
+ if (headingBlend) {
599
+ const t = predict.blendMs > 0 ? Math.min(1, (clock() - headingBlend.at) / predict.blendMs) : 1;
600
+ heading = blendHeading(headingBlend.from, heading, t, dims);
601
+ if (t >= 1) headingBlend = null;
602
+ }
603
+ return entityOf({ ...server, pos, vel: velocity, heading, grounded, motion }, true);
563
604
  }
564
605
  function drawn(id: string): Entity | null {
606
+ if (solo) return solo.get(id);
565
607
  if (mine && id === mine.id) return meNow();
566
608
  const s = net.sample();
567
609
  const a = s ? frameOf(s.a) : latest;
@@ -571,12 +613,13 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
571
613
  // A placed body jumps: nobody's view glides across a placement.
572
614
  if (ea.r !== eb.r || a?.e !== b?.e || !s) return entityOf(eb);
573
615
  const t = s.alpha;
574
- const h = dir({ x: lerp(ea.heading.x, eb.heading.x, t), y: lerp(ea.heading.y, eb.heading.y, t), z: lerp(ea.heading.z, eb.heading.z, t) }, dims);
575
- return entityOf({ ...eb, pos: vec3({ x: lerp(ea.pos.x, eb.pos.x, t), y: lerp(ea.pos.y, eb.pos.y, t), z: lerp(ea.pos.z, eb.pos.z, t) }, dims), vel: eb.vel, heading: h });
616
+ const h = blendHeading(ea.heading, eb.heading, t, dims);
617
+ return entityOf({ ...(t < 1 ? ea : eb), grounded: t >= 1 ? eb.grounded : ea.grounded && (t === 0 || eb.grounded), pos: vec3({ x: lerp(ea.pos.x, eb.pos.x, t), y: lerp(ea.pos.y, eb.pos.y, t), z: lerp(ea.pos.z, eb.pos.z, t) }, 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 });
576
618
  }
577
619
  function get(id: string): Entity | null { return drawn(id); }
578
620
  function each(kind: string, fn: (e: Entity) => void): void {
579
621
  pump();
622
+ if (solo) { solo.each(kind, fn); return; }
580
623
  const s = net.sample();
581
624
  const a = s ? frameOf(s.a) : latest;
582
625
  const b = s ? frameOf(s.b) : latest;
@@ -587,20 +630,51 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
587
630
  for (const id of ids) { const e = drawn(id); if (e) fn(e); }
588
631
  }
589
632
  function roster(): RosterRow[] {
633
+ if (solo) return solo.roster;
590
634
  const scores = new Map<number, number>();
591
635
  if (latest) for (const u of latest.ents.values()) { const k = kindOf.get(u.kind); if (u.seat !== undefined && k?.score) scores.set(u.seat, Number(u.fields[k.score]) || 0); }
592
636
  return (net.slots ?? []).map((sl) => ({ seat: sl.slot, name: sl.name, driver: sl.agent ? 'ai' as const : sl.bot ? 'bot' as const : 'person' as const, score: scores.get(sl.slot) ?? 0, me: sl.seat !== null && sl.seat === net.seat }));
593
637
  }
638
+ function forwardSolo(name: string): void {
639
+ if (solo && !soloListeners.has(name)) soloListeners.set(name, solo.on(name, e => emit(name, e, true)));
640
+ }
641
+ function stopSolo(): void {
642
+ for (const off of soloListeners.values()) off(); soloListeners.clear();
643
+ const old = solo; solo = null; old?.close();
644
+ }
645
+ function checkSolo(now: number): void {
646
+ if (opts.fallback !== 'solo') return;
647
+ const elapsed = Math.min(300, Math.max(0, now - checkedAt)); checkedAt = now;
648
+ if (net.offline || net.watching) { noBodyMs = 0; stopSolo(); return; }
649
+ if (net.link !== 'online') { noBodyMs = 0; return; }
650
+ if (mine && net.seat !== null && latest?.ents.has(mine.id)) {
651
+ noBodyMs = 0;
652
+ if (solo) { stopSolo(); net.line(null); emit('status', status); emit('placed', { id: mine.id }); }
653
+ return;
654
+ }
655
+ if (solo || net.seat === null && !net.full) return;
656
+ noBodyMs += elapsed;
657
+ if (noBodyMs < 4000 || !(schema.settings.offline || schema.settings.host === 'browser')) return;
658
+ // Keep the online socket and its seat queue. The separate offline room emits no wire
659
+ // traffic, writes no room save to the server, and closes as soon as a body arrives.
660
+ const g = globalThis as unknown as Record<string, unknown>;
661
+ const probes = [g.__homieNet, g.__homiePort];
662
+ solo = openRoom<R>({ game, move: moves, now: opts.now, timers: opts.timers, net: { config: null, post: null, linkOverlay: false } });
663
+ [g.__homieNet, g.__homiePort] = probes;
664
+ for (const name of listeners.keys()) forwardSolo(name);
665
+ 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');
666
+ emit('status', 'offline', true);
667
+ }
594
668
  let extra: Record<string, () => unknown> = {};
595
- const myScore = (): number | 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; };
596
- const busy = (): boolean => status !== 'playing' || !mine || latest?.ents.get(mine.id)?.away === true;
669
+ 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; };
670
+ const busy = (): boolean => solo ? !solo.me : status !== 'playing' || !mine || latest?.ents.get(mine.id)?.away === true;
597
671
  // The probes the testing tools read (`check`, `shoot`, `perf`, the playtest judge): the same two objects a game written the old way publishes.
598
672
  net.expose({
599
673
  self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
600
674
  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; },
601
675
  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 }),
602
676
  scores: () => roster().map((r) => ({ seat: r.seat, score: r.score })),
603
- score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => status,
677
+ score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => solo ? 'offline' : status,
604
678
  });
605
679
  if (typeof window !== 'undefined') {
606
680
  exposePort(net as Netplay<unknown, unknown, unknown>, {
@@ -616,27 +690,47 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
616
690
  if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onShow);
617
691
 
618
692
  return {
619
- get status() { return status; },
620
- get seat() { return net.seat; },
693
+ get status() { return solo ? 'offline' : status; },
694
+ get seat() { return solo ? solo.seat : net.seat; },
621
695
  get me() { pump(); return meNow(); },
622
696
  each, get,
623
- on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); return () => { set?.delete(fn); }; },
624
- get shared() { return Object.freeze(unpackFields(schema.shared, net.stateOf('shared'), dims)); },
697
+ on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); forwardSolo(name); return () => { set?.delete(fn); }; },
698
+ get shared() { if (solo) return solo.shared; return Object.freeze(unpackFields(schema.shared, net.stateOf('shared'), dims)); },
625
699
  input(s) {
700
+ if (solo) { solo.input(s); return; }
626
701
  const kind = myKind() ?? schema.kinds.find((k) => k.player) ?? null;
627
702
  const next: Record<string, unknown> = {};
628
- for (const [name, fd] of kind?.input ?? []) { if (fd.t === 'press') { if (s[name] === true || s[name] === 1) pressed.add(name); } else if (s[name] !== undefined) next[name] = s[name]; }
703
+ let changed = false;
704
+ for (const [name, fd] of kind?.input ?? []) {
705
+ if (fd.t === 'press') { if ((s[name] === true || s[name] === 1) && !pressed.has(name)) changed = true; }
706
+ else { if (s[name] !== undefined) next[name] = s[name]; if ((next[name] ?? fd.init) !== (sample[name] ?? fd.init)) changed = true; }
707
+ }
708
+ // A fresh control can replace a preview partway through a tick (especially
709
+ // a jump). Keep the pose at the instant of the press; only subsequent time
710
+ // may advance the new path, rather than drawing elapsed time with new input.
711
+ if (changed) pump();
712
+ const shown = changed ? meNow()?.pos : null;
713
+ for (const [name, fd] of kind?.input ?? []) if (fd.t === 'press' && (s[name] === true || s[name] === 1)) pressed.add(name);
629
714
  sample = next;
715
+ if (shown) {
716
+ const now = clock(), nextShown = meNow()?.pos;
717
+ if (nextShown && distance(shown, nextShown) > 0.00001) {
718
+ const carried = visualOffset(now);
719
+ // Coalesce the carried blend: analogue controls may change every frame.
720
+ offsets = [{ delta: { x: carried.x + shown.x - nextShown.x, y: carried.y + shown.y - nextShown.y, z: carried.z + shown.z - nextShown.z },
721
+ at: now, last: now, fade: 1, path: { x: nextShown.x - carried.x, y: nextShown.y - carried.y, z: nextShown.z - carried.z } }];
722
+ }
723
+ }
630
724
  },
631
- command(name, data = {}) { if (schema.commands[name]) net.send('cmd', [name, data]); else console.warn(`[room] no command "${name}" is declared in shapes.commands`); },
725
+ command(name, data = {}) { if (solo) { solo.command(name, data); return; } if (schema.commands[name]) net.send('cmd', [name, data]); else console.warn(`[room] no command "${name}" is declared in shapes.commands`); },
632
726
  get round() { return roundNow(); },
633
727
  get roster() { return roster(); },
634
728
  follow(id) { const e = id ? latest?.ents.get(id) : null; net.follow(e && e.seat !== undefined ? e.seat : null); },
635
729
  probe(x) { extra = { ...extra, ...x }; },
636
- ask(id, askId, args = {}) { const e = latest?.ents.get(id); if (e && e.seat !== undefined) agents?.ask(e.seat, askId, args); },
637
- askButtons(id, offer = {}) { const e = latest?.ents.get(id); return e && e.seat !== undefined ? agents?.askButtons(e.seat, offer) ?? [] : []; },
730
+ ask(id, askId, args = {}) { if (solo) return; const e = latest?.ents.get(id); if (e && e.seat !== undefined) agents?.ask(e.seat, askId, args); },
731
+ askButtons(id, offer = {}) { if (solo) return []; const e = latest?.ents.get(id); return e && e.seat !== undefined ? agents?.askButtons(e.seat, offer) ?? [] : []; },
638
732
  tune, map: Object.freeze({ ...map, spots: Object.freeze(spots) }), net: net as Netplay,
639
733
  pump,
640
- close() { closed = true; if (timer) clearInterval(timer); if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onShow); agents?.stop(); net.close(); setStatus('closed'); },
734
+ close() { stopSolo(); closed = true; if (timer) clearInterval(timer); if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onShow); agents?.stop(); net.close(); setStatus('closed'); },
641
735
  };
642
736
  }
package/shop/SHOP.md CHANGED
@@ -39,9 +39,10 @@ A kind is a label, not a recurring-billing, wallet, random-reward or fulfillment
39
39
  omitted limits do not apply. `referralNewPlayersOnly: true` limits attribution to new players.
40
40
  - `catalog`: modes (`test`, `live`) populated by `homie-studio shop catalog` for named Stripe Products.
41
41
  Prices always come from your file, even when a Stripe Product exists.
42
- - Flood protection: `purchaseAttemptsPerMinute` (6 per buyer),
43
- `purchaseAttemptsPerAddressPerMinute` (600), `guestBuyersPerAddressPerHour` (600).
44
- These are configurable positive integers, per Worker instance. `PLAYER_LIMIT_DAILY` also applies.
42
+ - Optional flood protection: `purchaseAttemptsPerMinute`,
43
+ `purchaseAttemptsPerAddressPerMinute`, `guestBuyersPerAddressPerHour`.
44
+ Omitted or null means no limit. Explicit positive integers apply per Worker instance.
45
+ An explicitly set `PLAYER_LIMIT_DAILY` also applies to new shop buyers.
45
46
  Referral delivery uses `REFERRAL_STATEMENTS_PER_MINUTE`, `REFERRAL_STATEMENTS_GLOBAL_PER_MINUTE`
46
47
  (both 30), and `REFERRAL_STATEMENT_BYTES` (65536). `requestBytes` overrides the catalog-sized shop request
47
48
  allowance; `SHOP_WEBHOOK_BYTES` (524288) controls the webhook byte allowance.
@@ -2,10 +2,16 @@
2
2
  "id": "ember-vale",
3
3
  "name": "Ember Vale",
4
4
  "blurb": "A hero who lasts for days. Hunt slimes with whoever is here tonight; your level, gold and name follow you to every device. Go hardcore and a death is forever.",
5
- "players": { "min": 1, "max": 8 },
5
+ "players": {
6
+ "min": 1,
7
+ "max": 8
8
+ },
6
9
  "roundSeconds": 90,
7
- "entry": "src/main.ts",
8
- "netplay": { "v": 1, "public": true, "movement": "owner" },
10
+ "entry": "src/view.ts",
11
+ "netplay": {
12
+ "v": 1,
13
+ "public": true
14
+ },
9
15
  "saves": true,
10
16
  "decide": false,
11
17
  "landing": {
@@ -16,6 +22,12 @@
16
22
  "Every night ends with a ranking; your hero keeps what it earned.",
17
23
  "Hardcore: one life. Fall and you join the Hall of the Fallen."
18
24
  ],
19
- "controls": { "phone": "Drag on the left to move, STRIKE on the right.", "computer": "WASD or arrows to move, Space to strike." }
25
+ "controls": {
26
+ "phone": "Drag on the left to move, STRIKE on the right.",
27
+ "computer": "WASD or arrows to move, Space to strike."
28
+ }
29
+ },
30
+ "room": {
31
+ "host": "server"
20
32
  }
21
33
  }
@@ -0,0 +1,66 @@
1
+ {
2
+ "bounds": {
3
+ "min": [
4
+ 0,
5
+ 0
6
+ ],
7
+ "max": [
8
+ 32,
9
+ 20
10
+ ]
11
+ },
12
+ "spots": {
13
+ "camp": [
14
+ [
15
+ 16,
16
+ 10
17
+ ]
18
+ ],
19
+ "king": [
20
+ [
21
+ 16,
22
+ 2.4
23
+ ]
24
+ ],
25
+ "east-woods": [
26
+ [
27
+ 28,
28
+ 10.6
29
+ ]
30
+ ],
31
+ "start": [
32
+ [
33
+ 19.2,
34
+ 10.0
35
+ ],
36
+ [
37
+ 12.8,
38
+ 10.0
39
+ ],
40
+ [
41
+ 16.0,
42
+ 12.4
43
+ ],
44
+ [
45
+ 16.0,
46
+ 7.6
47
+ ],
48
+ [
49
+ 18.26274169979695,
50
+ 11.697056274847714
51
+ ],
52
+ [
53
+ 13.737258300203047,
54
+ 8.302943725152286
55
+ ],
56
+ [
57
+ 13.737258300203049,
58
+ 11.697056274847714
59
+ ],
60
+ [
61
+ 18.26274169979695,
62
+ 8.302943725152286
63
+ ]
64
+ ]
65
+ }
66
+ }
@@ -0,0 +1,31 @@
1
+ import { defineMove } from '@homie-rocks/studio/rules';
2
+
3
+ export const move = defineMove({
4
+ hero(body, input, ctx) {
5
+ const M = ctx.math, m = body.motion;
6
+ if (ctx.tick < m.downUntil) { body.vel = { x: 0, y: 0, z: 0 }; return; }
7
+ const v = M.scale(M.clampLen({ x: input.ax / 127, y: input.ay / 127, z: 0 }, 1), m.speed);
8
+ if (M.len(v) > 0.3) body.heading = M.norm(v);
9
+ if (input.strike && ctx.tick >= m.ready && m.live) {
10
+ m.attackTick = ctx.tick; m.ready = ctx.tick + ctx.ticks(ctx.tune.strikeMs / 1000);
11
+ m.lungeDir = body.heading; m.lungeDone = m.lunges ? 0 : 1;
12
+ }
13
+ let delta = M.scale(v, ctx.dt);
14
+ if (m.lungeDone < 1) {
15
+ const u = M.clamp((ctx.tick - m.attackTick + 1) * ctx.dt / 0.09, 0, 1), ease = 1 - (1 - u) * (1 - u);
16
+ delta = M.add(delta, M.scale(m.lungeDir, (ease - m.lungeDone) * ctx.tune.lunge)); m.lungeDone = ease;
17
+ }
18
+ ctx.map.sweep(body, delta); body.vel = v;
19
+ },
20
+ slime(body, input, ctx) {
21
+ const M = ctx.math, m = body.motion;
22
+ if (m.pushAt > 0) {
23
+ const u = M.clamp((ctx.tick - m.pushAt) * ctx.dt / (ctx.tune.pushMs / 1000), 0, 1);
24
+ const target = M.add(m.pushFrom, M.scale(m.pushDir, ctx.tune.slimePush * (1 - M.pow(1 - u, 3))));
25
+ ctx.map.sweep(body, M.sub(target, body.pos));
26
+ if (u >= 1) m.pushAt = 0;
27
+ body.vel = { x: 0, y: 0, z: 0 }; return;
28
+ }
29
+ body.vel = m.velocity; ctx.map.sweep(body, M.scale(body.vel, ctx.dt));
30
+ },
31
+ });