@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.
- package/CHANGELOG.md +66 -0
- package/README.md +2 -0
- package/bin/homie-studio.mjs +1 -1
- package/docs/custom-routes.md +72 -0
- package/guides/app/SKILL.md +7 -0
- package/guides/playtest/scripts/playtest.mjs +7 -5
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +4 -1
- package/lib/check.mjs +8 -1
- package/lib/cloudflare.mjs +65 -23
- package/lib/dev.mjs +6 -8
- package/lib/local-database-start.mjs +6 -0
- package/lib/mcp-tools.mjs +3 -3
- package/lib/port-check.mjs +3 -2
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +30 -10
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +87 -0
- package/lib/worker-build.mjs +16 -4
- package/lib/worker-config.mjs +10 -1
- package/netplay/NETPLAY.md +5 -1
- package/netplay/netplay.ts +24 -9
- package/package.json +1 -1
- package/rules/core.ts +9 -0
- package/rules/host.ts +40 -11
- package/rules/view.ts +1 -0
- package/tools/TOOLS.md +6 -1
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-streams.mjs +3 -3
- package/worker/asset-paths.mjs +2 -0
- package/worker/background-queue.mjs +31 -0
- package/worker/build-id.mjs +1 -1
- package/worker/built-metadata.mjs +2 -0
- package/worker/catalogue.mjs +18 -0
- package/worker/chat-page.mjs +1 -1
- package/worker/custom-routes.mjs +16 -0
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +23 -11
- package/worker/function-registry.mjs +4 -0
- package/worker/functions.mjs +7 -5
- package/worker/gate-routing.mjs +61 -0
- package/worker/gate.mjs +106 -91
- package/worker/hosted-registry.mjs +11 -0
- package/worker/hosted.mjs +3 -17
- package/worker/idle-chat.mjs +56 -0
- package/worker/index.mjs +56 -2785
- package/worker/lazy-objects.mjs +43 -0
- package/worker/lobby-limits.mjs +2 -0
- package/worker/lobby-peak.mjs +44 -0
- package/worker/lobby-shards.mjs +43 -0
- package/worker/lobby-store.mjs +35 -0
- package/worker/lobby-summary.mjs +28 -0
- package/worker/lobby.mjs +319 -0
- package/worker/lounge-store.mjs +2 -2
- package/worker/lounge.mjs +3 -3
- package/worker/match-router.mjs +74 -0
- package/worker/mcp-event-streams.mjs +3 -3
- package/worker/mcp-events.mjs +4 -4
- package/worker/mcp-room.mjs +2 -1
- package/worker/mcp-store.mjs +12 -1
- package/worker/mcp-tools.mjs +4 -2
- package/worker/mcp.mjs +5 -1
- package/worker/office.mjs +18 -21
- package/worker/operational-schema.mjs +16 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +9 -29
- package/worker/parts.mjs +2 -1
- package/worker/payment-recovery.mjs +33 -19
- package/worker/player-registry.mjs +9 -0
- package/worker/players.mjs +8 -7
- package/worker/purchase-machine.mjs +5 -3
- package/worker/reconcile-guard.mjs +22 -0
- package/worker/relay-channel.mjs +119 -0
- package/worker/request-db.mjs +36 -10
- package/worker/responses.mjs +18 -0
- package/worker/room-chat.mjs +217 -0
- package/worker/room-controls.mjs +296 -0
- package/worker/room-lifecycle.mjs +10 -0
- package/worker/room-protocol.mjs +197 -0
- package/worker/room-sessions.mjs +65 -0
- package/worker/room.mjs +54 -740
- package/worker/rooms-cache.mjs +19 -0
- package/worker/route-auth.mjs +22 -0
- package/worker/routes.mjs +670 -0
- package/worker/runtime-support.mjs +447 -0
- package/worker/seats.mjs +1 -1
- package/worker/selling/index.mjs +1 -0
- package/worker/selling/runtime.mjs +7 -5
- package/worker/service-run.mjs +1 -1
- package/worker/session-codec.mjs +24 -0
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop.mjs +27 -20
- package/worker/stats.mjs +9 -7
- package/worker/stream-objects.mjs +18 -0
- package/worker/stripe-intake.mjs +79 -0
- package/worker/stripe.mjs +3 -0
- package/worker/table.mjs +1448 -0
- package/worker/tool-registry.mjs +10 -0
- package/worker/version.mjs +1 -1
- package/worker/watch-sockets.mjs +75 -0
package/lib/scaffold.mjs
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
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' },
|
|
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
|
-
|
|
103
|
-
assets: { directory: at.dist, binding: 'ASSETS', run_worker_first:
|
|
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
|
}
|
package/lib/worker-build.mjs
CHANGED
|
@@ -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
|
|
21
|
-
const
|
|
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
|
package/lib/worker-config.mjs
CHANGED
|
@@ -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
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -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) => …)`**
|
|
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
|
package/netplay/netplay.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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')`
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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; },
|