@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
@@ -2197,6 +2197,91 @@
2197
2197
  }
2198
2198
  }
2199
2199
  }
2200
+ },
2201
+ "0.40.0": {
2202
+ "files": {
2203
+ "CLAUDE.md": "ddf2b2a0376202f6",
2204
+ "README.md": "684b3cca8001673c",
2205
+ "HANDOFF.md": "29337e2ace95b2e7",
2206
+ "games/README.md": "5ad32fe80c61f117",
2207
+ "music/README.md": "a3dc042dadd39df8",
2208
+ "videos/README.md": "16babc3437d5545b",
2209
+ "posts/README.md": "37841aae476bd540",
2210
+ "site/README.md": "732894273a32dc3f",
2211
+ "site/src/worker.mjs": "34444a6ca4b14437"
2212
+ },
2213
+ "sections": {
2214
+ "AGENTS.md": {
2215
+ "(intro)": "214aa46eb3a895d5",
2216
+ "## Layout": "c18362ed28975d81",
2217
+ "## Apps: one screen, roles and parts": "be6f8fa45f93211a",
2218
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
2219
+ "## The site": "0cb8dd9a6f8a0096",
2220
+ "## Making games": "2da7fa74c8884834",
2221
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2222
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2223
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2224
+ "## Servers and AI seats": "98d08705087a33ec",
2225
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2226
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2227
+ "## Rules": "2e6b29afb85dcf7e",
2228
+ "## Telling Homie": "80b46fdd9e578db8",
2229
+ "## Beta": "9d02638207a26e73"
2230
+ }
2231
+ },
2232
+ "named": {
2233
+ "Homie Arcade": {
2234
+ "sections": {
2235
+ "AGENTS.md": {
2236
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
2237
+ }
2238
+ }
2239
+ }
2240
+ }
2241
+ },
2242
+ "0.42.0": {
2243
+ "files": {
2244
+ "CLAUDE.md": "ddf2b2a0376202f6",
2245
+ "README.md": "684b3cca8001673c",
2246
+ "HANDOFF.md": "29337e2ace95b2e7",
2247
+ "games/README.md": "5ad32fe80c61f117",
2248
+ "music/README.md": "a3dc042dadd39df8",
2249
+ "videos/README.md": "16babc3437d5545b",
2250
+ "posts/README.md": "37841aae476bd540",
2251
+ "site/README.md": "732894273a32dc3f",
2252
+ "site/src/worker.mjs": "34444a6ca4b14437"
2253
+ },
2254
+ "sections": {
2255
+ "AGENTS.md": {
2256
+ "(intro)": "214aa46eb3a895d5",
2257
+ "## Layout": "c18362ed28975d81",
2258
+ "## Apps: one screen, roles and parts": [
2259
+ "8596f2b25edda3e2",
2260
+ "be6f8fa45f93211a"
2261
+ ],
2262
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
2263
+ "## The site": "0cb8dd9a6f8a0096",
2264
+ "## Making games": "2da7fa74c8884834",
2265
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2266
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2267
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2268
+ "## Servers and AI seats": "98d08705087a33ec",
2269
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2270
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2271
+ "## Rules": "2e6b29afb85dcf7e",
2272
+ "## Telling Homie": "80b46fdd9e578db8",
2273
+ "## Beta": "9d02638207a26e73"
2274
+ }
2275
+ },
2276
+ "named": {
2277
+ "Homie Arcade": {
2278
+ "sections": {
2279
+ "AGENTS.md": {
2280
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
2281
+ }
2282
+ }
2283
+ }
2284
+ }
2200
2285
  }
2201
2286
  }
2202
2287
  }
@@ -38,6 +38,7 @@ export async function buildTools(root,esbuild) {
38
38
  if(!['owner','signed-in','public'].includes(t.audience)&&!(t.audience&&typeof t.audience.app==='string'&&typeof t.audience.role==='string'))throw new Error(`tool ${t.name} needs an audience`);
39
39
  if(t.inputSchema?.type!=='object')throw new Error(`tool ${t.name} input schema must be an object`);
40
40
  validator.getValidator(t.inputSchema);
41
+ if(t.price && (t.kind==='prompt'||!Number.isSafeInteger(t.price.amount)||t.price.amount<=0||! /^[a-z]{3}$/.test(t.price.currency)))throw new Error(`tool ${t.name}: price needs a positive minor-unit amount and currency`);
41
42
  if(t.kind!==undefined&&!['tool','prompt'].includes(t.kind))throw new Error(`tool ${t.name}: invalid kind`);
42
43
  if(t.webhook&&(!/^[A-Z][A-Z0-9_]{0,63}$/.test(t.webhook.secret)||typeof t.webhook.person!=='string'||!t.webhook.person))throw new Error(`tool ${t.name}: webhook needs a named secret and account`);
43
44
  for(const [name,event] of Object.entries(t.events??{})){
@@ -51,5 +52,6 @@ export async function buildTools(root,esbuild) {
51
52
  let text=readFileSync(worker,'utf8');
52
53
  if(!text.includes('useTools(')){text += "\nimport { useTools } from '@homie-rocks/studio/worker';\nuseTools(async () => (await import('./tools/index.mjs')).default);\n";writeFileSync(worker,text);}
53
54
  }
55
+ writeFileSync(join(dir,'declarations.json'),JSON.stringify({paid:defs.some(t=>t.price)})+'\n');
54
56
  return defs.map(t=>t.name);
55
57
  }
package/lib/upgrade.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {refreshWorkerConfig} from './worker-config.mjs';
1
2
  /**
2
3
  * `homie-studio upgrade [--apply] [--diff]` — an existing studio takes what this version's template adds, and
3
4
  * nothing it made its own is ever overwritten.
@@ -346,6 +347,7 @@ export function upgradeApply(root, plan) {
346
347
  }
347
348
  if (text !== null && (!existsSync(path) || readFileSync(path, 'utf8') !== text)) writeAtomic(path, text);
348
349
  }
350
+ refreshWorkerConfig(root);
349
351
  const pinned = done.some((c) => c.kind === 'pin');
350
352
  return {
351
353
  ...plan, applied: true, done, skipped,
@@ -1,34 +1,22 @@
1
- import {existsSync,mkdirSync,readFileSync,writeFileSync,rmSync} from 'node:fs';
2
- import {join,relative,dirname} from 'node:path';
1
+ import {refreshWorkerConfig} from './worker-config.mjs';
2
+ import {pathToFileURL} from 'node:url';
3
+ import {existsSync,mkdirSync,writeFileSync,rmSync} from 'node:fs';
4
+ import {join,relative} from 'node:path';
3
5
  import {readConfig} from './routes.mjs';
4
6
  import {paidReleases} from './parts-upload.mjs';
5
- import {configPath} from './studio.mjs';
6
7
  /** Preserve dynamic imports as real Worker modules: page startup never evaluates MCP libraries. */
7
8
  export async function buildWorker(root,esbuild) {
8
9
  const source=join(root,'site/src/worker.mjs');if(!existsSync(source))return;
9
10
  const out=join(root,'site/src/runtime');
11
+ refreshWorkerConfig(root);
10
12
  const alias={...(readConfig(root)?.alias??{})};
11
- if(paidReleases(root).length)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
13
+ const toolModule=join(root,'site/src/tools/index.mjs');
14
+ const pricedTools=existsSync(toolModule) && (await import(pathToFileURL(toolModule).href+'?worker='+Date.now())).default.some(tool=>tool.price);
15
+ if(paidReleases(root).length || existsSync(join(root,'shop.json')) || pricedTools)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
12
16
  const result=await esbuild.build({entryPoints:[source],absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent'});
13
17
  rmSync(out,{recursive:true,force:true});
14
18
  mkdirSync(out,{recursive:true});
15
19
  for(const file of result.outputFiles)writeFileSync(file.path,file.contents);
16
- const config=configPath(root);
17
- if(existsSync(config)){
18
- const original=readFileSync(config,'utf8');
19
- const json=JSON.parse(original.replace(/^\s*\/\/.*$/gm,''));
20
- const before=JSON.stringify(json);
21
- json.main=relative(dirname(config),join(out,'worker.js')).split('\\').join('/');
22
- json.no_bundle=true;json.find_additional_modules=true;
23
- json.rules=[...(json.rules??[]).filter(r=>!r.homieMcp),{type:'ESModule',globs:['**/*.js'],fallthrough:true}];
24
- // No duplicate rule on rebuild.
25
- json.rules=json.rules.filter((r,i,all)=>all.findIndex(x=>JSON.stringify(x)===JSON.stringify(r))===i);
26
- json.compatibility_flags=[...new Set([...(json.compatibility_flags??[]),'nodejs_compat'])];
27
- if(JSON.stringify(json)!==before) {
28
- const comments=original.match(/^(?:\s*\/\/[^\n]*\n)*/)?.[0]??'';
29
- writeFileSync(config,comments+JSON.stringify(json,null,2)+'\n');
30
- }
31
- }
32
20
  const sizes=Object.entries(result.metafile.outputs).map(([path,value])=>({path:relative(root,path),bytes:value.bytes,imports:value.imports}));
33
21
  writeFileSync(join(out,'modules.json'),JSON.stringify(sizes,null,2)+'\n');
34
22
  return sizes;
@@ -0,0 +1,28 @@
1
+ import {readFileSync,writeFileSync,existsSync} from 'node:fs';
2
+ import {join} from 'node:path';
3
+ import {configPath,readStudio,layoutOf,listExperiences} from './studio.mjs';
4
+ import {wranglerConfig} from './scaffold.mjs';
5
+ import {paidReleases} from './parts-upload.mjs';
6
+ const parse=t=>JSON.parse(t.replace(/^\s*\/\/.*$/gm,''));
7
+ const read=file=>existsSync(file)?parse(readFileSync(file,'utf8')):{};
8
+ function merge(base,custom) {
9
+ if(Array.isArray(custom))return custom;
10
+ if(custom && typeof custom==='object')return Object.fromEntries([...new Set([...Object.keys(base??{}),...Object.keys(custom)])].sort().map(k=>[k,k in custom?merge(base?.[k],custom[k]):base[k]]));
11
+ return custom;
12
+ }
13
+ /** Generated output is never an input. Studio additions belong in wrangler.custom.json. */
14
+ export function updateWorkerConfig(root,generated) {
15
+ const file=configPath(root),next=merge(parse(generated),read(join(root,'wrangler.custom.json')));
16
+ const text='// Generated by homie-studio. Edit studio.json or wrangler.custom.json, then build.\n'+JSON.stringify(next,null,2)+'\n';
17
+ if(!existsSync(file)||readFileSync(file,'utf8')!==text)writeFileSync(file,text);
18
+ }
19
+ export function refreshWorkerConfig(root,overrides={}) {
20
+ const studio=readStudio(root),cf=studio.cloudflare;
21
+ if(!cf?.worker)return;
22
+ const metadata=read(join(root,'site/src/functions/declarations.json'));
23
+ const functions=Array.isArray(metadata)?metadata:[];
24
+ const tools=read(join(root,'site/src/tools/declarations.json'));
25
+ const ai=cf.ai??listExperiences(root).some(g=>g.decide===true || g.chat!==false && (g.chat?.mode??'text')==='text' && g.chat?.ai!==false);
26
+ const paid=paidReleases(root).length>0||existsSync(join(root,'shop.json'))||tools.paid===true;
27
+ updateWorkerConfig(root,wranglerConfig({worker:cf.worker,name:studio.name,d1:cf.d1,d1Id:cf.d1Id,r2:cf.r2,layout:layoutOf(root),ai,paidParts:paid,partsRateLimit:cf.purchaseRateLimit??null,routes:cf.routes??null,triggers:functions.length?{crons:[...new Set(['* * * * *',...functions.map(f=>f.schedule).filter(Boolean)])].sort()}:null,...overrides}));
28
+ }
@@ -2163,8 +2163,13 @@ Small errors fade as a visual offset with a nominal 100 ms blend. Overlapping sm
2163
2163
  queue their individual fades, so their accumulated offset is not erased all at once. A disagreement
2164
2164
  beyond three ticks of travel plays the corrected path from the snapshot at 1.25 times speed,
2165
2165
  showing the hold and slide of a knock. Offset fades are limited by progress along the drawn
2166
- path and the body's declared maximum movement speed, so a late-input correction cannot erase more travel than the player makes or compound a fast knock's catch-up speed. Catch-up preserves the drawn pose when a snapshot replaces its history, and uses the next movement preview when its next whole-tick history entry is not yet available. When movement
2167
- stops after catch-up, the remaining fade follows the configured blend time, still bounded by that speed. An effect on the body waits for its drawn tick. Placements, epoch changes and clock rebases
2166
+ path and by normal movement speed, so a late-input correction cannot erase more travel than
2167
+ the player makes or amplify a fast knockback. When movement stops after catch-up, the remaining
2168
+ fade uses the configured blend time with the same speed limit; large offsets can take longer.
2169
+ Catch-up preserves the drawn pose when a snapshot replaces its history and uses the next
2170
+ movement preview when its next whole-tick history entry is not yet available.
2171
+ The fade's speed limit has a 1 m/s floor so even a body with zero declared running speed can
2172
+ finish a vertical correction. An effect on the body waits for its drawn tick. Placements, epoch changes and clock rebases
2168
2173
  jump deliberately. Other bodies keep the helper's adaptive interpolation, with one send period
2169
2174
  as the default minimum delay. The `room.predict` settings customize these defaults.
2170
2175
 
@@ -2390,3 +2395,70 @@ A missing offline module leaves the connection helper alive and says the game ne
2390
2395
  connection. A failed offline runtime stops locally while reconnection continues. An online
2391
2396
  runtime failure ends the room. Snapshots never grow above 16,384 bytes, including above
2392
2397
  sixteen seats.
2398
+
2399
+ ### 29.8 Three dimensions, level data and animation
2400
+
2401
+ All five shipped starters are rules games with `room.host: 'server'`. Existing games
2402
+ made with `port` keep their version 1 callbacks and browser hosting. Ember Vale keeps
2403
+ its cloud character saves; the room save holds the shared fight and companion state.
2404
+
2405
+ A rules declaration with `space: { dims: 3 }` uses metres and seconds, with **z up**.
2406
+ `pos` is the body's feet, `vel` has three components, `heading` is a unit direction,
2407
+ and `grounded` says a supporting collision has an upward normal greater than 0.5.
2408
+ The guarded `move` gets these values and declared `motion`, input, public tuning,
2409
+ `ctx.dt`, deterministic maths and `ctx.map.sweep`. The host and prediction run the
2410
+ same function. Gravity, jump impulse, acceleration and facing are game decisions,
2411
+ not a second physics loop in the view. Camera-relative controls are converted to
2412
+ world-space input in the view before calling `room.input`.
2413
+
2414
+ Bodies may be upright capsules (`radius`, full `height`), spheres (`radius`), or
2415
+ axis-aligned boxes (`radius` is horizontal half-width, `height` is full height).
2416
+ Sweeps are continuous: a fast body cannot skip a thin solid. `ctx.map.sweep` moves
2417
+ against the static map and returns its contact normal; a second sweep along that
2418
+ normal's tangent slides. `world.sweep` also considers entity bodies and accepts
2419
+ `ignore` ids. `world.ray`, `near`, `inBox` and areas consider height in a 3D game.
2420
+ A placement is a discontinuity, with a new spawn revision, rather than a sweep.
2421
+
2422
+ The game's declared map directory contains `main.json`. Static data is compiled
2423
+ into both the host and view and included in the state compatibility hash. It is
2424
+ not copied into each room save. Changing geometry makes old room state incompatible,
2425
+ just as changing a field does. Named `spots` provide spawn points and other fixed
2426
+ positions. A map has finite, increasing `bounds.min`/`max` vectors and at most 4,096
2427
+ solid shapes across these lists:
2428
+
2429
+ | List | Declaration | Meaning in 3D |
2430
+ |---|---|---|
2431
+ | `boxes` | `{ min: [x,y,z], max: [x,y,z] }` | Axis-aligned solid |
2432
+ | `circles` | `{ at: [x,y,z], r }` | Vertical column through the map, for arena obstacles |
2433
+ | `spheres` | `{ at: [x,y,z], r }` | Centre and radius |
2434
+ | `capsules` | `{ at: [x,y,z], r, height }` | Feet, radius, full upright height (at least twice the radius) |
2435
+ | `heightTiles` | `{ at: [x,y,z], size: [width,depth], heights: [h00,h10,h01,h11] }` | Relative corner heights, split on the 00–11 diagonal |
2436
+
2437
+ Height tiles support the foot point, with open edges and no underside. They are
2438
+ terrain, not a triangle-mesh collider or a solid platform; use boxes for platforms
2439
+ and ceilings. Every tile, solid and bounded convex-cast iteration is charged to
2440
+ the handler budget. The build check uses the same map and body shape as the host
2441
+ and predictor, measures full-room steps, and restores the complete declared state.
2442
+ The two meadow starters' playable ground is flat; their rolling scenery is visual.
2443
+
2444
+ The view interpolates remote three-axis position and velocity, and takes the shortest
2445
+ arc between unit headings, including half turns. Discrete animation fields stay at
2446
+ the earlier snapshot until their tick. Takeoff becomes airborne as the interpolated
2447
+ pose rises; landing waits for contact. Own-body reconciliation eases all three axes
2448
+ and heading. A renderer reads `room.me`/`room.each`, maps rules `(x,y,z)` to its
2449
+ renderer axes, and feeds those poses and grounded/vertical-speed values to animation.
2450
+ Effects such as strikes and hits still come from the authoritative room.
2451
+
2452
+ Rules can read `world.kids` and `world.levelSet` (an explicitly set party dial).
2453
+ `world.level` is the party's skill dial; `world.guideLevel` is the server's own level
2454
+ for guides. `world.guideSeats` is the frozen list of guide seats, assigned by the
2455
+ host from admitted agents and reserved seats; party companions use `world.level`.
2456
+ These policy values survive saves without changing older saves' defaults.
2457
+
2458
+ A view may opt into `openRoom({ fallback: 'solo' })` when its room allows offline
2459
+ play. After four active seconds without a body in a connected room, it plays a
2460
+ private rules round while the original connection keeps waiting for a seat. The
2461
+ private round imports no room save, match score or simulation state into the online
2462
+ room. When a body is assigned, the private round closes and the view joins the
2463
+ online match. Ember Vale retains its separate cloud hero: experience earned in
2464
+ solo play still belongs to that hero, whose level is sent through the usual command.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.39.0",
3
+ "version": "0.42.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -88,6 +88,10 @@
88
88
  "./tools": {
89
89
  "types": "./tools/tools.ts",
90
90
  "default": "./tools/tools.ts"
91
+ },
92
+ "./functions": {
93
+ "types": "./functions/functions.ts",
94
+ "default": "./functions/functions.ts"
91
95
  }
92
96
  },
93
97
  "files": [
@@ -116,7 +120,8 @@
116
120
  "links/",
117
121
  "apps/",
118
122
  "app-starters/",
119
- "tools/"
123
+ "tools/",
124
+ "functions/"
120
125
  ],
121
126
  "engines": {
122
127
  "node": ">=22"
@@ -0,0 +1,115 @@
1
+ /** Upright 3D collision. Positions are feet; a rounded box is a box plus a sphere.
2
+ * Capsules are a vertical segment plus a sphere. No engine-specific maths or time.
3
+ */
4
+ import { charge } from './guard.ts';
5
+ import type { Vec3, MapBox, MapCircle, MapHeightTile } from './rules.ts';
6
+
7
+ export interface BodyShape { shape: string; radius: number; height: number }
8
+ export interface Solid { min: Vec3; max: Vec3; r: number }
9
+ export interface Hit3 { t: number; nx: number; ny: number; nz: number; id?: string }
10
+ export interface Map3 {
11
+ bounds: MapBox; boxes: readonly MapBox[]; circles: readonly MapCircle[];
12
+ heightTiles?: readonly MapHeightTile[];
13
+ spheres?: readonly MapCircle[];
14
+ capsules?: readonly (MapCircle & { height: number })[];
15
+ }
16
+ const EPS = 1e-7;
17
+ const point = (x: number, y: number, z: number): Vec3 => ({ x, y, z });
18
+ export function solidAt(at: Vec3, body: BodyShape): Solid {
19
+ const r = body.radius, height = body.height || 2 * r;
20
+ if (body.shape === 'box') return { min: point(at.x - r, at.y - r, at.z), max: point(at.x + r, at.y + r, at.z + height), r: 0 };
21
+ return { min: point(at.x, at.y, at.z + r), max: point(at.x, at.y, at.z + (body.shape === 'sphere' ? r : height - r)), r };
22
+ }
23
+ /** Closest vector between the cores of two rounded boxes. At most one core has
24
+ * horizontal extent, except box/box, so this represents all supported pairs exactly.
25
+ */
26
+ function separation(a: Solid, b: Solid, d: Vec3, t: number): Vec3 {
27
+ const gap = (lo: number, hi: number, min: number, max: number): number => lo > max ? lo - max : hi < min ? hi - min : 0;
28
+ return point(gap(a.min.x + d.x * t, a.max.x + d.x * t, b.min.x, b.max.x), gap(a.min.y + d.y * t, a.max.y + d.y * t, b.min.y, b.max.y), gap(a.min.z + d.z * t, a.max.z + d.z * t, b.min.z, b.max.z));
29
+ }
30
+ /** Continuous convex cast: each separating plane gives a lower bound on impact.
31
+ * Every iteration is charged, and a fixed cap bounds grazing contacts as well.
32
+ */
33
+ export function castSolid(a: Solid, d: Vec3, b: Solid): Hit3 | null {
34
+ charge(30);
35
+ let t = 0;
36
+ const r = a.r + b.r;
37
+ let normal = point(0, 0, 0);
38
+ // Sharp boxes need the slab test: their closest vector vanishes at a face.
39
+ if (r === 0) {
40
+ let enter = 0, leave = 1;
41
+ for (const axis of ['x', 'y', 'z'] as const) {
42
+ const lo = b.min[axis] - a.max[axis], hi = b.max[axis] - a.min[axis], v = d[axis];
43
+ if (v === 0) { if (lo >= 0 || hi <= 0) return null; continue; }
44
+ const near = (v > 0 ? lo : hi) / v, far = (v > 0 ? hi : lo) / v;
45
+ if (near >= enter) { enter = near; normal = point(0, 0, 0); normal[axis] = v > 0 ? -1 : 1; }
46
+ leave = Math.min(leave, far);
47
+ if (enter > leave) return null;
48
+ }
49
+ return enter >= 0 && enter <= 1 && normal.x + normal.y + normal.z !== 0 ? { t: enter, nx: normal.x, ny: normal.y, nz: normal.z } : null;
50
+ }
51
+ for (let i = 0; i < 32; i++) {
52
+ charge(24);
53
+ const s = separation(a, b, d, t), length = Math.sqrt(s.x * s.x + s.y * s.y + s.z * s.z);
54
+ const distance = length - r;
55
+ // Let an initially overlapping body escape. Placements are not collision moves.
56
+ if (i === 0 && distance < -EPS) return null;
57
+ if (length > 0) normal = point(s.x / length, s.y / length, s.z / length);
58
+ const approach = -(normal.x * d.x + normal.y * d.y + normal.z * d.z);
59
+ if (approach <= EPS) return null;
60
+ if (distance <= EPS) return { t, nx: normal.x, ny: normal.y, nz: normal.z };
61
+ t += distance / approach;
62
+ if (t > 1) return null;
63
+ }
64
+ // At the iteration cap, stop conservatively on the separating plane.
65
+ return { t, nx: normal.x, ny: normal.y, nz: normal.z };
66
+ }
67
+ /** Height tiles support a body's foot point. Each tile is split along its
68
+ * 00–11 diagonal; unlike bilinear sampling each half has a constant slope and
69
+ * the sweep has an exact intersection. Borders are open, so a body can walk off.
70
+ */
71
+ function castHeightTile(tile: MapHeightTile, p: Vec3, d: Vec3): Hit3 | null {
72
+ charge(80);
73
+ const [h00, h10, h01, h11] = tile.heights, sx = tile.size.x, sy = tile.size.y;
74
+ const x = p.x - tile.at.x, y = p.y - tile.at.y;
75
+ let best: Hit3 | null = null;
76
+ for (const half of [0, 1]) {
77
+ const gx = half === 0 ? (h10 - h00) / sx : (h11 - h01) / sx;
78
+ const gy = half === 0 ? (h11 - h10) / sy : (h01 - h00) / sy;
79
+ const gap = p.z - tile.at.z - h00 - gx * x - gy * y;
80
+ const closing = gx * d.x + gy * d.y - d.z;
81
+ if (gap < -EPS || closing <= EPS) continue;
82
+ const t = Math.max(0, gap / closing);
83
+ if (t > 1 || best && t >= best.t) continue;
84
+ const u = (x + d.x * t) / sx, v = (y + d.y * t) / sy;
85
+ if (u < -EPS || u > 1 + EPS || v < -EPS || v > 1 + EPS || (half === 0 ? v > u + EPS : u > v + EPS)) continue;
86
+ const length = Math.sqrt(gx * gx + gy * gy + 1);
87
+ best = { t, nx: -gx / length, ny: -gy / length, nz: 1 / length };
88
+ }
89
+ return best;
90
+ }
91
+
92
+ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | null {
93
+ charge(24);
94
+ const a = solidAt(p, body), r = body.radius, height = body.height || r * 2;
95
+ let best: Hit3 | null = null;
96
+ const take = (h: Hit3 | null): void => { if (h && h.t >= 0 && h.t <= 1 && (!best || h.t < best.t)) best = h; };
97
+ for (const axis of ['x', 'y', 'z'] as const) {
98
+ const low = map.bounds.min[axis] + (axis === 'z' ? 0 : r);
99
+ const high = map.bounds.max[axis] - (axis === 'z' ? height : r);
100
+ if (d[axis] === 0) continue;
101
+ const n = point(0, 0, 0); n[axis] = d[axis] > 0 ? -1 : 1;
102
+ take({ t: ((d[axis] > 0 ? high : low) - p[axis]) / d[axis], nx: n.x, ny: n.y, nz: n.z });
103
+ }
104
+ for (const tile of map.heightTiles ?? []) take(castHeightTile(tile, p, d));
105
+ for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }));
106
+ // Legacy map circles are vertical columns in a 3D map.
107
+ for (const c of map.circles) take(castSolid(a, d, { min: point(c.at.x, c.at.y, map.bounds.min.z - r), max: point(c.at.x, c.at.y, map.bounds.max.z + r), r: c.r }));
108
+ for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }));
109
+ for (const c of map.capsules ?? []) take(castSolid(a, d, solidAt(c.at, { shape: 'capsule', radius: c.r, height: c.height })));
110
+ return best;
111
+ }
112
+ export function restsOnMap(map: Map3, p: Vec3, body: BodyShape): boolean {
113
+ const h = castMap3(map, p, point(0, 0, -0.002), body);
114
+ return Boolean(h && h.nz > 0.5);
115
+ }