@homie-rocks/studio 0.32.1 → 0.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -1
- package/README.md +24 -6
- package/agents/agents.ts +57 -24
- package/bin/homie-studio.mjs +3 -3
- package/lib/build.mjs +64 -9
- package/lib/cf-usage.mjs +76 -0
- package/lib/cloudflare.mjs +25 -2
- package/lib/dev.mjs +45 -4
- package/lib/lab-check.mjs +3 -2
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +1 -1
- package/lib/office.mjs +7 -0
- package/lib/perf.mjs +8 -3
- package/lib/player-image.mjs +40 -0
- package/lib/rules-build.mjs +184 -0
- package/lib/rules-guard.mjs +770 -0
- package/lib/scaffold.mjs +43 -16
- package/lib/shop-catalog.mjs +10 -9
- package/lib/shop.mjs +32 -15
- package/lib/studio.mjs +22 -1
- package/lib/template-history.json +90 -0
- package/lib/upgrade.mjs +19 -15
- package/lib/version.mjs +2 -7
- package/netplay/NETPLAY.md +249 -4
- package/netplay/netplay.ts +55 -13
- package/package.json +23 -1
- package/rules/core.ts +1250 -0
- package/rules/guard.ts +419 -0
- package/rules/host.ts +707 -0
- package/rules/math.ts +266 -0
- package/rules/pack.ts +292 -0
- package/rules/rules.ts +544 -0
- package/rules/view.ts +447 -0
- package/shop/SELLING.md +14 -16
- package/shop/SHOP.md +119 -30
- package/shop/shop.ts +6 -8
- package/site/SITE.md +119 -2
- package/starters/coin-dash/game.json +11 -0
- package/starters/coin-dash/index.html +17 -0
- package/starters/coin-dash/map/main.json +16 -0
- package/starters/coin-dash/src/move.ts +27 -0
- package/starters/coin-dash/src/rules.ts +86 -0
- package/starters/coin-dash/src/view.ts +106 -0
- package/starters/coin-dash/tunables.json +5 -0
- package/starters/ember-vale/index.html +11 -0
- package/worker/brain.mjs +8 -7
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +135 -0
- package/worker/hosted.mjs +56 -0
- package/worker/index.mjs +413 -22
- package/worker/office-page.mjs +1 -0
- package/worker/office.mjs +24 -7
- package/worker/pages.mjs +189 -43
- package/worker/players.mjs +10 -6
- package/worker/referrals.mjs +192 -57
- package/worker/room-store.mjs +54 -0
- package/worker/room.mjs +294 -119
- package/worker/saves-shell.mjs +7 -1
- package/worker/schema.mjs +4 -3
- package/worker/shop-page.mjs +103 -37
- package/worker/shop-rules.mjs +156 -117
- package/worker/shop-store.mjs +70 -9
- package/worker/shop.mjs +275 -143
- package/worker/site.mjs +26 -5
- package/worker/stripe.mjs +9 -6
- package/worker/version.mjs +1 -1
package/lib/scaffold.mjs
CHANGED
|
@@ -29,9 +29,10 @@ import { PLAYERS_MIGRATION, PLAYERS_MIGRATION_FILE } from '../worker/players.mjs
|
|
|
29
29
|
import { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from '../worker/office-schema.mjs';
|
|
30
30
|
import { SERVERS_MIGRATION, SERVERS_MIGRATION_FILE } from '../worker/servers.mjs';
|
|
31
31
|
import { CHAT_MIGRATION, CHAT_MIGRATION_FILE } from '../worker/chat-store.mjs';
|
|
32
|
-
import { SHOP_MIGRATION, SHOP_MIGRATION_FILE } from '../worker/shop-store.mjs';
|
|
32
|
+
import { SHOP_MIGRATION, SHOP_MIGRATION_FILE, SHOP_RESERVATIONS, SHOP_RESERVATIONS_FILE, SHOP_STATEMENTS, SHOP_STATEMENTS_FILE } from '../worker/shop-store.mjs';
|
|
33
33
|
import { LOUNGE_MIGRATION, LOUNGE_MIGRATION_FILE } from '../worker/lounge-store.mjs';
|
|
34
34
|
import { themeFile } from './site.mjs';
|
|
35
|
+
import { rulesIndex } from './studio.mjs';
|
|
35
36
|
|
|
36
37
|
export const COMPAT_DATE = '2026-06-01';
|
|
37
38
|
/** 4.135.0 or later: Worker Previews (`wrangler preview`, a Durable Object namespace per Preview). */
|
|
@@ -75,7 +76,9 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
|
|
|
75
76
|
// The site's own fetches (its directory claim, "played this week") go out as any browser's would, so a studio
|
|
76
77
|
// whose directory is on the same zone (a house studio on *.homie.rocks, or two Workers on one workers.dev
|
|
77
78
|
// subdomain) reaches it instead of failing with 1042.
|
|
78
|
-
|
|
79
|
+
// `disallow_eval_during_startup`: no code is made from a string while the Worker loads (Cloudflare never allows it
|
|
80
|
+
// later). A game's rules run in this Worker (NETPLAY.md section 29), and nothing in them is ever made from text.
|
|
81
|
+
compatibility_flags: ['global_fetch_strictly_public', 'disallow_eval_during_startup'],
|
|
79
82
|
workers_dev: true,
|
|
80
83
|
preview_urls: true,
|
|
81
84
|
// The studio's own custom-domain and exact-host routes, as its owner wrote them: this file is written again by
|
|
@@ -210,7 +213,7 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
210
213
|
one-time link that marks their own player account (a passkey on this site) as the owner's, so their games and
|
|
211
214
|
the studio's back office recognise them.
|
|
212
215
|
- \`npx --no-install homie-studio upgrade\` — after pinning a newer \`@homie-rocks/studio\` (or through
|
|
213
|
-
\`npx -y
|
|
216
|
+
\`npx -y @homie-rocks/studio@<its version> upgrade\`): what the newer template adds to this studio (AGENTS.md
|
|
214
217
|
sections, READMEs, .gitignore lines) and what it keeps. It changes nothing until \`--apply\`, and never
|
|
215
218
|
touches a file or section this studio changed; \`--diff\` shows how those differ from the template's.
|
|
216
219
|
|
|
@@ -433,18 +436,18 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
433
436
|
- **The studio sells with its OWN Stripe account.** The studio is the seller: its prices, its refunds, its tax, its
|
|
434
437
|
disputes. Money goes from players to the studio's Stripe; homie.rocks never sees it and Homie takes no cut.
|
|
435
438
|
\`SELLING.md\` says what that means for the owner in plain words (not legal advice).
|
|
436
|
-
- **\`shop.json\`**
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
439
|
+
- **\`shop.json\`** is the studio's settings file: \`till\` (\`stripe\`, \`stripe-managed\`, or \`off\`), \`currency\`,
|
|
440
|
+
optional \`refundDays\` and \`capPerPlayerMonth\`, and items with studio-chosen kinds, names, prices and entitlement
|
|
441
|
+
keys. No Homie ceiling on prices, tips, item count, keys, text, durations or referral terms. Stripe currency
|
|
442
|
+
units and provider minimums still apply, including zero-decimal currencies. \`shop init --supporter\` writes a
|
|
443
|
+
supporter item and explicit protective policy, with no spending cap or refund window. Existing values stay.
|
|
444
|
+
- **The studio chooses who may buy:** \`policy.preset\` is \`protective\` (the default), \`adults-only\`, or \`custom\`.
|
|
445
|
+
Each rule can be read and edited in shop.json; SHOP.md lists them. Protective keeps the account and age
|
|
446
|
+
question, under-13 refusal, parent checkout for teens, closed kids shops, beginner fairness, refusal of paid
|
|
447
|
+
randomness and countdowns, and TV code to a phone. Do not silently relax protection for children. The studio
|
|
448
|
+
is responsible for law and provider terms where it sells; presets are not legal advice.
|
|
449
|
+
- **Flood protection:** \`purchaseAttemptsPerMinute\` (default 6) is per account; \`purchaseAttemptsPerAddressPerMinute\` (default 600) is per address. Both are per Worker instance and configurable.
|
|
450
|
+
New guest buyers use \`guestBuyersPerAddressPerHour\` (default 600). These protect against floods, not spending.
|
|
448
451
|
- **In a game:** \`import { createShop } from '@homie-rocks/studio/shop'\`; \`shop.has('skin:ember')\`,
|
|
449
452
|
\`shop.entitlements()\`, \`shop.on('change', …)\`, \`shop.open(item)\` from a button the player pressed (never the
|
|
450
453
|
play button, never on a timer), \`shop.used(key)\` when it is equipped. A supporter's badge rides on their seat
|
|
@@ -710,8 +713,14 @@ export function studioFiles({ name, slug, homie, template = false }) {
|
|
|
710
713
|
'site/README.md': SITE_README,
|
|
711
714
|
'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1, and R2 once storage is added.
|
|
712
715
|
// The code is @homie-rocks/studio's, pinned in package.json, so an update never changes a published game by surprise.
|
|
716
|
+
import { hostRules } from '@homie-rocks/studio/worker';
|
|
717
|
+
// The rules of this studio's games that run on the server (written by homie-studio build; empty until a game has some).
|
|
718
|
+
import rules from './rules/index.mjs';
|
|
719
|
+
|
|
720
|
+
hostRules(rules);
|
|
713
721
|
export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
714
722
|
`,
|
|
723
|
+
'site/src/rules/index.mjs': rulesIndex([]),
|
|
715
724
|
'site/migrations/0001_studio.sql': MIGRATION,
|
|
716
725
|
[`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
|
|
717
726
|
[`site/migrations/${PLAYERS_MIGRATION_FILE}`]: PLAYERS_MIGRATION,
|
|
@@ -719,6 +728,8 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
|
719
728
|
[`site/migrations/${SERVERS_MIGRATION_FILE}`]: SERVERS_MIGRATION,
|
|
720
729
|
[`site/migrations/${CHAT_MIGRATION_FILE}`]: CHAT_MIGRATION,
|
|
721
730
|
[`site/migrations/${SHOP_MIGRATION_FILE}`]: SHOP_MIGRATION,
|
|
731
|
+
[`site/migrations/${SHOP_RESERVATIONS_FILE}`]: SHOP_RESERVATIONS,
|
|
732
|
+
[`site/migrations/${SHOP_STATEMENTS_FILE}`]: SHOP_STATEMENTS,
|
|
722
733
|
[`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
|
|
723
734
|
'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
|
|
724
735
|
'.claude/skills/.gitkeep': '',
|
|
@@ -825,6 +836,22 @@ export function ensureShopMigration(root) {
|
|
|
825
836
|
return `site/migrations/${SHOP_MIGRATION_FILE}`;
|
|
826
837
|
}
|
|
827
838
|
|
|
839
|
+
function ensureShopReservations(root) {
|
|
840
|
+
const file = join(root, 'site', 'migrations', SHOP_RESERVATIONS_FILE);
|
|
841
|
+
if (existsSync(file)) return null;
|
|
842
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
843
|
+
writeFileSync(file, SHOP_RESERVATIONS);
|
|
844
|
+
return `site/migrations/${SHOP_RESERVATIONS_FILE}`;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function ensureShopStatements(root) {
|
|
848
|
+
const file = join(root, 'site', 'migrations', SHOP_STATEMENTS_FILE);
|
|
849
|
+
if (existsSync(file)) return null;
|
|
850
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
851
|
+
writeFileSync(file, SHOP_STATEMENTS);
|
|
852
|
+
return `site/migrations/${SHOP_STATEMENTS_FILE}`;
|
|
853
|
+
}
|
|
854
|
+
|
|
828
855
|
/** Every migration the template owns that this studio lacks, added: the files written (deploy and dev say so). */
|
|
829
856
|
/** A studio made before 0.23.0 has no room chat tables: the owner's chat rules and players' reports. */
|
|
830
857
|
function ensureChatMigration(root) {
|
|
@@ -845,7 +872,7 @@ function ensureLoungeMigration(root) {
|
|
|
845
872
|
}
|
|
846
873
|
|
|
847
874
|
export function ensureMigrations(root) {
|
|
848
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root)].filter(Boolean);
|
|
875
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root)].filter(Boolean);
|
|
849
876
|
}
|
|
850
877
|
|
|
851
878
|
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
package/lib/shop-catalog.mjs
CHANGED
|
@@ -27,16 +27,17 @@ import { productIdOf } from '../worker/stripe.mjs';
|
|
|
27
27
|
const MARK = 'shop-v1';
|
|
28
28
|
|
|
29
29
|
/** The Products the catalog should hold, from the checked shop.json. */
|
|
30
|
-
export function wantedProducts(shop, slug) {
|
|
31
|
-
return (shop?.items ?? []).map((it) => {
|
|
32
|
-
const id = productIdOf(slug, it.id);
|
|
30
|
+
export async function wantedProducts(shop, slug) {
|
|
31
|
+
return Promise.all((shop?.items ?? []).map(async (it) => {
|
|
32
|
+
const id = await productIdOf(slug, it.id);
|
|
33
33
|
return {
|
|
34
34
|
id, item: it.id, name: it.name, description: it.blurb ?? null, tax_code: defaultTaxCode(it),
|
|
35
|
-
metadata:
|
|
35
|
+
// Stripe metadata values: 500 characters. https://docs.stripe.com/metadata (fetched 2026-10-08).
|
|
36
|
+
metadata: { homie: MARK, homie_studio: String(slug), homie_item: it.id.length <= 500 ? it.id : id },
|
|
36
37
|
price: it.kind === 'tip' ? null : { currency: shop.currency, unit_amount: it.price, tax_behavior: 'exclusive' },
|
|
37
|
-
shown: it.kind === 'tip' ? `pay what you want, ${money(it.min, shop.currency)} to ${money(it.max, shop.currency)}` : money(it.price, shop.currency),
|
|
38
|
+
shown: it.kind === 'tip' ? `pay what you want, ${money(it.min, shop.currency)}${it.max === null ? ' or more' : ` to ${money(it.max, shop.currency)}`}` : money(it.price, shop.currency),
|
|
38
39
|
};
|
|
39
|
-
});
|
|
40
|
+
}));
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
|
@@ -60,14 +61,14 @@ export function productsIn(value, out = [], depth = 0) {
|
|
|
60
61
|
const taxCodeOf = (p) => (typeof p?.tax_code === 'string' ? p.tax_code : p?.tax_code?.id ?? null);
|
|
61
62
|
|
|
62
63
|
/** The plan: the read first, then (with what Stripe answered) the writes still needed. */
|
|
63
|
-
export function catalogPlan(root, { have = null, mode = null, record = true } = {}) {
|
|
64
|
+
export async function catalogPlan(root, { have = null, mode = null, record = true } = {}) {
|
|
64
65
|
const r = readShop(root);
|
|
65
66
|
if (r.absent) return { ok: false, command: 'shop catalog', why: 'no shop.json: homie-studio shop init writes one first' };
|
|
66
|
-
if (!r.ok) return { ok: false, command: 'shop catalog', why: `shop.json
|
|
67
|
+
if (!r.ok) return { ok: false, command: 'shop catalog', why: `shop.json conflicts with the studio settings or payment requirements (homie-studio shop check): ${r.errors.map((e) => `${e.at}: ${e.message}`).join('; ')}` };
|
|
67
68
|
const studio = readStudio(root);
|
|
68
69
|
const slug = studio.slug;
|
|
69
70
|
if (!slug) return { ok: false, command: 'shop catalog', why: 'studio.json has no slug: the catalog\'s Product ids are made from it' };
|
|
70
|
-
const wanted = wantedProducts(r.shop, slug);
|
|
71
|
+
const wanted = await wantedProducts(r.shop, slug);
|
|
71
72
|
const read = {
|
|
72
73
|
tool: 'stripe_api_read', method: 'GET', path: '/v1/products', params: { limit: 100, expand: ['data.default_price'] },
|
|
73
74
|
then: 'Save exactly what it answers (every page, while has_more) to a file in the scratch folder, then run: npx --no-install homie-studio shop catalog --have <that file>',
|
package/lib/shop.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* homie-studio shop is it selling, and if not, what is missing (the live site's office view)
|
|
6
6
|
* homie-studio shop init [--supporter] [--currency usd] [--price 500] [--managed]
|
|
7
7
|
* writes shop.json (a $5 Supporter pack with --supporter) and SELLING.md
|
|
8
|
-
* homie-studio shop check shop.json against the
|
|
8
|
+
* homie-studio shop check shop.json against the studio settings and provider requirements
|
|
9
9
|
* homie-studio shop connect [--managed] [--live] a page on THIS computer (127.0.0.1, one use, ten minutes) where the owner
|
|
10
10
|
* pastes the studio's restricted Stripe key (the one thing only they can make);
|
|
11
11
|
* with it this process makes the webhook (0.24.3), and the key and the
|
|
@@ -32,7 +32,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
32
32
|
import { askedFor, withKey } from './office.mjs';
|
|
33
33
|
import { runner } from './cloudflare.mjs';
|
|
34
34
|
import { listGames, readStudio, siteUrl } from './studio.mjs';
|
|
35
|
-
import { SHOP_FILE, audienceOf, checkShop, defaultTaxCode, money } from '../worker/shop-rules.mjs';
|
|
35
|
+
import { POLICY_PRESETS, SHOP_FILE, audienceOf, checkShop, defaultTaxCode, money } from '../worker/shop-rules.mjs';
|
|
36
36
|
import {
|
|
37
37
|
KEY_SHAPE, STRIPE_VERSION, StripeError, WEBHOOK_SECRET_SHAPE, createWebhookEndpoint, expireCheckoutSession, isPermissionError, listWebhookEndpoints, modeOf,
|
|
38
38
|
stripeCall, updateWebhookEndpoint,
|
|
@@ -62,14 +62,14 @@ export function readShop(root) {
|
|
|
62
62
|
try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { return { ok: false, shop: null, errors: [{ at: '', message: `shop.json is not JSON: ${error.message}` }], warnings: [], file }; }
|
|
63
63
|
const games = listGames(root).map((g) => g.id);
|
|
64
64
|
const studio = readStudio(root);
|
|
65
|
-
return { ...checkShop(raw, { games, audience: audienceOf(studio) }), file };
|
|
65
|
+
return { ...checkShop(raw, { games, audience: audienceOf(studio), studioName: studio.name }), file };
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/** The build's half: the checked shop for games.json, or a thrown error that says what to fix (nothing is published). */
|
|
69
69
|
export function shopForBuild(root, { log = () => {} } = {}) {
|
|
70
70
|
const r = readShop(root);
|
|
71
71
|
if (r.absent) return null;
|
|
72
|
-
if (!r.ok) throw new Error(`shop.json
|
|
72
|
+
if (!r.ok) throw new Error(`shop.json conflicts with the studio settings or payment requirements, so the build stops (check the studio settings and payment requirements):\n${r.errors.map((e) => ` ${e.at || 'shop.json'}: ${e.message}`).join('\n')}`);
|
|
73
73
|
for (const w of r.warnings) log(`shop.json ${w.at}: ${w.message}`);
|
|
74
74
|
return r.shop;
|
|
75
75
|
}
|
|
@@ -77,7 +77,7 @@ export function shopForBuild(root, { log = () => {} } = {}) {
|
|
|
77
77
|
export function shopCheck(root) {
|
|
78
78
|
const r = readShop(root);
|
|
79
79
|
if (r.absent) return { ok: true, command: 'shop check', absent: true, message: 'No shop.json: this studio sells nothing (homie-studio shop init writes one).' };
|
|
80
|
-
return { ok: r.ok, command: 'shop check', errors: r.errors, warnings: r.warnings, items: r.shop?.items.map((i) => `${i.id} (${i.kind}, ${i.kind === 'tip' ? 'pay what you want' : money(i.price, r.shop.currency)})`) ?? [], till: r.shop?.till ?? null, why: r.ok ? undefined : `shop.json: ${r.errors.map((e) => `${e.at}: ${e.message}`).join('; ')}` };
|
|
80
|
+
return { ok: r.ok, command: 'shop check', policy: r.shop?.policy, capPerPlayerMonth: r.shop?.capPerPlayerMonth, refundDays: r.shop?.refundDays, errors: r.errors, warnings: r.warnings, items: r.shop?.items.map((i) => `${i.id} (${i.kind}, ${i.kind === 'tip' ? 'pay what you want' : money(i.price, r.shop.currency)})`) ?? [], till: r.shop?.till ?? null, why: r.ok ? undefined : `shop.json: ${r.errors.map((e) => `${e.at}: ${e.message}`).join('; ')}` };
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/** The Supporter pack the kit suggests first: deterministic, never pay-to-win, a badge on the profile and in rooms. */
|
|
@@ -89,11 +89,10 @@ export function shopInit(root, { supporter = false, currency = 'usd', price = 50
|
|
|
89
89
|
const file = join(root, SHOP_FILE);
|
|
90
90
|
if (existsSync(file)) return { ok: false, command: 'shop init', why: 'shop.json is here already: change it, then homie-studio shop check' };
|
|
91
91
|
const studio = readStudio(root);
|
|
92
|
-
if (audienceOf(studio) === 'kids') return { ok: false, command: 'shop init', why: 'studio.json says "audience": "kids": a studio made for children sells nothing in its games' };
|
|
93
92
|
const shop = {
|
|
94
|
-
till: managed ? 'stripe-managed' : 'stripe', currency: String(currency).toLowerCase(),
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
till: managed ? 'stripe-managed' : 'stripe', currency: String(currency).toLowerCase(),
|
|
94
|
+
policy: { preset: 'protective', ...POLICY_PRESETS.protective },
|
|
95
|
+
items: supporter ? [supporterItem({ price: Number(price) })] : [],
|
|
97
96
|
};
|
|
98
97
|
const r = checkShop(shop, { games: listGames(root).map((g) => g.id) });
|
|
99
98
|
if (!r.ok) return { ok: false, command: 'shop init', why: r.errors.map((e) => e.message).join('; ') };
|
|
@@ -124,10 +123,26 @@ export async function shopRefund(root, order, { url, reason, note } = {}) {
|
|
|
124
123
|
return askedFor(root, url, r, 'shop refund');
|
|
125
124
|
}
|
|
126
125
|
|
|
127
|
-
export async function shopStatements(root, { url, period, send = false } = {}) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
126
|
+
export async function shopStatements(root, { url, period, cursor = '', send = false } = {}) {
|
|
127
|
+
if (!send) {
|
|
128
|
+
const r = await withKey(root, url, (call) => call(`/_studio/api/shop/statements?cursor=${encodeURIComponent(cursor)}${period ? `&period=${encodeURIComponent(period)}` : ''}`));
|
|
129
|
+
return r.ok ? { ok: true, command: 'shop statements', period: r.period, nextCursor: r.nextCursor, statements: r.statements } : { ok: false, command: 'shop statements', why: r.message ?? r.why ?? 'the studio did not answer' };
|
|
130
|
+
}
|
|
131
|
+
const sent = [];
|
|
132
|
+
console.error('Sending statements. Keep this command open; run it again after closing to resume safely.');
|
|
133
|
+
return withKey(root, url, async (call) => {
|
|
134
|
+
do {
|
|
135
|
+
const page = await call('/_studio/api/shop/statements/send', { cursor, ...(period ? { period } : {}) });
|
|
136
|
+
if (!page.ok) return { ok: false, command: 'shop statements', sent, why: page.message ?? page.why ?? 'the studio did not answer' };
|
|
137
|
+
for (const failure of page.failures ?? []) if (!sent.some((x) => !x.ok && x.via === failure.via)) { sent.push(failure); console.error(`${failure.via}: failed: ${failure.why ?? failure.status}`); }
|
|
138
|
+
sent.push(...page.sent);
|
|
139
|
+
for (const result of page.sent) console.error(`${result.via}: ${result.ok ? 'page sent' : 'failed: ' + (result.why ?? result.status)}`);
|
|
140
|
+
period = page.period;
|
|
141
|
+
cursor = page.nextCursor;
|
|
142
|
+
if (page.retryAfter) await new Promise((resolve) => setTimeout(resolve, page.retryAfter * 1000));
|
|
143
|
+
} while (cursor !== null);
|
|
144
|
+
return { ok: true, command: 'shop statements', period, nextCursor: null, sent };
|
|
145
|
+
});
|
|
131
146
|
}
|
|
132
147
|
|
|
133
148
|
/* ------------------------------------------------------------------ the key, from a page on this computer */
|
|
@@ -346,15 +361,17 @@ export function shopLines(r) {
|
|
|
346
361
|
} else if (r.command === 'shop check') {
|
|
347
362
|
if (r.absent) lines.push(r.message);
|
|
348
363
|
else {
|
|
349
|
-
lines.push(r.ok ? `shop.json
|
|
364
|
+
lines.push(r.ok ? `shop.json matches the studio settings: ${r.items.join(', ') || 'no items yet'} (till: ${r.till}).` : 'shop.json conflicts with the studio settings or payment requirements:');
|
|
365
|
+
if (r.policy) lines.push(`Studio policy: ${r.policy.preset}; edit policy in shop.json. Monthly cap: ${r.capPerPlayerMonth ?? 'not set'}; refund days: ${r.refundDays ?? 'not set'}.`);
|
|
350
366
|
for (const e of r.errors ?? []) lines.push(` ${e.at}: ${e.message}`);
|
|
351
367
|
for (const w of r.warnings ?? []) lines.push(` note ${w.at}: ${w.message}`);
|
|
352
368
|
}
|
|
353
369
|
} else if (r.command === 'shop init') {
|
|
354
370
|
lines.push(`Wrote ${r.wrote.join(', ')} (till: ${r.till}${r.items.length ? `; ${r.items.join(', ')}` : ''}). Next:`, ...r.next.map((n) => ` ${n}`));
|
|
355
371
|
} else if (r.command === 'shop statements') {
|
|
372
|
+
if (r.nextCursor) lines.push(`More rows: shop statements --period ${r.period} --cursor '${r.nextCursor.replaceAll("'", "'\\''")}'${r.sent ? ' --send' : ''}`);
|
|
356
373
|
if (r.sent) for (const s of r.sent) lines.push(`${s.via}: ${s.ok ? 'sent' : `not sent (${s.why ?? s.status})`}`);
|
|
357
|
-
else for (const s of r.statements ?? []) lines.push(`${s.statement.referrer}: due ${money(s.statement.totals.due, s.statement.currency)} (${s.statement.lines.length} line(s)), signed`);
|
|
374
|
+
else for (const s of r.statements ?? []) lines.push(`${s.statement.referrer}: due ${s.statement.totals ? money(s.statement.totals.due, s.statement.currency) : 'see first page'} (${s.statement.lines.length} line(s)), signed`);
|
|
358
375
|
if (!(r.sent ?? r.statements ?? []).length) lines.push(`No referral statements for ${r.period}.`);
|
|
359
376
|
} else lines.push(r.message ?? '');
|
|
360
377
|
return lines;
|
package/lib/studio.mjs
CHANGED
|
@@ -94,11 +94,32 @@ export function listGames(root) {
|
|
|
94
94
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* RULES ON THE SERVER (NETPLAY.md section 29). A game written as rules plus view says so in game.json with a `"room"`
|
|
99
|
+
* object (where its rules run, its tick rate…; rules/rules.ts `roomSettings` checks them at build) and has a
|
|
100
|
+
* src/rules.ts. Any other game is hosted by a player's browser, as before. The file alone decides nothing: games made
|
|
101
|
+
* before this had their own src/rules.ts, with whatever they liked in it, and they build exactly as they did.
|
|
102
|
+
*/
|
|
103
|
+
export const isRulesGame = (g) => g?.room != null && typeof g.room === 'object' && existsSync(join(g.dir, 'src', 'rules.ts'));
|
|
104
|
+
|
|
105
|
+
const RULES_HEADER = '// Written by `homie-studio build` from this studio\'s games. Do not edit: the next build writes it again.\n';
|
|
106
|
+
/**
|
|
107
|
+
* site/src/rules/index.mjs: the table of this studio's server-hosted games as site/src/worker.mjs imports it (an
|
|
108
|
+
* empty one for a studio with none, which is what a new studio starts with). lib/rules-build.mjs writes the rest.
|
|
109
|
+
*/
|
|
110
|
+
export function rulesIndex(ids = []) {
|
|
111
|
+
const lines = [`${RULES_HEADER}// The rules of this studio's server-hosted games, for the Worker (site/src/worker.mjs hands them to hostRules).`];
|
|
112
|
+
ids.forEach((id, i) => lines.push(`import rules${i} from './${id}.mjs';`, `import data${i} from './${id}.data.mjs';`));
|
|
113
|
+
lines.push(`export default {${ids.map((id, i) => `\n ${JSON.stringify(id)}: { rules: rules${i}, ...data${i} },`).join('')}${ids.length ? '\n' : ''}};`);
|
|
114
|
+
return `${lines.join('\n')}\n`;
|
|
115
|
+
}
|
|
116
|
+
|
|
97
117
|
export function starters() {
|
|
98
118
|
const dir = join(PACKAGE_ROOT, 'starters');
|
|
99
119
|
return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => {
|
|
100
120
|
const meta = JSON.parse(readFileSync(join(dir, d.name, 'game.json'), 'utf8'));
|
|
101
|
-
|
|
121
|
+
// `example`: a starter that shows how something is written (coin-dash: rules on the server) and is not yet offered as the start of a new game.
|
|
122
|
+
return { id: d.name, name: meta.name, blurb: meta.blurb, players: meta.players, roundSeconds: meta.roundSeconds, ...(meta.example === true ? { example: true } : {}) };
|
|
102
123
|
});
|
|
103
124
|
}
|
|
104
125
|
|
|
@@ -1732,6 +1732,96 @@
|
|
|
1732
1732
|
}
|
|
1733
1733
|
}
|
|
1734
1734
|
}
|
|
1735
|
+
},
|
|
1736
|
+
"0.33.0": {
|
|
1737
|
+
"files": {
|
|
1738
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1739
|
+
"README.md": "684b3cca8001673c",
|
|
1740
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
1741
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1742
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1743
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1744
|
+
"posts/README.md": "37841aae476bd540",
|
|
1745
|
+
"site/README.md": "732894273a32dc3f",
|
|
1746
|
+
"site/src/worker.mjs": "fc90f3609cad18ba"
|
|
1747
|
+
},
|
|
1748
|
+
"sections": {
|
|
1749
|
+
"AGENTS.md": {
|
|
1750
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1751
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1752
|
+
"## Commands (all through the pinned CLI in node_modules)": [
|
|
1753
|
+
"29a04ed3d956610a",
|
|
1754
|
+
"a7f290579585368c"
|
|
1755
|
+
],
|
|
1756
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
1757
|
+
"## Making games": "2da7fa74c8884834",
|
|
1758
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1759
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
1760
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
1761
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1762
|
+
"## Selling things (the shop)": [
|
|
1763
|
+
"3173053462311959",
|
|
1764
|
+
"56225d9c6292d975",
|
|
1765
|
+
"b04b0a2b25f524f2"
|
|
1766
|
+
],
|
|
1767
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1768
|
+
"## Rules": "2e6b29afb85dcf7e",
|
|
1769
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
1770
|
+
"## Beta": "9d02638207a26e73"
|
|
1771
|
+
}
|
|
1772
|
+
},
|
|
1773
|
+
"named": {
|
|
1774
|
+
"Homie Arcade": {
|
|
1775
|
+
"sections": {
|
|
1776
|
+
"AGENTS.md": {
|
|
1777
|
+
"## Commands (all through the pinned CLI in node_modules)": [
|
|
1778
|
+
"5d1c068adda1116b",
|
|
1779
|
+
"7cad8fd10f62fef1"
|
|
1780
|
+
]
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
},
|
|
1786
|
+
"0.33.1": {
|
|
1787
|
+
"files": {
|
|
1788
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1789
|
+
"README.md": "684b3cca8001673c",
|
|
1790
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
1791
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1792
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1793
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1794
|
+
"posts/README.md": "37841aae476bd540",
|
|
1795
|
+
"site/README.md": "732894273a32dc3f",
|
|
1796
|
+
"site/src/worker.mjs": "fc90f3609cad18ba"
|
|
1797
|
+
},
|
|
1798
|
+
"sections": {
|
|
1799
|
+
"AGENTS.md": {
|
|
1800
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1801
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1802
|
+
"## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
|
|
1803
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
1804
|
+
"## Making games": "2da7fa74c8884834",
|
|
1805
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1806
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
1807
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
1808
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1809
|
+
"## Selling things (the shop)": "56225d9c6292d975",
|
|
1810
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1811
|
+
"## Rules": "2e6b29afb85dcf7e",
|
|
1812
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
1813
|
+
"## Beta": "9d02638207a26e73"
|
|
1814
|
+
}
|
|
1815
|
+
},
|
|
1816
|
+
"named": {
|
|
1817
|
+
"Homie Arcade": {
|
|
1818
|
+
"sections": {
|
|
1819
|
+
"AGENTS.md": {
|
|
1820
|
+
"## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1735
1825
|
}
|
|
1736
1826
|
}
|
|
1737
1827
|
}
|
package/lib/upgrade.mjs
CHANGED
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* `--diff` prints the difference, so the person can take the template's words by hand.
|
|
16
16
|
*
|
|
17
17
|
* Plus the pin: when this CLI is newer than the version package.json pins (run through
|
|
18
|
-
* `npx -y
|
|
19
|
-
* studio.json's `homie.studio` says the version the studio is on.
|
|
18
|
+
* `npx -y @homie-rocks/studio@<the new version> upgrade`), --apply pins this version (then `npm install`), and
|
|
19
|
+
* studio.json's `homie.studio` says the version the studio is on. The pin it writes is always the npm registry's:
|
|
20
|
+
* a studio made before 0.10.0 pinned an address on homie.rocks, and its first upgrade moves it to the registry.
|
|
20
21
|
*
|
|
21
22
|
* Without --apply it changes nothing and says what --apply would do: the person agrees first. It never touches
|
|
22
23
|
* studio.json beyond `homie.studio`, the studio's look (site/theme.json), its Worker config (deploy's), its games,
|
|
@@ -43,7 +44,7 @@ import { STUDIO_VERSION, packageSpec } from './version.mjs';
|
|
|
43
44
|
/** Whole files the template writes and a studio may keep as they are: updated only while the studio never changed them. */
|
|
44
45
|
export const TEMPLATE_FILES = ['AGENTS.md', 'CLAUDE.md', 'README.md', 'HANDOFF.md', 'games/README.md', 'music/README.md', 'videos/README.md', 'posts/README.md', 'site/README.md', 'site/src/worker.mjs'];
|
|
45
46
|
/** Files the template adds when missing and never changes once they exist (a migration is applied; a manifest is the studio's). */
|
|
46
|
-
export const ADD_ONLY = ['music/manifest.json', 'videos/manifest.json', '.claude/skills/.gitkeep'];
|
|
47
|
+
export const ADD_ONLY = ['music/manifest.json', 'videos/manifest.json', '.claude/skills/.gitkeep', 'site/src/rules/index.mjs'];
|
|
47
48
|
const MIGRATIONS = /^site\/migrations\/[^/]+\.sql$/;
|
|
48
49
|
/** The name and slug stand-ins the history is fingerprinted with. */
|
|
49
50
|
export const STAND_IN = { name: '{{studio.name}}', slug: '{{studio.slug}}' };
|
|
@@ -124,19 +125,21 @@ function known(history, who = {}) {
|
|
|
124
125
|
const semver = (v) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(String(v ?? '')) ?? []).slice(1).map(Number);
|
|
125
126
|
const newer = (a, b) => { const x = semver(a); const y = semver(b); for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0); return false; };
|
|
126
127
|
|
|
127
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* The version in the address a studio made before 0.10.0 pinned (https://<directory>/npm/homie-studio-<version>.tgz),
|
|
130
|
+
* or null. Read for one thing only: so that studio's upgrade knows which version it is leaving. Nothing writes
|
|
131
|
+
* such an address; its upgrade writes the npm registry's exact version.
|
|
132
|
+
*/
|
|
133
|
+
const addressPin = (spec) => /^https?:\/\/.+\/homie-studio-(\d+\.\d+\.\d+)\.tgz(?:[?#].*)?$/.exec(String(spec ?? ''))?.[1] ?? null;
|
|
134
|
+
|
|
135
|
+
/** The @homie-rocks/studio version a package.json spec pins: an exact version, npm:… of one, or an older studio's address. */
|
|
128
136
|
export function pinnedVersion(spec) {
|
|
129
137
|
const s = String(spec ?? '');
|
|
130
|
-
return
|
|
138
|
+
return /^(?:npm:@homie-rocks\/studio@)?=?(\d+\.\d+\.\d+)$/.exec(s)?.[1] ?? addressPin(s);
|
|
131
139
|
}
|
|
132
|
-
/** The
|
|
133
|
-
function specFor(spec
|
|
134
|
-
|
|
135
|
-
const tar = /^(https?:\/\/.+\/)homie-studio-\d+\.\d+\.\d+\.tgz$/.exec(s);
|
|
136
|
-
if (tar) return `${tar[1]}homie-studio-${STUDIO_VERSION}.tgz`;
|
|
137
|
-
if (/^npm:@homie-rocks\/studio@/.test(s)) return `npm:@homie-rocks/studio@${STUDIO_VERSION}`;
|
|
138
|
-
if (/^=?\d+\.\d+\.\d+$/.test(s)) return STUDIO_VERSION;
|
|
139
|
-
return packageSpec(directory);
|
|
140
|
+
/** The pin for this version: the npm registry's exact version (an npm: alias stays an alias). */
|
|
141
|
+
function specFor(spec) {
|
|
142
|
+
return /^npm:@homie-rocks\/studio@/.test(String(spec ?? '')) ? `npm:@homie-rocks/studio@${STUDIO_VERSION}` : packageSpec();
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
/** A line diff (the longest common run kept), for the plan: ` ` same, `- ` the studio's, `+ ` the template's. */
|
|
@@ -183,8 +186,9 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
|
|
|
183
186
|
if (pinned && newer(pinned, STUDIO_VERSION)) {
|
|
184
187
|
return { ok: false, command: 'upgrade', from: pinned, to: STUDIO_VERSION, why: `this studio pins @homie-rocks/studio ${pinned}, newer than this ${STUDIO_VERSION}: run its own copy (npx --no-install homie-studio upgrade)` };
|
|
185
188
|
}
|
|
186
|
-
|
|
187
|
-
|
|
189
|
+
// An older studio's address is moved to the registry even when it names this very version: it is no longer served.
|
|
190
|
+
if (pkg && where && pinned && (newer(STUDIO_VERSION, pinned) || addressPin(spec))) {
|
|
191
|
+
changes.push({ file: 'package.json', kind: 'pin', what: `pin @homie-rocks/studio ${STUDIO_VERSION}${addressPin(spec) ? ' from the npm registry' : ''} (was ${pinned}${addressPin(spec) ? ', at an address that is no longer served' : ''}); then npm install`, from: spec, to: specFor(spec) });
|
|
188
192
|
} else if (pkg && where && !pinned) {
|
|
189
193
|
kept.push({ file: 'package.json', what: `@homie-rocks/studio is "${String(spec).slice(0, 80)}" (not a published version): left as it is` });
|
|
190
194
|
}
|
package/lib/version.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The one place @homie-rocks/studio says which version it is and where that exact
|
|
3
|
-
* version lives. A studio pins this
|
|
3
|
+
* version lives. A studio pins this version (package.json + package-lock
|
|
4
4
|
* integrity), so a published game never changes because Homie changed.
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync } from 'node:fs';
|
|
@@ -8,14 +8,9 @@ import { readFileSync } from 'node:fs';
|
|
|
8
8
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
9
9
|
export const STUDIO_VERSION = pkg.version;
|
|
10
10
|
|
|
11
|
-
/** The pinned tarball of this version, on a Homie directory (homie.rocks by default). Kept for older studios. */
|
|
12
|
-
export function tarballUrl(base = 'https://homie.rocks', version = STUDIO_VERSION) {
|
|
13
|
-
return `${String(base).replace(/\/+$/, '')}/npm/homie-studio-${version}.tgz`;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
11
|
/**
|
|
17
12
|
* What a studio's package.json names: this exact version, from registry.npmjs.org (package-lock.json keeps its
|
|
18
13
|
* integrity). The public registry is what Cloudflare's Workers Builds and a Claude Code cloud session reach with
|
|
19
|
-
* their default network settings
|
|
14
|
+
* their default network settings.
|
|
20
15
|
*/
|
|
21
16
|
export function packageSpec() { return STUDIO_VERSION; }
|