@homie-rocks/studio 0.54.0 → 0.56.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 (104) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +2 -0
  3. package/bin/homie-studio.mjs +1 -1
  4. package/docs/custom-routes.md +72 -0
  5. package/guides/app/SKILL.md +7 -0
  6. package/guides/playtest/scripts/playtest.mjs +7 -5
  7. package/lib/background-queue.mjs +31 -0
  8. package/lib/build.mjs +4 -1
  9. package/lib/check.mjs +8 -1
  10. package/lib/cloudflare.mjs +65 -23
  11. package/lib/dev.mjs +6 -8
  12. package/lib/local-database-start.mjs +6 -0
  13. package/lib/mcp-tools.mjs +3 -3
  14. package/lib/port-check.mjs +3 -2
  15. package/lib/read-replication.mjs +26 -0
  16. package/lib/scaffold.mjs +30 -10
  17. package/lib/shop.mjs +7 -0
  18. package/lib/static-assets.mjs +27 -0
  19. package/lib/template-history.json +87 -0
  20. package/lib/worker-build.mjs +16 -4
  21. package/lib/worker-config.mjs +10 -1
  22. package/netplay/NETPLAY.md +5 -1
  23. package/netplay/netplay.ts +24 -9
  24. package/package.json +1 -1
  25. package/rules/core.ts +9 -0
  26. package/rules/host.ts +40 -11
  27. package/rules/view.ts +1 -0
  28. package/tools/TOOLS.md +6 -1
  29. package/worker/analytics.mjs +84 -0
  30. package/worker/app-frame.mjs +1 -1
  31. package/worker/app-graphics.mjs +1 -1
  32. package/worker/app-streams.mjs +3 -3
  33. package/worker/asset-paths.mjs +2 -0
  34. package/worker/background-queue.mjs +31 -0
  35. package/worker/build-id.mjs +1 -1
  36. package/worker/built-metadata.mjs +2 -0
  37. package/worker/catalogue.mjs +18 -0
  38. package/worker/chat-page.mjs +1 -1
  39. package/worker/custom-routes.mjs +16 -0
  40. package/worker/embed.mjs +1 -1
  41. package/worker/event-resources.mjs +23 -11
  42. package/worker/function-registry.mjs +4 -0
  43. package/worker/functions.mjs +7 -5
  44. package/worker/gate-routing.mjs +61 -0
  45. package/worker/gate.mjs +106 -91
  46. package/worker/hosted-registry.mjs +11 -0
  47. package/worker/hosted.mjs +3 -17
  48. package/worker/idle-chat.mjs +56 -0
  49. package/worker/index.mjs +56 -2785
  50. package/worker/lazy-objects.mjs +43 -0
  51. package/worker/lobby-limits.mjs +2 -0
  52. package/worker/lobby-peak.mjs +44 -0
  53. package/worker/lobby-shards.mjs +43 -0
  54. package/worker/lobby-store.mjs +35 -0
  55. package/worker/lobby-summary.mjs +28 -0
  56. package/worker/lobby.mjs +319 -0
  57. package/worker/lounge-store.mjs +2 -2
  58. package/worker/lounge.mjs +3 -3
  59. package/worker/match-router.mjs +74 -0
  60. package/worker/mcp-event-streams.mjs +3 -3
  61. package/worker/mcp-events.mjs +4 -4
  62. package/worker/mcp-room.mjs +2 -1
  63. package/worker/mcp-store.mjs +12 -1
  64. package/worker/mcp-tools.mjs +4 -2
  65. package/worker/mcp.mjs +5 -1
  66. package/worker/office.mjs +18 -21
  67. package/worker/operational-schema.mjs +16 -0
  68. package/worker/page-policy.mjs +29 -0
  69. package/worker/pages.mjs +9 -29
  70. package/worker/parts.mjs +2 -1
  71. package/worker/payment-recovery.mjs +33 -19
  72. package/worker/player-registry.mjs +9 -0
  73. package/worker/players.mjs +8 -7
  74. package/worker/purchase-machine.mjs +5 -3
  75. package/worker/reconcile-guard.mjs +22 -0
  76. package/worker/relay-channel.mjs +119 -0
  77. package/worker/request-db.mjs +36 -10
  78. package/worker/responses.mjs +18 -0
  79. package/worker/room-chat.mjs +217 -0
  80. package/worker/room-controls.mjs +296 -0
  81. package/worker/room-lifecycle.mjs +10 -0
  82. package/worker/room-protocol.mjs +197 -0
  83. package/worker/room-sessions.mjs +65 -0
  84. package/worker/room.mjs +54 -740
  85. package/worker/rooms-cache.mjs +19 -0
  86. package/worker/route-auth.mjs +22 -0
  87. package/worker/routes.mjs +670 -0
  88. package/worker/runtime-support.mjs +447 -0
  89. package/worker/seats.mjs +1 -1
  90. package/worker/selling/index.mjs +1 -0
  91. package/worker/selling/runtime.mjs +7 -5
  92. package/worker/service-run.mjs +1 -1
  93. package/worker/session-codec.mjs +24 -0
  94. package/worker/shop-links.mjs +1 -1
  95. package/worker/shop-page.mjs +1 -0
  96. package/worker/shop.mjs +27 -20
  97. package/worker/stats.mjs +9 -7
  98. package/worker/stream-objects.mjs +18 -0
  99. package/worker/stripe-intake.mjs +79 -0
  100. package/worker/stripe.mjs +3 -0
  101. package/worker/table.mjs +1448 -0
  102. package/worker/tool-registry.mjs +10 -0
  103. package/worker/version.mjs +1 -1
  104. package/worker/watch-sockets.mjs +75 -0
package/lib/scaffold.mjs CHANGED
@@ -1,4 +1,7 @@
1
- import {TOOL_NAMESPACE_MIGRATION,TOOL_NAMESPACE_MIGRATION_FILE,CACHE_MIGRATION,CACHE_MIGRATION_FILE,OPERATIONAL_MIGRATION,OPERATIONAL_MIGRATION_FILE} from '../worker/operational-schema.mjs';
1
+ import {ANALYTICS_MIGRATION,ANALYTICS_MIGRATION_FILE} from '../worker/analytics.mjs';
2
+ import {backgroundQueues} from './background-queue.mjs';
3
+ import {WORKER_ROUTES} from './static-assets.mjs';
4
+ import {EXTRA_RUNTIME_MIGRATIONS,RUNTIME_INDEX_MIGRATION,RUNTIME_INDEX_MIGRATION_FILE,TOOL_NAMESPACE_MIGRATION,TOOL_NAMESPACE_MIGRATION_FILE,CACHE_MIGRATION,CACHE_MIGRATION_FILE,OPERATIONAL_MIGRATION,OPERATIONAL_MIGRATION_FILE} from '../worker/operational-schema.mjs';
2
5
  import {BOOKING_RESOURCES_MIGRATION,BOOKING_RESOURCES_MIGRATION_FILE} from '../worker/booking-resources.mjs';
3
6
  import {BOOKING_HOLDS_MIGRATION,BOOKING_HOLDS_MIGRATION_FILE} from '../worker/booking-holds.mjs';
4
7
  import {BUSINESS_EVENTS_MIGRATION,BUSINESS_EVENTS_MIGRATION_FILE} from '../worker/business-events.mjs';
@@ -76,10 +79,10 @@ export function slugify(name) {
76
79
  * No `database_id` until one is known: Wrangler (4.45.0+) and the Deploy to Cloudflare flow create the database
77
80
  * the binding names and keep it linked.
78
81
  */
79
- export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', main = null, ai = false, routes = null, paidParts = false, partsRateLimit = null, triggers = null, eventFeeds = null }) {
82
+ export function wranglerConfig({analytics = false, workerRoutes = WORKER_ROUTES, worker, name, d1, d1Id = null, r2 = null, layout = 'root', main = null, ai = false, routes = null, paidParts = false, partsRateLimit = null, triggers = null, eventFeeds = null }) {
80
83
  const at = layout === 'site' ? { schema: '../node_modules', main: 'src/runtime/worker.js', dist: './dist', migrations: 'migrations' }
81
84
  : { schema: 'node_modules', main: 'site/src/runtime/worker.js', dist: './site/dist', migrations: 'site/migrations' };
82
- const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }, { name: 'GATE', class_name: 'Gate' }, { name: 'CONCENTRATOR', class_name: 'Concentrator' }];
85
+ const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }, { name: 'GATE', class_name: 'Gate' }, { name: 'CONCENTRATOR', class_name: 'Concentrator' }, {name:'APP_RECORDS',class_name:'AppRecordHub'}, {name:'EVENTS',class_name:'EventHub'}];
83
86
  const config = {
84
87
  $schema: `${at.schema}/wrangler/config-schema.json`,
85
88
  name: worker,
@@ -92,25 +95,27 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
92
95
  // later). A game's rules run in this Worker (NETPLAY.md section 29), and nothing in them is ever made from text.
93
96
  no_bundle: true, find_additional_modules: true, rules: [{ type: 'ESModule', globs: ['**/*.js'], fallthrough: true }],
94
97
  compatibility_flags: ['nodejs_compat', 'global_fetch_strictly_public', 'disallow_eval_during_startup'],
95
- ...(paidParts ? { alias: { '@homie-rocks/studio/worker': '@homie-rocks/studio/worker/selling' }, triggers: { crons: ['*/5 * * * *'] }, ...(partsRateLimit ? { ratelimits: [partsRateLimit] } : {}) } : {}),
98
+ ...(paidParts ? { alias: { '@homie-rocks/studio/worker': '@homie-rocks/studio/worker/selling' }, ...(partsRateLimit ? { ratelimits: [partsRateLimit] } : {}) } : {}),
96
99
  workers_dev: true,
97
100
  preview_urls: true,
98
101
  // The studio's own custom-domain and exact-host routes, as its owner wrote them: this file is written again by
99
102
  // every deploy, and one written without them sends the studio's domain back to whatever else the zone routes
100
103
  // (lib/routes.mjs). A studio without routes gets no key at all.
101
104
  ...(routes?.length ? { routes } : {}),
102
- ...(triggers ? { triggers: paidParts ? { ...triggers, crons: [...new Set([...(triggers.crons ?? []), '*/5 * * * *'])] } : triggers } : {}),
103
- assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: true },
105
+ triggers: { crons: [...new Set([...(triggers?.crons ?? []), ...(paidParts ? ['*/5 * * * *','17 * * * *'] : []), ...(analytics ? ['17 * * * *'] : [])])] },
106
+ assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: workerRoutes },
107
+ ...(analytics ? {analytics_engine_datasets:[{binding:'STUDIO_ANALYTICS',dataset:`${worker.replace(/-/g,'_')}_visits`}],analytics:{binding:'ANALYTICS_SQL'}} : {}),
108
+ queues:{producers:[{binding:'BACKGROUND',queue:backgroundQueues(worker).work},{binding:'BACKGROUND_DLQ',queue:backgroundQueues(worker).dead}],consumers:[{queue:backgroundQueues(worker).work,max_batch_size:100,max_batch_timeout:1,max_retries:5,dead_letter_queue:backgroundQueues(worker).dead}]},
104
109
  durable_objects: { bindings: rooms },
105
- migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }, { tag: 'v2-gates', new_sqlite_classes: ['Gate', 'Concentrator'] }],
110
+ migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }, { tag: 'v2-gates', new_sqlite_classes: ['Gate', 'Concentrator'] }, {tag:'v3-streams',new_sqlite_classes:['AppRecordHub','EventHub']}],
106
111
  d1_databases: [{ binding: 'DB', database_name: d1, ...(d1Id ? { database_id: d1Id } : {}), migrations_dir: at.migrations }],
107
112
  ...(r2 ? { r2_buckets: [{ binding: 'MEDIA', bucket_name: r2 }, ...(paidParts ? [{ binding: 'PURCHASE_MEDIA', bucket_name: `${r2.slice(0, 52)}-purchases` }] : [])] } : {}),
108
113
  // Workers AI, only when a server's AI guides think with it (agents_brain workers-ai; deploy adds it, 0.17.0).
109
114
  // Never in Previews: they do not inherit it, so a Preview's guides answer from the game's script.
110
115
  ...(ai ? { ai: { binding: 'AI' } } : {}),
111
- vars: { STUDIO_NAME: name, ...(eventFeeds&&Object.keys(eventFeeds).length?{STUDIO_EVENT_FEEDS:JSON.stringify(eventFeeds)}:{}) },
116
+ vars: { STUDIO_NAME: name, ...(analytics ? {STUDIO_ANALYTICS_DATASET: `${worker.replace(/-/g,'_')}_visits`} : {}), ...(eventFeeds&&Object.keys(eventFeeds).length?{STUDIO_EVENT_FEEDS:JSON.stringify(eventFeeds)}:{}) },
112
117
  observability: { enabled: true },
113
- previews: { vars: { STUDIO_NAME: name, HOMIE_PREVIEW: '1' }, durable_objects: { bindings: rooms } },
118
+ previews: { vars: { STUDIO_NAME: name, ...(analytics ? {STUDIO_ANALYTICS_DATASET: `${worker.replace(/-/g,'_')}_visits`} : {}), HOMIE_PREVIEW: '1' }, durable_objects: { bindings: rooms } },
114
119
  };
115
120
  return `// This studio's site on its own Cloudflare account (written by homie-studio). Everything here runs on
116
121
  // the studio's Cloudflare Workers account. \`npm run deploy\` (on this computer, or in Workers Builds) puts it live;
@@ -147,6 +152,8 @@ the Table/Lobby Durable Objects, on the studio's Cloudflare account), built
147
152
  from \`@homie-rocks/studio\`, pinned in \`package.json\`. The homie.rocks
148
153
  directory lists its games; homie.rocks does not host them.
149
154
 
155
+ For studio-owned HTTP endpoints, read \`node_modules/@homie-rocks/studio/docs/custom-routes.md\` before editing \`site/src/worker.mjs\`. Use the reserved \`/api/custom/*\` namespace and the documented authorization and cookie scopes.
156
+
150
157
  ## Version-matched authoring guides
151
158
 
152
159
  Before authoring or rewriting a game, read \`node_modules/@homie-rocks/studio/guides/game/SKILL.md\`,
@@ -833,6 +840,9 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
833
840
  [`site/migrations/${OPERATIONAL_MIGRATION_FILE}`]: OPERATIONAL_MIGRATION,
834
841
  [`site/migrations/${CACHE_MIGRATION_FILE}`]: CACHE_MIGRATION,
835
842
  [`site/migrations/${TOOL_NAMESPACE_MIGRATION_FILE}`]: TOOL_NAMESPACE_MIGRATION,
843
+ [`site/migrations/${ANALYTICS_MIGRATION_FILE}`]: ANALYTICS_MIGRATION,
844
+ [`site/migrations/${RUNTIME_INDEX_MIGRATION_FILE}`]: RUNTIME_INDEX_MIGRATION,
845
+ ...Object.fromEntries(Object.entries(EXTRA_RUNTIME_MIGRATIONS).sort(([a],[b])=>a.localeCompare(b)).map(([name,sql])=>[`site/migrations/${name}`,sql])),
836
846
  [`site/migrations/${BOOKING_HOLDS_MIGRATION_FILE}`]: BOOKING_HOLDS_MIGRATION,
837
847
  [`site/migrations/${APP_LIVE_MIGRATION_FILE}`]: APP_LIVE_MIGRATION,
838
848
  [`site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`]: TOOL_DATABASE_MIGRATION,
@@ -1095,8 +1105,18 @@ function ensureOperational(root) {
1095
1105
  if(existsSync(file))return null;
1096
1106
  mkdirSync(dirname(file),{recursive:true});writeFileSync(file,OPERATIONAL_MIGRATION);return `site/migrations/${OPERATIONAL_MIGRATION_FILE}`;
1097
1107
  }
1108
+ function ensureRuntimeIndexes(root) {
1109
+ const file=join(root,'site','migrations',RUNTIME_INDEX_MIGRATION_FILE);
1110
+ if(existsSync(file))return null;
1111
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,RUNTIME_INDEX_MIGRATION);return `site/migrations/${RUNTIME_INDEX_MIGRATION_FILE}`;
1112
+ }
1113
+ function ensureAnalytics(root) {
1114
+ const file=join(root,'site','migrations',ANALYTICS_MIGRATION_FILE);
1115
+ if(existsSync(file))return null;
1116
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,ANALYTICS_MIGRATION);return `site/migrations/${ANALYTICS_MIGRATION_FILE}`;
1117
+ }
1098
1118
  export function ensureMigrations(root) {
1099
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root), ensureAppLive(root), ensureBusinessEvents(root), ensureBookingHolds(root),ensureBookingResources(root),ensureOperational(root),ensureCache(root),ensureToolNamespaces(root)].filter(Boolean);
1119
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root), ensureAppLive(root), ensureBusinessEvents(root), ensureBookingHolds(root),ensureBookingResources(root),ensureOperational(root),ensureCache(root),ensureToolNamespaces(root),ensureRuntimeIndexes(root),...Object.entries(EXTRA_RUNTIME_MIGRATIONS).sort(([a],[b])=>a.localeCompare(b)).map(([name,sql])=>{const file=join(root,'site','migrations',name);if(existsSync(file))return null;mkdirSync(dirname(file),{recursive:true});writeFileSync(file,sql);return `site/migrations/${name}`;}),ensureAnalytics(root)].filter(Boolean);
1100
1120
  }
1101
1121
 
1102
1122
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
package/lib/shop.mjs CHANGED
@@ -50,6 +50,8 @@ export const HOOK_EVENTS = Object.freeze([
50
50
  ]);
51
51
  /** The restricted key's permissions, as Stripe's key page names them. Nothing else. */
52
52
  export const KEY_PERMISSIONS = Object.freeze([
53
+ ['Products', 'Read', 'read expanded products on checkout and subscription lines'],
54
+ ['Prices', 'Read', 'read prices on checkout and subscription lines'],
53
55
  ['Checkout Sessions', 'Write', 'open Stripe\'s checkout page for one item'],
54
56
  ['Charges', 'Write', 'refunds (Stripe keeps refunds under Charges)'],
55
57
  ['PaymentIntents', 'Write', 'verify, create and cancel machine payments as well as read purchase receipts'],
@@ -70,6 +72,11 @@ export const SHOP_HOOK_EVENTS = Object.freeze([
70
72
  ]);
71
73
  /** The restricted key's permissions, as Stripe's key page names them. Nothing else. */
72
74
  export const SHOP_KEY_PERMISSIONS = Object.freeze([
75
+ ['Subscriptions', 'Read', 'verify current subscription items and paid periods'],
76
+ ['Invoices', 'Read', 'verify subscription renewal payments'],
77
+ ['Customer portal', 'Write', 'let shop buyers manage their subscription'],
78
+ ['Products', 'Read', 'read expanded products on checkout and subscription lines'],
79
+ ['Prices', 'Read', 'read prices on checkout and subscription lines'],
73
80
  ['Checkout Sessions', 'Write', 'open Stripe\'s checkout page for one item'],
74
81
  ['Charges', 'Write', 'refunds (Stripe keeps refunds under Charges)'],
75
82
  ['PaymentIntents', 'Read', 'which payment an order was'],
@@ -0,0 +1,27 @@
1
+ /** Assets matching a protected namespace must pass through runtime authorization. */
2
+ export const WORKER_ROUTES = [
3
+ '/', '/index.html', '/api/*', '/_studio/*', '/mcp', '/mcp/*', '/oauth/*', '/hooks/*',
4
+ '/.well-known/*', '/_site/*', '/parts/*', '/games/*', '/account', '/account/*',
5
+ '/shop', '/shop/*', '/lounge', '/lounge/*', '/_homie/*', '/media/*',
6
+ '/robots.txt', '/sitemap.xml', '/llms.txt', '/llms-full.txt',
7
+ ];
8
+ export function staticWorkerRoutes(catalogue) {
9
+ if (catalogue?.studio?.concept === true) return true;
10
+ return [...WORKER_ROUTES, ...(catalogue?.games ?? []).flatMap(game =>
11
+ [`/${game.id}`, `/${game.id}/*`, ...(game.kind === 'app' ? [] : [`!/games/${game.id}/assets/*`, `!/games/${game.id}/_landing/*`])])];
12
+ }
13
+ /** Keep the owner's header rules last. Worker responses attach their own headers. */
14
+ export function staticHeaders(custom = '', {concept = false} = {}) {
15
+ custom = custom.replace(/# BEGIN HOMIE HEADERS[\s\S]*?# END HOMIE HEADERS\n?/g, '');
16
+ return `# BEGIN HOMIE HEADERS
17
+ # Studio defaults: public static assets are served without a Worker invocation.
18
+ /*
19
+ Cache-Control: public, max-age=0, must-revalidate, no-transform
20
+ X-Frame-Options: DENY${concept ? "\n X-Robots-Tag: noindex, nofollow" : ""}
21
+ /games/*
22
+ ! X-Frame-Options
23
+ /parts/*
24
+ ! X-Frame-Options
25
+ # END HOMIE HEADERS
26
+ ${custom}`;
27
+ }
@@ -4005,6 +4005,93 @@
4005
4005
  }
4006
4006
  }
4007
4007
  }
4008
+ },
4009
+ "0.55.0": {
4010
+ "files": {
4011
+ "CLAUDE.md": "ddf2b2a0376202f6",
4012
+ "README.md": "684b3cca8001673c",
4013
+ "HANDOFF.md": "29337e2ace95b2e7",
4014
+ "games/README.md": "5ad32fe80c61f117",
4015
+ "music/README.md": "a3dc042dadd39df8",
4016
+ "videos/README.md": "16babc3437d5545b",
4017
+ "posts/README.md": "37841aae476bd540",
4018
+ "site/README.md": "1dbdf22c03a1e505",
4019
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
4020
+ },
4021
+ "sections": {
4022
+ "AGENTS.md": {
4023
+ "(intro)": [
4024
+ "394cbbe934e80722",
4025
+ "af7f2f732505d379"
4026
+ ],
4027
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
4028
+ "## Layout": "f3a280b8c96cd068",
4029
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
4030
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
4031
+ "## The site": "0cb8dd9a6f8a0096",
4032
+ "## Making games": "bb715272fd1a615b",
4033
+ "## The Game Codex and progress": "0738ef719cef3a4c",
4034
+ "## Art direction and models": "a48fff7bf0a5f2fc",
4035
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
4036
+ "## Servers and AI seats": "1ccfd549d2369a60",
4037
+ "## Selling things (the shop)": "0df5a5fe1833c624",
4038
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
4039
+ "## Rules": "b2234d80f6cbe71d",
4040
+ "## Telling Homie": "80b46fdd9e578db8",
4041
+ "## Beta": "9d02638207a26e73"
4042
+ }
4043
+ },
4044
+ "named": {
4045
+ "Homie Arcade": {
4046
+ "sections": {
4047
+ "AGENTS.md": {
4048
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
4049
+ }
4050
+ }
4051
+ }
4052
+ }
4053
+ },
4054
+ "0.56.0": {
4055
+ "files": {
4056
+ "CLAUDE.md": "ddf2b2a0376202f6",
4057
+ "README.md": "684b3cca8001673c",
4058
+ "HANDOFF.md": "29337e2ace95b2e7",
4059
+ "games/README.md": "5ad32fe80c61f117",
4060
+ "music/README.md": "a3dc042dadd39df8",
4061
+ "videos/README.md": "16babc3437d5545b",
4062
+ "posts/README.md": "37841aae476bd540",
4063
+ "site/README.md": "1dbdf22c03a1e505",
4064
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
4065
+ },
4066
+ "sections": {
4067
+ "AGENTS.md": {
4068
+ "(intro)": "af7f2f732505d379",
4069
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
4070
+ "## Layout": "f3a280b8c96cd068",
4071
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
4072
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
4073
+ "## The site": "0cb8dd9a6f8a0096",
4074
+ "## Making games": "bb715272fd1a615b",
4075
+ "## The Game Codex and progress": "0738ef719cef3a4c",
4076
+ "## Art direction and models": "a48fff7bf0a5f2fc",
4077
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
4078
+ "## Servers and AI seats": "1ccfd549d2369a60",
4079
+ "## Selling things (the shop)": "0df5a5fe1833c624",
4080
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
4081
+ "## Rules": "b2234d80f6cbe71d",
4082
+ "## Telling Homie": "80b46fdd9e578db8",
4083
+ "## Beta": "9d02638207a26e73"
4084
+ }
4085
+ },
4086
+ "named": {
4087
+ "Homie Arcade": {
4088
+ "sections": {
4089
+ "AGENTS.md": {
4090
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
4091
+ }
4092
+ }
4093
+ }
4094
+ }
4008
4095
  }
4009
4096
  }
4010
4097
  }
@@ -1,3 +1,4 @@
1
+ import {staticWorkerRoutes} from './static-assets.mjs';
1
2
  import {createHash} from 'node:crypto';
2
3
  import {refreshWorkerConfig} from './worker-config.mjs';
3
4
  import {pathToFileURL} from 'node:url';
@@ -7,7 +8,7 @@ import {readConfig} from './routes.mjs';
7
8
  import {PACKAGE_ROOT,workerDir} from './studio.mjs';
8
9
  import {paidReleases} from './parts-upload.mjs';
9
10
  /** Preserve dynamic imports as real Worker modules: page startup never evaluates MCP libraries. */
10
- export async function buildWorker(root,esbuild) {
11
+ export async function buildWorker(root,esbuild,metadata=null) {
11
12
  const source=join(root,'site/src/worker.mjs');if(!existsSync(source))return;
12
13
  const out=join(root,'site/src/runtime');
13
14
 
@@ -17,8 +18,19 @@ export async function buildWorker(root,esbuild) {
17
18
  if(paidReleases(root).length || existsSync(join(root,'shop.json')) || pricedTools)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
18
19
  // Older studio entries export only Table and Lobby. Supply the new toolkit-owned
19
20
  // classes in the generated bundle without rewriting the studio's source/customizations.
20
- const contents=`export * from ${JSON.stringify(source)};\nexport { default } from ${JSON.stringify(source)};\nexport { Gate, Concentrator } from ${JSON.stringify(join(PACKAGE_ROOT,'worker/gate.mjs'))};\n`;
21
- const result=await esbuild.build({stdin:{contents,resolveDir:root,sourcefile:'worker.mjs'},entryNames:'worker',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'});
21
+ const standardRooms=/export\s*\{[^}]*\bTable\b[^}]*\bLobby\b[^}]*\}\s*from\s*['"]@homie-rocks\/studio\/worker['"]/.test(readFileSync(source,'utf8'));
22
+ const lazyRooms=standardRooms?`export {Table,Lobby} from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\n`:'';
23
+ // A custom fetch wrapper may predate background delivery. Complete the event
24
+ // boundary here, preserving explicit studio handlers and leaving source alone.
25
+ const toolkitEntry=alias['@homie-rocks/studio/worker']==='@homie-rocks/studio/worker/selling'?'worker/selling/index.mjs':'worker/index.mjs';
26
+ const contents=`${lazyRooms}export * from ${JSON.stringify(source)};\nimport studioWorker from ${JSON.stringify(source)};\nimport toolkitWorker from ${JSON.stringify(join(PACKAGE_ROOT,toolkitEntry))};\nexport default { ...studioWorker, queue: studioWorker.queue ?? toolkitWorker.queue, scheduled: studioWorker.scheduled ?? toolkitWorker.scheduled };\nexport { Gate, Concentrator } from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\nexport {AppRecordHub, EventHub} from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\n`;
27
+ const result=await esbuild.build({stdin:{contents,resolveDir:root,sourcefile:'worker.mjs'},entryNames:'worker',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',plugins:[{name:'studio-runtime',setup(build){
28
+ if(metadata)build.onLoad({filter:/[/\\]worker[/\\]built-metadata\.mjs$/},()=>({contents:'export default '+JSON.stringify(metadata),loader:'js'}));
29
+ // Merely overriding entry exports still evaluates the barrel's dependencies.
30
+ // Standard generated rooms use lazy exports at that barrel as well; a studio
31
+ // with custom subclasses retains the original class constructors.
32
+ if(standardRooms)build.onLoad({filter:/[/\\]worker[/\\]index\.mjs$/},({path})=>path===join(PACKAGE_ROOT,'worker/index.mjs')?{contents:readFileSync(path,'utf8').replace("export {Table} from './table.mjs';","export {Table} from './lazy-objects.mjs';").replace("export {Lobby} from './lobby.mjs';","export {Lobby} from './lazy-objects.mjs';").replace("export { Gate, Concentrator } from './gate.mjs';","export { Gate, Concentrator } from './lazy-objects.mjs';").replace("export {AppRecordHub, EventHub} from './stream-objects.mjs';","export {AppRecordHub, EventHub} from './lazy-objects.mjs';"),loader:'js'}:undefined);
33
+ }}]});
22
34
  // A watcher may already have read the previous entry while this build is
23
35
  // publishing. Keep its entire graph available. Publish immutable modules first,
24
36
  // then switch the single entry atomically; never remove a running graph.
@@ -41,7 +53,7 @@ export async function buildWorker(root,esbuild) {
41
53
  }
42
54
  // Wrangler's module root is the immutable generation, so old generations are
43
55
  // retained locally for in-flight reloads but are never added to a deployment.
44
- refreshWorkerConfig(root);
56
+ refreshWorkerConfig(root, metadata ? {workerRoutes: staticWorkerRoutes(metadata.catalogue)} : {});
45
57
  const current=join(out,'current.json'), pending=`${current}.${process.pid}.tmp`;
46
58
  writeFileSync(pending,JSON.stringify({main:join(dir,'worker.js')})+'\n');renameSync(pending,current);
47
59
  // Dev sometimes watches a copy with routes/remote AI removed. Publish its
@@ -1,3 +1,4 @@
1
+ import {staticWorkerRoutes} from './static-assets.mjs';
1
2
  import {verifiedMcpConfig,publicMcpSetup} from './shop-mcp.mjs';
2
3
  import {readFileSync,writeFileSync,existsSync,renameSync} from 'node:fs';
3
4
  import {join} from 'node:path';
@@ -27,6 +28,14 @@ export function updateWorkerConfig(root,generated) {
27
28
  writeFileSync(join(root,'wrangler.custom.json'),JSON.stringify(custom,null,2)+'\n');
28
29
  }
29
30
  const next=merge(base,custom);
31
+ // Authorization paths are additive: custom assets must never bypass toolkit gates.
32
+ const requiredRoutes=base.assets?.run_worker_first, customRoutes=custom.assets?.run_worker_first;
33
+ // A "!" pattern excludes paths from the Worker. Only Homie writes those (static-assets.mjs): a custom one
34
+ // could take /games/*, /_studio/* or an app's files out from behind their access checks.
35
+ const exclusions=Array.isArray(customRoutes)?customRoutes.filter(p=>String(p).startsWith('!')):[];
36
+ if(exclusions.length)throw new Error(`wrangler.custom.json assets.run_worker_first cannot exclude paths (${exclusions.join(', ')}): list only the extra paths your own routes need`);
37
+ next.assets={...next.assets,run_worker_first:requiredRoutes===true || customRoutes===true ? true : [...new Set([...(Array.isArray(requiredRoutes)?requiredRoutes:[]),...(Array.isArray(customRoutes)?customRoutes:[])])]};
38
+ if(readStudio(root).concept===true)next.assets={...next.assets,run_worker_first:true};
30
39
  next.vars={...next.vars,HOMIE_CONCEPT:readStudio(root).concept===true?'1':'0'};
31
40
  verifiedMcpConfig(root);
32
41
  if(publicMcpSetup(root)) {
@@ -48,5 +57,5 @@ export function refreshWorkerConfig(root,overrides={}) {
48
57
  const tools=read(join(root,'site/src/tools/declarations.json'));
49
58
  const ai=cf.ai??listExperiences(root).some(g=>g.decide===true || g.chat!==false && (g.chat?.mode??'text')==='text' && g.chat?.ai!==false);
50
59
  const paid=paidReleases(root).length>0||existsSync(join(root,'shop.json'))||tools.paid===true;
51
- 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,eventFeeds:studio.mcp?.events,triggers:{crons:[scheduleTrigger(functions,{eventFeeds:Object.keys(studio.mcp?.events??{}).length>0})].filter(Boolean)},...overrides}));
60
+ updateWorkerConfig(root,wranglerConfig({analytics:studio.stats?.analytics===true,workerRoutes:staticWorkerRoutes(read(join(root,'site/dist/games.json'))),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,eventFeeds:studio.mcp?.events,triggers:{crons:[scheduleTrigger(functions,{eventFeeds:Object.keys(studio.mcp?.events??{}).length>0})].filter(Boolean)},...overrides}));
52
61
  }
@@ -1591,7 +1591,11 @@ A page stands in one of six places with its room, and the helper says which:
1591
1591
  - **`net.offline` means "not in a room"**, not "no network": it is true for `alone` and `offline`.
1592
1592
  **`net.connected`** is true only while the socket is up and welcomed. The state between them
1593
1593
  (`!net.offline && !net.connected`) is `reconnecting`.
1594
- - **`on('link', (e) => …)`** fires on every change with `{ state, prev, why, hosting, downMs }`.
1594
+ - **`on('link', (e) => …)`** immediately calls the listener with the current state
1595
+ (`prev === state`, `why: 'subscribe'`), then fires on every change with `{ state, prev, why, hosting, downMs }`.
1596
+ Ignore `why === 'subscribe'` when triggering reconnect recovery; it initializes
1597
+ UI and does not signal a new connection. Listener errors are isolated, including
1598
+ this immediate call, and `on()` always returns the unsubscribe function.
1595
1599
  `hosting` says this page is running the rules right now. Two pages can both say so for a
1596
1600
  moment: a host that was cut off keeps simulating until its welcome tells it the room moved on,
1597
1601
  and a page that is `alone` hosts its own private round. Before revision 9 neither said
@@ -170,7 +170,7 @@ export type LinkState = 'connecting' | 'online' | 'reconnecting' | 'alone' | 'of
170
170
  export interface LinkChange {
171
171
  state: LinkState;
172
172
  prev: LinkState;
173
- /** Why it changed: `welcome`, `lost` (the socket closed), `stale` (it went silent), `relay-timeout` (the room never answered), `reconnect-timeout` (it was in the room and the room did not come back in `reconnectMaxMs`), `no-shell`, or a refusal's code. */
173
+ /** `subscribe` is the current state delivered immediately on subscription (prev equals state). Otherwise, why it changed: `welcome`, `lost` (the socket closed), `stale` (it went silent), `relay-timeout` (the room never answered), `reconnect-timeout` (it was in the room and the room did not come back in `reconnectMaxMs`), `no-shell`, or a refusal's code. */
174
174
  why: string;
175
175
  /** This browser is running the rules right now (alone, or as the room's host): two browsers can both say so while one is cut off. */
176
176
  hosting: boolean;
@@ -749,7 +749,7 @@ export interface NetHandlers<S, A, C> {
749
749
  unchat: (e: { ids: string[] }) => void;
750
750
  /** Revision 8: a message of mine was not sent (slow mode, sign in to type, a word, the studio's filter…). */
751
751
  held: (h: ChatHeld) => void;
752
- /** Revision 9: where this browser stands with its room changed (online, reconnecting, alone, closed). */
752
+ /** Current link on subscription (why: subscribe), then changes (online, reconnecting, alone, closed). */
753
753
  link: (e: LinkChange) => void;
754
754
  /** Revision 9: a newer build of the game is live; this tab should reload (section 23). */
755
755
  stale: (e: StaleNotice) => void;
@@ -797,7 +797,7 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
797
797
  * THE LINK (revision 9, NETPLAY.md section 22). `offline` is true when this browser is NOT in a room: there is no
798
798
  * shell at all, or the room never answered and it plays alone (`link === 'alone'`). `connected` is true only while
799
799
  * the socket is up and welcomed. Between the two (`!offline && !connected`) the browser was in a room and is
800
- * knocking again: `link === 'reconnecting'`. `link` says which in one word, and `on('link')` when it changes.
800
+ * knocking again: `link === 'reconnecting'`. `link` says which in one word, and `on('link')` immediately reports the current state, then each change.
801
801
  */
802
802
  readonly link: LinkState;
803
803
  /** Times the helper has scheduled another knock at the relay (the same number as `stats().reconnects`). 0: never interrupted. */
@@ -1713,6 +1713,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1713
1713
  const peers = new Map<string, Peer>();
1714
1714
  let heldPeers: Peer[] = [];
1715
1715
  let roundInfo: RoundInfo | null = null;
1716
+ let idlePing = false, lastClockPing = -Infinity;
1716
1717
  let slots: Slot[] | null = null;
1717
1718
  let lastCkpt: Checkpoint<C> | null = null;
1718
1719
 
@@ -2352,12 +2353,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2352
2353
 
2353
2354
  // ---------------------------------------------------------------- wire in
2354
2355
  function onMessage(data: unknown): void {
2355
- lastMsgAt = wall();
2356
+ if (data === 'homie-pong' && idlePing) { lastMsgAt = wall(); return; }
2356
2357
  const text = typeof data === 'string' ? data : String(data);
2357
2358
  countBytes(bytesIn, text.length);
2358
2359
  let m: Record<string, unknown>;
2359
2360
  try { m = JSON.parse(text) as Record<string, unknown>; } catch { return; }
2360
2361
  if (!m || typeof m['t'] !== 'string') return;
2362
+ lastMsgAt = wall();
2361
2363
  switch (m['t']) {
2362
2364
  case 'welcome': return onWelcome(m);
2363
2365
  case 'snap': return onSnap(m, text.length);
@@ -2583,6 +2585,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2583
2585
  }
2584
2586
 
2585
2587
  function onWelcome(m: Record<string, unknown>): void {
2588
+ idlePing = m.idlePing === true; lastClockPing = -Infinity;
2586
2589
  post?.({ what: 'build', ver: version });
2587
2590
  clearTimeout(connectTimer);
2588
2591
  const next = m['role'] as Role;
@@ -2810,7 +2813,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2810
2813
  const st = Number(m['st']);
2811
2814
  const sent = pingsOut.get(c);
2812
2815
  if (sent === undefined || !Number.isFinite(st)) return;
2813
- pingsOut.delete(c);
2816
+ for (const key of pingsOut.keys()) if (key <= c) pingsOut.delete(key);
2814
2817
  const recv = Date.now();
2815
2818
  const r = recv - sent;
2816
2819
  const off = st + r / 2 - recv;
@@ -2869,7 +2872,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2869
2872
  }
2870
2873
 
2871
2874
  function hidden(): boolean { try { return Boolean(g.document?.hidden); } catch { return false; } }
2872
- function ping(): void {
2875
+ function ping(force = false): void {
2873
2876
  // This runs every two seconds at least. A gap of more than three and a half means the page itself was held.
2874
2877
  const beat = wall();
2875
2878
  const pingHeld = beat - lastPingTick > 2000 + 1500;
@@ -2885,12 +2888,22 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2885
2888
  // the word of a timer that itself ran seconds late: the page was blocked (a heavy boot, shaders compiling) and
2886
2889
  // could not have heard anything, and what the relay sent meanwhile is still queued behind this very timer.
2887
2890
  // Dropping a healthy socket there turned every long hitch into a reconnect.
2888
- if (ws && lastMsgAt && wall() - lastMsgAt > staleMs) {
2891
+ const oldestPing = pingsOut.values().next().value;
2892
+ if (ws && oldestPing !== undefined && Date.now() - oldestPing >= 30_000 && !pingHeld) { lost(ws, 'stale'); return; }
2893
+ const silence = idlePing && (roundInfo?.phase !== 'live' || wall() - lastClockPing > staleMs) ? Math.max(staleMs, 60_000) : staleMs;
2894
+ if (ws && lastMsgAt && wall() - lastMsgAt > silence) {
2889
2895
  if (!pingHeld) { lost(ws, 'stale'); return; }
2890
2896
  // Two and a half seconds to be heard from (a pong answers the ping below): a socket that is really dead is
2891
2897
  // still dropped, two beats from now.
2892
2898
  lastMsgAt = wall() - staleMs + 2500;
2893
2899
  }
2900
+ // An idle hibernatable room can answer this at the edge. Keep a regular
2901
+ // clock/visibility sample on visibility changes, and every heartbeat during play.
2902
+ if (idlePing && roundInfo?.phase !== 'live' && !(roundInfo?.phase === 'over' && now() >= roundInfo.endsAt) && !force) {
2903
+ try { ws?.send('homie-ping'); } catch { if(ws)lost(ws,'send'); }
2904
+ return;
2905
+ }
2906
+ lastClockPing = wall();
2894
2907
  const c = Date.now();
2895
2908
  pingsOut.set(c, c);
2896
2909
  if (pingsOut.size > 16) { const first = pingsOut.keys().next().value; if (first !== undefined) pingsOut.delete(first); }
@@ -3026,6 +3039,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3026
3039
  ws = sock; socketStartedAt = wall();
3027
3040
  sock.onopen = () => {
3028
3041
  lastMsgAt = wall();
3042
+ pingsOut.clear();
3029
3043
  raw({
3030
3044
  t: 'hello', rules: rulesGame, v: NETPLAY_VERSION, rev: NETPLAY_REVISION, token, name: name || undefined, device, want, canHost: asAgent && asAgent.hands === 'host' ? false : canHost, game: opts.game, max: opts.maxPlayers,
3031
3045
  ...(rulesGame ? { coast: 1 } : {}),
@@ -3101,8 +3115,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3101
3115
  if (hidden()) {
3102
3116
  // A hidden phone tab loses its timers and rAF: hand the round on before it stalls.
3103
3117
  if (role === 'host' && peers.size > 1) yieldHost();
3104
- ping();
3105
- } else ping();
3118
+ ping(true);
3119
+ } else ping(true);
3106
3120
  });
3107
3121
  g.addEventListener?.('online', () => { if (!closed && !connected && rulesGame) { if (ws) lost(ws, 'online'); if (retryTimer !== null) clearTimeout(retryTimer); retryTimer = null; attempt = 0; open(); } });
3108
3122
  g.addEventListener?.('pagehide', () => { if (role === 'host') { sendCheckpoint(); rulesSender?.leave(); } raw({ t: 'bye' }); });
@@ -3353,6 +3367,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3353
3367
  on(kind, fn) {
3354
3368
  (handlers[kind] as Set<unknown>).add(fn);
3355
3369
  if (kind === 'view') markFollows();
3370
+ if (kind === 'link') try { (fn as NetHandlers<S, A, C>['link'])({state:link,prev:link,why:'subscribe',hosting:roleKnown && role === 'host',downMs:connected || !downSince ? 0 : Math.round(wall() - downSince)}); } catch { /* A failed UI listener must still be removable. */ }
3356
3371
  return () => { (handlers[kind] as Set<unknown>).delete(fn); };
3357
3372
  },
3358
3373
  now,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.54.0",
3
+ "version": "0.56.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",
package/rules/core.ts CHANGED
@@ -173,6 +173,8 @@ export interface Core {
173
173
  shared(): unknown[];
174
174
  /** The current round, for a host rebasing wall-clock deadlines after a pause or restore. */
175
175
  round(): Extract<CoreOut, { t: 'round' }>;
176
+ /** End a settled wall-clock break without simulating its idle ticks. */
177
+ resumeRound(): boolean;
176
178
  bodies(): Body[];
177
179
  /** Every person's body with where it stands: who an effect with an audience reaches (0.46.3). */
178
180
  listeners(): { seat: number; id: string; pos: Vec3 }[];
@@ -1537,6 +1539,13 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1537
1539
  }
1538
1540
  return {
1539
1541
  round: () => ({ t: 'round', ...round, ...(round.phase === 'over' && !overAt ? { results: results() } : {}) }),
1542
+ resumeRound() {
1543
+ if (round.phase !== 'over' || overAt) return false;
1544
+ // Preserve queued events and their simulation-time deadlines. Nothing
1545
+ // moves during the wall-clock break; the next step delivers them.
1546
+ if (playing) startRound(); else restartAt = tick;
1547
+ return true;
1548
+ },
1540
1549
  get tick() { return tick; },
1541
1550
  reviseMovement,
1542
1551
  get epoch() { return epoch; },