@homie-rocks/studio 0.51.2 → 0.51.3
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 +25 -0
- package/app-starters/gathering/app.json +2 -44
- package/app-starters/gathering/index.html +1 -1
- package/app-starters/gathering/src/view.ts +6 -19
- package/apps/APPS.md +55 -1
- package/bin/homie-studio.mjs +5 -1
- package/guides/app/SKILL.md +8 -2
- package/guides/shop/SKILL.md +8 -0
- package/lib/app-needs.mjs +17 -2
- package/lib/app-ready.mjs +1 -0
- package/lib/art-cli.mjs +2 -2
- package/lib/art-tools.mjs +2 -2
- package/lib/check.mjs +2 -0
- package/lib/decisions.mjs +9 -5
- package/lib/dev.mjs +3 -0
- package/lib/shop-links.mjs +4 -2
- package/lib/shop-local.mjs +31 -0
- package/lib/style-presets.mjs +1 -0
- package/lib/template-history.json +42 -0
- package/package.json +1 -1
- package/shop/SHOP.md +6 -0
- package/worker/app-records.mjs +3 -3
- package/worker/arrival.mjs +5 -4
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +7 -0
- package/worker/pages.mjs +9 -3
- package/worker/shop-page.mjs +2 -2
- package/worker/shop-subscriptions.mjs +42 -0
- package/worker/site.mjs +9 -7
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,31 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.51.3] - 2026-10-14
|
|
21
|
+
|
|
22
|
+
**Plugin 0.51.3**
|
|
23
|
+
|
|
24
|
+
Business and cause apps use their own words, count every paid monthly gift, and test real Stripe checkout locally.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Local real Stripe test checkout: `shop connect --local --profile <sandbox> --url <Wi-Fi address>` syncs test Payment Links without Cloudflare or a public webhook. Stripe CLI forwards signed events to `dev`; phones return to the same Wi-Fi thank-you page.
|
|
29
|
+
- App acceptance needs can name a checkout popup, interact with it, and continue asserting the original phone.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Monthly subscription invoices create deduplicated paid orders and app events with the original reference, so every gift reaches fulfilment.
|
|
34
|
+
- Business/cause menus name their own pages and apps. App shells use app names, website buttons have independent wording, thank-you pages name the organisation, and default phone sharing stays above bottom tabs.
|
|
35
|
+
- Gathering is a task check-in starter with its own records and no giving or quiz workflow. App style defaults use illustration and AI refinements retain AI attribution.
|
|
36
|
+
- `dev` loads root `.dev.vars`. App collections no longer stop accepting records at 1,000.
|
|
37
|
+
- Requests and scheduled runs remove referral metadata recreated by an old Worker during a rolling deployment.
|
|
38
|
+
- The installed app reference documents the supported local payment/checker path and distinguishes local MCP tools from hosted connector tools.
|
|
39
|
+
|
|
40
|
+
### Upgrade notes
|
|
41
|
+
|
|
42
|
+
- Restart local dev after updating `.dev.vars`. Local Stripe links are test-only; live connection and deployment are unchanged.
|
|
43
|
+
- Existing app `screen.share` choices and authored site headers remain in control. Subscription renewals now emit `order.paid` as well as lifecycle events; fulfilment listening to both should deduplicate by payment identity.
|
|
44
|
+
|
|
20
45
|
## [0.51.2] - 2026-10-14
|
|
21
46
|
|
|
22
47
|
**Plugin 0.51.2** · Deploying and listing do what the studio chose, under one address.
|
|
@@ -39,14 +39,8 @@
|
|
|
39
39
|
"many": "people",
|
|
40
40
|
"action": "Check in",
|
|
41
41
|
"total": "Check-ins",
|
|
42
|
-
"choice": "Choose
|
|
43
|
-
"monthly": "Gift frequency",
|
|
44
|
-
"once": "One time",
|
|
45
|
-
"month": "Monthly",
|
|
46
|
-
"give": "Give securely on Stripe",
|
|
47
|
-
"goal": "Intention goal \u00b7 not payments received",
|
|
42
|
+
"choice": "Choose a task",
|
|
48
43
|
"prompt": "Choose what works for you.",
|
|
49
|
-
"finished": "All three answers are in. Thank you!",
|
|
50
44
|
"complete": "Mark complete",
|
|
51
45
|
"done": "Complete",
|
|
52
46
|
"pending": "Checked in",
|
|
@@ -68,14 +62,6 @@
|
|
|
68
62
|
"type": "string",
|
|
69
63
|
"required": true,
|
|
70
64
|
"maxLength": 60
|
|
71
|
-
},
|
|
72
|
-
"amount": {
|
|
73
|
-
"type": "number",
|
|
74
|
-
"required": true
|
|
75
|
-
},
|
|
76
|
-
"monthly": {
|
|
77
|
-
"type": "boolean",
|
|
78
|
-
"required": true
|
|
79
65
|
}
|
|
80
66
|
}
|
|
81
67
|
}
|
|
@@ -129,34 +115,6 @@
|
|
|
129
115
|
"Packing",
|
|
130
116
|
"Deliveries"
|
|
131
117
|
],
|
|
132
|
-
"color": "#184b43"
|
|
133
|
-
"currency": "CAD",
|
|
134
|
-
"goal": 2500,
|
|
135
|
-
"questions": [
|
|
136
|
-
{
|
|
137
|
-
"text": "Which planet is called the red planet?",
|
|
138
|
-
"choices": [
|
|
139
|
-
"Venus",
|
|
140
|
-
"Mars",
|
|
141
|
-
"Jupiter"
|
|
142
|
-
]
|
|
143
|
-
},
|
|
144
|
-
{
|
|
145
|
-
"text": "How many sides does a hexagon have?",
|
|
146
|
-
"choices": [
|
|
147
|
-
"5",
|
|
148
|
-
"6",
|
|
149
|
-
"8"
|
|
150
|
-
]
|
|
151
|
-
},
|
|
152
|
-
{
|
|
153
|
-
"text": "Which is a mammal?",
|
|
154
|
-
"choices": [
|
|
155
|
-
"Dolphin",
|
|
156
|
-
"Trout",
|
|
157
|
-
"Octopus"
|
|
158
|
-
]
|
|
159
|
-
}
|
|
160
|
-
]
|
|
118
|
+
"color": "#184b43"
|
|
161
119
|
}
|
|
162
120
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Live together</title><style>
|
|
2
2
|
*{box-sizing:border-box}body{margin:0;background:var(--tone,#183b55);color:#fff7df;font-family:system-ui,sans-serif}main{min-height:100svh;padding:clamp(24px,5vw,64px);display:grid;grid-template-columns:1.1fr 1fr;gap:40px;align-items:center}h1{font-size:clamp(44px,6vw,88px);line-height:1;letter-spacing:-.05em;margin:20px 0}p{line-height:1.6;max-width:40em}#connection{font-size:12px;text-transform:uppercase;letter-spacing:.18em}.panel{background:#fff7df;color:#20352e;border-radius:28px;padding:28px;box-shadow:0 30px 80px #0003}#count{font-size:80px;font-weight:800;line-height:1}ul{padding:0;list-style:none}li{padding:12px 0;border-bottom:1px solid #20352e22}button,select,a{font:inherit;min-height:48px;border-radius:12px}button,select{padding:10px 16px;width:100%;margin-top:8px}button{background:var(--tone);color:#fff7df;border:0;cursor:pointer}button:disabled{opacity:.5}a{display:inline-flex;align-items:center;color:inherit}label{display:block;margin-top:14px}select{background:white;color:#20352e;border:1px solid #20352e66}progress{width:100%;height:22px;accent-color:var(--tone)}#status{min-height:24px}#amount{font-size:40px;font-weight:700}button:focus-visible,select:focus-visible,a:focus-visible{outline:3px solid #df9325;outline-offset:4px}.wall #controls{display:none}#giving-link[hidden],[hidden]{display:none!important}@media(max-width:650px){main{display:block;padding:72px 22px 24px}h1{font-size:46px}.panel{margin-top:24px;padding:22px}#count{font-size:58px}li{font-size:14px}}@media(max-height:520px) and (min-width:651px){main{padding:65px 25px 20px;gap:22px}h1{font-size:38px}.panel{padding:18px}}
|
|
3
|
-
</style><main><header><span id="connection"></span><h1></h1><p id="intro"></p><
|
|
3
|
+
</style><main><header><span id="connection"></span><h1></h1><p id="intro"></p><ul id="entries" aria-live="polite"></ul></header><section class="panel"><div id="label"></div><div id="count" data-count>0</div><div id="controls"><p id="prompt"></p><label><span id="choice-label"></span><select id="choice"></select></label><button data-action></button></div><p id="status" role="status"></p></section></main><script type="module" src="./assets/main.js"></script></html>
|
|
@@ -3,7 +3,7 @@ import { openRoom } from '@homie-rocks/studio/rules/view';
|
|
|
3
3
|
import type rules from './rules';
|
|
4
4
|
import { createAppRecords, type AppRecord } from '@homie-rocks/studio/apps';
|
|
5
5
|
import { randomId } from '@homie-rocks/studio/links';
|
|
6
|
-
type Entry = { choice: string; status: string
|
|
6
|
+
type Entry = { choice: string; status: string };
|
|
7
7
|
const room = openRoom<typeof rules>();
|
|
8
8
|
const net = room.net;
|
|
9
9
|
const role = net.params.role ?? 'customer';
|
|
@@ -11,41 +11,29 @@ const words: Record<string, string> = app.words;
|
|
|
11
11
|
const config = app.starter;
|
|
12
12
|
const $ = <T extends HTMLElement = HTMLElement>(s: string) => document.querySelector<T>(s)!;
|
|
13
13
|
const records = createAppRecords<Entry>({ collection: 'entries' });
|
|
14
|
-
let rows: AppRecord<Entry>[] = [], busy = false, ready = false
|
|
14
|
+
let rows: AppRecord<Entry>[] = [], busy = false, ready = false;
|
|
15
15
|
const action = $<HTMLButtonElement>('[data-action]');
|
|
16
16
|
document.body.className = role;
|
|
17
17
|
document.body.style.setProperty('--tone', config.color);
|
|
18
18
|
$('h1').textContent = app.name; $('#intro').textContent = app.blurb;
|
|
19
19
|
$('#label').textContent = words.total; action.textContent = words.action;
|
|
20
20
|
$('#choice-label').textContent = words.choice;
|
|
21
|
-
$('#frequency-label').textContent = words.monthly;
|
|
22
|
-
$('#once').textContent = words.once; $('#month').textContent = words.month;
|
|
23
|
-
$('#giving-link').textContent = words.give;
|
|
24
|
-
$('#giving-link').hidden = config.kind !== 'giving';
|
|
25
|
-
$('#frequency').hidden = config.kind !== 'giving';
|
|
26
|
-
$('#goal').hidden = config.kind !== 'giving';
|
|
27
21
|
$('#controls').hidden = role === 'wall';
|
|
28
22
|
const select = $<HTMLSelectElement>('#choice');
|
|
29
23
|
function choices() {
|
|
30
24
|
select.replaceChildren();
|
|
31
|
-
const list = config.
|
|
25
|
+
const list = config.choices;
|
|
32
26
|
for (const value of list) { const option = document.createElement('option'); option.value = value; option.textContent = value; select.append(option); }
|
|
33
|
-
$('#prompt').textContent =
|
|
34
|
-
action.hidden = config.kind === 'quiz' && question >= config.questions.length;
|
|
27
|
+
$('#prompt').textContent = words.prompt;
|
|
35
28
|
}
|
|
36
29
|
choices();
|
|
37
30
|
function paint() {
|
|
38
31
|
$('[data-count]').textContent = String(rows.length);
|
|
39
|
-
const amount = rows.reduce((n, r) => n + r.data.amount, 0);
|
|
40
|
-
$('#amount').textContent = new Intl.NumberFormat(undefined, { style: 'currency', currency: config.currency }).format(amount);
|
|
41
|
-
$('#target').textContent = new Intl.NumberFormat(undefined, { style: 'currency', currency: config.currency }).format(config.goal);
|
|
42
|
-
$<HTMLProgressElement>('progress').max = config.goal; $<HTMLProgressElement>('progress').value = amount;
|
|
43
|
-
$('#goal-label').textContent = words.goal;
|
|
44
32
|
action.disabled = busy || !ready;
|
|
45
33
|
$('#entries').replaceChildren();
|
|
46
34
|
for (const row of rows.slice(-6).reverse()) {
|
|
47
35
|
const li = document.createElement('li');
|
|
48
|
-
li.textContent = `${row.data.choice} · ${row.data.status}
|
|
36
|
+
li.textContent = `${row.data.choice} · ${row.data.status}`;
|
|
49
37
|
if (role === 'staff' && row.data.status !== words.done) {
|
|
50
38
|
const button = document.createElement('button'); button.textContent = words.complete;
|
|
51
39
|
button.onclick = () => { void records.update(row, { ...row.data, status: words.done }).then(refresh).catch(e => { $('#status').textContent = e.message; }); }; li.append(button);
|
|
@@ -61,9 +49,8 @@ action.onclick = async () => {
|
|
|
61
49
|
busy = true; paint();
|
|
62
50
|
try {
|
|
63
51
|
const choice = select.value;
|
|
64
|
-
await records.create({ choice
|
|
52
|
+
await records.create({ choice, status: words.pending }, randomId());
|
|
65
53
|
$('#status').textContent = words.thanks;
|
|
66
|
-
if (config.kind === 'quiz') { question++; choices(); }
|
|
67
54
|
room.command('refresh', {}); await refresh();
|
|
68
55
|
} catch (error) { $('#status').textContent = (error as Error).message; }
|
|
69
56
|
finally { busy = false; paint(); }
|
package/apps/APPS.md
CHANGED
|
@@ -18,7 +18,7 @@ await queue.remove(rows[0]);
|
|
|
18
18
|
queue.dispose();
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
The shell handles requests only from its own frame and uses its own role, link and account, never authority claimed by the message. The Worker checks every request. Roles declare `signIn: boolean` and `can: ["read:queue", "create:queue", "update:queue", "delete:queue"]`. Collections declare fields with `type` (string/number/boolean), optional `required`, `maxLength`, `enum`, and `initial` (the only value allowed on create). Unknown fields are refused. Records are at most 8 KB
|
|
21
|
+
The shell handles requests only from its own frame and uses its own role, link and account, never authority claimed by the message. The Worker checks every request. Roles declare `signIn: boolean` and `can: ["read:queue", "create:queue", "update:queue", "delete:queue"]`. Collections declare fields with `type` (string/number/boolean), optional `required`, `maxLength`, `enum`, and `initial` (the only value allowed on create). Unknown fields are refused. Records are at most 8 KB and request bodies at most 12 KB. Homie imposes no collection count limit; the studio’s database capacity applies.
|
|
22
22
|
|
|
23
23
|
Generate a fresh ID for every new record; never reuse a deleted ID. Updates and deletes compare `version` atomically. Conflicts return 409; reload and resolve them. A successful write is committed before acknowledgement. Records are scoped by app and collection, not by room. Deleting a room does not delete records. With `records.persist: false`, use netplay keyed state for transient data instead. Database failures are visible and never replaced with fabricated local success.
|
|
24
24
|
|
|
@@ -127,3 +127,57 @@ Use the Manage my gifts link for the buyer-owned Stripe portal in the shop.
|
|
|
127
127
|
Records-only apps support the same shop bridge as room apps. In the sandboxed
|
|
128
128
|
view, use `createShop({target: parent})` to send checkout and booking requests to
|
|
129
129
|
the authenticated outer shell; a room simulation is not required for payments.
|
|
130
|
+
|
|
131
|
+
## Real Stripe test checkout on this computer
|
|
132
|
+
|
|
133
|
+
Use the installed toolkit's app guide and this reference for its exact version.
|
|
134
|
+
The local toolkit MCP exposes `parts_find`; the hosted directory connector may not.
|
|
135
|
+
With a shell, `homie-studio parts find "giving"` is the supported equivalent. Read a
|
|
136
|
+
part's contract: a pledge-only example is not payment fulfilment. The giving starter
|
|
137
|
+
uses the shop bridge; `shop.catalog()` is available in this version. Field names
|
|
138
|
+
start with a lowercase letter and continue with lowercase letters, digits or `_`.
|
|
139
|
+
|
|
140
|
+
No public HTTPS address or Cloudflare account is needed for local Stripe test
|
|
141
|
+
payments. Use the owner's existing Stripe CLI sandbox profile; log in only when
|
|
142
|
+
that profile is unavailable or expired. Never create an imitation checkout.
|
|
143
|
+
|
|
144
|
+
1. Create the app and `shop init --giving --currency CAD` (choose the actual currency).
|
|
145
|
+
2. Choose the computer's Wi-Fi address and a fixed dev port. Run
|
|
146
|
+
`homie-studio shop connect --local --profile <sandbox-profile> --url http://192.168.1.8:8787`.
|
|
147
|
+
This syncs real test Products, Prices and Payment Links through Stripe CLI and
|
|
148
|
+
saves local bindings in the project's `.dev.vars`; it does not deploy or create
|
|
149
|
+
a public webhook. Local products use a separate identity from deployed products.
|
|
150
|
+
3. Run `stripe --project-name <sandbox-profile> listen --all-snapshot --forward-to http://localhost:8787/api/shop/hook`.
|
|
151
|
+
Put its signing secret in root `.dev.vars` as `STRIPE_WEBHOOK_SECRET=whsec_…`.
|
|
152
|
+
Keep that listener running. Never commit the secrets file or print its contents.
|
|
153
|
+
4. Run `homie-studio dev --lan --port 8787`. Dev loads root `.dev.vars` beside its
|
|
154
|
+
generated Wrangler configuration. Restart after changing bindings. If dev reports
|
|
155
|
+
another port, update the listener and repeat local shop connection with that port.
|
|
156
|
+
5. Open **the same Wi-Fi address** on desktop and phones. Checkout returns there,
|
|
157
|
+
so its guest cookie and thank-you page work on both. Localhost is only suitable
|
|
158
|
+
when every browser is on this computer. Use Stripe's test card; verify the signed
|
|
159
|
+
webhook, paid order and app record before reporting success.
|
|
160
|
+
|
|
161
|
+
The first Checkout emits `order.paid`. Later paid subscription-cycle invoices each
|
|
162
|
+
create a distinct paid order and emit `order.paid` with the original app and reference.
|
|
163
|
+
Invoice retries do not count again; the initial invoice does not duplicate Checkout.
|
|
164
|
+
Keep fulfilment idempotent by order/event ID. Subscription lifecycle events remain
|
|
165
|
+
available separately. Failed invoices never create paid gifts.
|
|
166
|
+
|
|
167
|
+
`check` uses isolated records on localhost by default; `--no-isolate` opts into the
|
|
168
|
+
existing records. Stripe webhooks use the ordinary local database, so for a real
|
|
169
|
+
payment acceptance run use a disposable studio and `check --no-isolate`. Test money
|
|
170
|
+
must not be mistaken for live giving. Named staff/tablet/kiosk surfaces and roles
|
|
171
|
+
are supported; only the surfaces named by each need are asserted.
|
|
172
|
+
|
|
173
|
+
For a Give button opening a new tab, name its popup in the click step:
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{"surface":"phone","selector":"[data-give]","click":true,"popup":"checkout"}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Later steps and assertions can use `"surface":"checkout"` for the provider page.
|
|
180
|
+
The phone remains its own surface; assert its confirmed gift after checkout. The
|
|
181
|
+
checker brings each page forward before reading or capturing it. Use the real
|
|
182
|
+
provider's controls, never a test-only app action. App sharing defaults to the top
|
|
183
|
+
right, clear of bottom tabs; `screen.share` remains an author-controlled override.
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -846,7 +846,7 @@ const OUTWARD_FLAGS = {
|
|
|
846
846
|
'chat remove': ['url', 'all'], 'chat budget': ['url'],
|
|
847
847
|
'servers close': ['url', 'reopen'], 'servers level': ['url'], 'servers member': ['url', 'role', 'remove'],
|
|
848
848
|
'parts reissue': ['claim-hash', 'receipt-verified', 'buyer'], 'parts retire': [], 'parts keys': [], 'parts refund': ['game'],
|
|
849
|
-
'shop connect': ['url', 'managed', 'live', 'manual', 'renew', 'from-clipboard'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note', 'manual-transaction'],
|
|
849
|
+
'shop connect': ['local', 'profile', 'url', 'managed', 'live', 'manual', 'renew', 'from-clipboard'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note', 'manual-transaction'],
|
|
850
850
|
'agents pass': ['url', 'label', 'server', 'hands', 'role', 'days'], 'agents revoke': ['url'], 'agents brain': ['url', 'budget', 'remove'],
|
|
851
851
|
'agents sit': ['url', 'server', 'pass', 'label', 'brain'],
|
|
852
852
|
'events webhook': ['url','after'],
|
|
@@ -1115,6 +1115,10 @@ async function main() {
|
|
|
1115
1115
|
if (!sub) return shopStatus(root, { url });
|
|
1116
1116
|
if (sub === 'init') return shopInit(root, { supporter: flags.has('supporter'), giving: flags.has('giving'), currency: flags.get('currency') ?? null, price: flags.get('price') ?? 500, managed: flags.has('managed') });
|
|
1117
1117
|
if (sub === 'check') return shopCheck(root);
|
|
1118
|
+
if (sub === 'connect' && flags.has('local')) {
|
|
1119
|
+
if(flags.has('live'))throw new Error('Local checkout requires test mode');
|
|
1120
|
+
return (await import('../lib/shop-local.mjs')).shopConnectLocal(root,{url:flags.get('url'),profile:flags.get('profile')});
|
|
1121
|
+
}
|
|
1118
1122
|
if (sub === 'connect') return shopConnect(root, { managed: flags.has('managed') ? true : null, live: flags.has('live'), manual: flags.has('manual'), fromClipboard: flags.has('from-clipboard'), renew: flags.has('renew'), log: (line) => process.stderr.write(`${line}\n`) });
|
|
1119
1123
|
if (sub === 'disconnect') return shopDisconnect(root);
|
|
1120
1124
|
if (sub === 'catalog') {
|
package/guides/app/SKILL.md
CHANGED
|
@@ -6,6 +6,12 @@ compatibility: Node 22 and Chrome; the studio's pinned toolkit and Wrangler.
|
|
|
6
6
|
|
|
7
7
|
# Make an app
|
|
8
8
|
|
|
9
|
+
For the installed version’s payment, records and local-check contracts, start with
|
|
10
|
+
`node_modules/@homie-rocks/studio/apps/APPS.md`. Its “Real Stripe test checkout on
|
|
11
|
+
this computer” section is the supported offline-hosting path: real Stripe test
|
|
12
|
+
checkout, CLI webhook forwarding, root `.dev.vars`, Wi-Fi return pages and named
|
|
13
|
+
checkout popups. Use an existing approved sandbox profile; no deploy is needed.
|
|
14
|
+
|
|
9
15
|
An app is **one screen, its roles, and its parts**. The screen transforms with the person and the work: a camera moves, a ticket unfolds, controls appear in context. Do not build page-to-page navigation, a sidebar of links, or a conventional admin website inside it. A studio's public website can still have pages around the app.
|
|
10
16
|
|
|
11
17
|
Read the person's brief and existing `apps/<id>/` first. Choose sensible engineering defaults from the brief; never ask the person how to implement shared state. A business or cause needs no game demo, mandatory entertainment, invented customers, scores or rounds. Never impose a shop, directory listing, sign-in for public visitors, or paid media. Make the fewest human steps possible; run commands yourself.
|
|
@@ -38,7 +44,7 @@ Before writing scene components:
|
|
|
38
44
|
|
|
39
45
|
1. Run `style init <id> --prompt "<the person's own scene words>"`. Record the direction in the Codex: composition, camera, materials, colour, typography and motion. Use the same style machinery as games, not the starter's default theme.
|
|
40
46
|
2. Inspect the installed `@homie-rocks/*` packages for mechanisms. `geom`, `props`, `render`, `camera`, `postfx` and `fx` can make the place; `input`, `ui`, `ui-world`, `device`, `audio` and `loop` make it usable. Read the package exports and examples rather than guessing imports. Install every dependency reported by the starter or parts before building.
|
|
41
|
-
3. Search `parts_find` for each meaningful piece; shell equivalent: `homie-studio parts find "<piece>"`. Read its contract, source studio and licence. Add fitting pieces with `part_add` / `parts add`; write the missing parts yourself. The example studios share real app pieces: search for venue, workshop, giving, arrivals, scene, queue and sound, not just game characters. If local example studios are available, read their shared `parts/` and manifests without modifying the source studios. A failed catalogue request is not proof that nothing exists.
|
|
47
|
+
3. Search the local toolkit MCP `parts_find` for each meaningful piece (the hosted connector may not expose it); shell equivalent: `homie-studio parts find "<piece>"`. Read its contract, source studio and licence. Add fitting pieces with `part_add` / `parts add`; write the missing parts yourself. The example studios share real app pieces: search for venue, workshop, giving, arrivals, scene, queue and sound, not just game characters. If local example studios are available, read their shared `parts/` and manifests without modifying the source studios. A failed catalogue request is not proof that nothing exists.
|
|
42
48
|
4. Use the art/models/sound/music skills with connected providers and an authorized budget. Generate distinctive assets when useful. If no provider is connected, continue with original geometry, locally drawn SVG, CC0 assets and local synthesized sound. Report the means honestly. Provider absence never justifies replacing the scene with a board.
|
|
43
49
|
5. Record packages, parts with attribution, original work and media routes under **Built from**. Keep costs and provenance visible.
|
|
44
50
|
|
|
@@ -124,7 +130,7 @@ Without Stripe, keep real services and prices visible and say payment is not con
|
|
|
124
130
|
- `apps/<id>/app.json` describes roles (`signIn`, `can`), surfaces (`phone`, `wall`, `kiosk`, `tablet`), wording, record collections, parts and the real UI action used by check. The app's entry and assets live beside it, just like a game.
|
|
125
131
|
- Before inventing each component, inspect engine packages for the mechanism and `parts_find` for the piece. A queue, quiz, menu board, live total, music loop or video intro can be a part. Record its licence and credit. Sharing happens only when requested; selling parts is not part of this workflow.
|
|
126
132
|
- Use the same engine packages as a game: `@homie-rocks/geom` and `props` for original scenery; `render`, `camera`, `postfx`, `fx` for light, composition and motion; `input`, `ui`, `ui-world`, `audio`, `device`, `loop` for interaction and feel. An app can be fully 3D and wild. Legibility and the actual task come first.
|
|
127
|
-
- Use the `style`, `art`, `models`, `animate`, `sound`, `music`, `video` and `perf` skills where they help.
|
|
133
|
+
- Use the `style`, `art`, `models`, `animate`, `sound`, `music`, `video` and `perf` skills where they help. Give the organisation a coherent scene with readable controls and purposeful motion. Media cost and licensing decisions remain the person's.
|
|
128
134
|
- Keep native buttons at least 44 px, keyboard focus visible, text as text, motion reducible, and the wall QR clear of useful information. Pointer gesture guards belong on the canvas, not native click buttons.
|
|
129
135
|
|
|
130
136
|
## Roles and records
|
package/guides/shop/SKILL.md
CHANGED
|
@@ -173,3 +173,11 @@ CLI fixture/API tokens; never enter card digits into a page. The local listener
|
|
|
173
173
|
for development; production sends signed events directly to the studio's webhook.
|
|
174
174
|
The optional keyed fixture remains available for testing explicitly selected session
|
|
175
175
|
checkout. Label offline fixtures simulations.
|
|
176
|
+
|
|
177
|
+
## Local Stripe test payments
|
|
178
|
+
|
|
179
|
+
Use `shop connect --local --profile <sandbox-profile> --url http://<Wi-Fi-IP>:8787`
|
|
180
|
+
and Stripe CLI `listen --all-snapshot --forward-to http://localhost:8787/api/shop/hook`. The full
|
|
181
|
+
recipe is in the installed toolkit’s `apps/APPS.md`, “Real Stripe test checkout on
|
|
182
|
+
this computer”. It uses root `.dev.vars`, needs no public HTTPS or Cloudflare, and
|
|
183
|
+
keeps the existing approved Stripe CLI session. Do not write a stand-in checkout.
|
package/lib/app-needs.mjs
CHANGED
|
@@ -24,6 +24,8 @@ export function readAppNeeds(root, id) {
|
|
|
24
24
|
/** Run real UI actions. A failed or absent flow stays visible; continue collecting other needs. */
|
|
25
25
|
export async function proveAppNeeds({ plan, surfaces, shots, report, ready = async () => {} }) {
|
|
26
26
|
const results = [];
|
|
27
|
+
const appBarrier=ready;
|
|
28
|
+
ready=async s=>{await s.page.bringToFront?.();if(s.external){s.frame=s.page.mainFrame();return;}await appBarrier(s);};
|
|
27
29
|
let activeRole;
|
|
28
30
|
const surface = name => { const s = surfaces.find(s => s.name === name && (!activeRole || s.role === activeRole)) ?? surfaces.find(s => s.name === name); if (!s) throw new Error(`unknown surface ${name}`); return s; };
|
|
29
31
|
for (const need of plan.needs) {
|
|
@@ -33,7 +35,8 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
33
35
|
const result = { id: need.id, statement: need.statement, status: 'not', captures: [] };
|
|
34
36
|
try {
|
|
35
37
|
if (!Array.isArray(need.assertions) || !need.assertions.length) throw new Error('need requires assertions on its named surfaces');
|
|
36
|
-
|
|
38
|
+
const popupNames=new Set((need.steps??[]).map(s=>s.popup).filter(Boolean));
|
|
39
|
+
for (const name of names) { if(popupNames.has(name))continue; const s = surface(name); if (need.role && s.role !== need.role) throw new Error(`${name} requires role ${need.role}, got ${s.role ?? 'unspecified'}`); }
|
|
37
40
|
for (const name of new Set(need.assertions.filter(a => a.effect).map(a => a.surface))) await surface(name).frame.evaluate(() => { window.__homieNeedsEffects = []; });
|
|
38
41
|
for (const step of need.steps ?? []) {
|
|
39
42
|
const target=surface(step.surface);
|
|
@@ -43,7 +46,19 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
43
46
|
if (step.reload) { await page.reload({ waitUntil: 'domcontentloaded' }); await ready(surface(step.surface)); continue; }
|
|
44
47
|
await waitInApp(target,step.selector,f=>f.waitForSelector(step.selector, { visible: true, timeout:10000 }),{ready});
|
|
45
48
|
const frame=target.frame;
|
|
46
|
-
if (step.click
|
|
49
|
+
if (step.click && step.popup) {
|
|
50
|
+
let listener,timer;
|
|
51
|
+
const opened=new Promise((resolve,reject)=>{
|
|
52
|
+
listener=popup=>resolve(popup);page.once('popup',listener);
|
|
53
|
+
timer=setTimeout(()=>reject(new Error('payment popup did not open')),10000);
|
|
54
|
+
});
|
|
55
|
+
try {
|
|
56
|
+
const [popup]=await Promise.all([opened,frame.click(step.selector)]);
|
|
57
|
+
surfaces.push({name:step.popup,page:popup,frame:popup.mainFrame(),external:true,role:target.role});
|
|
58
|
+
await page.bringToFront();
|
|
59
|
+
} finally {clearTimeout(timer);page.off('popup',listener);}
|
|
60
|
+
}
|
|
61
|
+
else if (step.click) {await frame.click(step.selector);await page.bringToFront?.();}
|
|
47
62
|
else if (step.fill !== undefined) { await frame.$eval(step.selector, el => { el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); }); await frame.type(step.selector, String(step.fill)); }
|
|
48
63
|
else if (step.select !== undefined) await frame.select(step.selector, String(step.select));
|
|
49
64
|
else throw new Error('step needs click, fill, select or reload');
|
package/lib/app-ready.mjs
CHANGED
package/lib/art-cli.mjs
CHANGED
|
@@ -151,13 +151,13 @@ export async function styleCommand(root, sub, positional, flags, { log = () => {
|
|
|
151
151
|
} else if (verb === 'set') {
|
|
152
152
|
if (!args[1] || args[2] === undefined) return { ok: false, command: 'style set', why: `usage: homie-studio style set <id> <decision> <value> (decisions: ${CATALOGUE_IDS.slice(0, 10).join(', ')}, …)` };
|
|
153
153
|
r = setDecision(root, id, args[1], args.slice(2).join(' '), { by: flags.get('by') === 'person' ? 'person' : 'ai', why: flags.get('why') ?? null, words: flags.get('words') ?? null, unlock: flags.has('unlock'), reason: flags.get('reason') ?? null, confirm: flags.has('confirm'), manifest: manifest() });
|
|
154
|
-
} else if (verb === 'steer') r = steerDecision(root, id, args[1], args.slice(2).join(' ') || String(flags.get('words') ?? ''), { manifest: manifest() });
|
|
154
|
+
} else if (verb === 'steer') r = steerDecision(root, id, args[1], args.slice(2).join(' ') || String(flags.get('words') ?? ''), { manifest: manifest(), by: flags.get('by') === 'person' ? 'person' : 'ai' });
|
|
155
155
|
else if (verb === 'lock') r = lockDecision(root, id, args[1] ?? 'style', { by: 'person', words: flags.get('words') ?? null });
|
|
156
156
|
else if (verb === 'unlock') r = unlockDecision(root, id, args[1], { reason: flags.get('reason') ?? null, by: 'person' });
|
|
157
157
|
else if (verb === 'board') { const { styleBoard } = await import('./style-board.mjs'); r = await styleBoard(root, id, { prompt: String(flags.get('prompt') ?? ''), log, library: !flags.has('no-library') }); }
|
|
158
158
|
else if (verb === 'pick') {
|
|
159
159
|
const mixes = Object.fromEntries(list(flags.get('mix')).map((x) => x.split('=')).filter(([k, v]) => k && v));
|
|
160
|
-
r = pickDirection(root, id, args[1] && !args[1].includes('=') ? args[1] : null, { mixes, manifest: manifest() });
|
|
160
|
+
r = pickDirection(root, id, args[1] && !args[1].includes('=') ? args[1] : null, { mixes, manifest: manifest(), by: flags.get('by') === 'person' ? 'person' : 'ai' });
|
|
161
161
|
} else if (verb === 'mood') { const { recordMood } = await import('./style-board.mjs'); r = await recordMood(root, id, args[1], resolve(String(flags.get('image') ?? '')), { usd: flags.get('usd') ?? null, receipt: flags.get('receipt') ?? null, model: flags.get('model') ?? null }); }
|
|
162
162
|
else if (verb === 'golden') {
|
|
163
163
|
if (args[1] === 'add') { const { addGolden } = await import('./style-board.mjs'); r = await addGolden(root, id, resolve(String(args[2] ?? flags.get('image') ?? '')), { from: flags.get('from') ?? null }); }
|
package/lib/art-tools.mjs
CHANGED
|
@@ -223,11 +223,11 @@ export function artToolDefs(ctx, h) {
|
|
|
223
223
|
const by = a.by === 'person' ? 'person' : 'ai';
|
|
224
224
|
let r; let action;
|
|
225
225
|
try {
|
|
226
|
-
if (a.pick || a.mix) { action = 'pick'; r = pickDirection(root, game, a.pick ? String(a.pick).toLowerCase() : null, { mixes: a.mix && typeof a.mix === 'object' ? a.mix : {}, manifest }); }
|
|
226
|
+
if (a.pick || a.mix) { action = 'pick'; r = pickDirection(root, game, a.pick ? String(a.pick).toLowerCase() : null, { mixes: a.mix && typeof a.mix === 'object' ? a.mix : {}, manifest, by }); }
|
|
227
227
|
else if (!a.decision) return fail('name the decision (decision: "style.palette"), or pick a direction (pick: "b")');
|
|
228
228
|
else if (a.lock) { action = 'lock'; r = lockDecision(root, game, a.lock === true ? a.decision : String(a.lock), { by: 'person', words: a.words ?? (by === 'person' ? 'tapped Lock' : null) }); }
|
|
229
229
|
else if (a.unlock && a.value === undefined && a.steer === undefined) { action = 'unlock'; r = unlockDecision(root, game, a.decision, { reason: a.reason ?? null, by: 'person' }); }
|
|
230
|
-
else if (a.steer) { action = 'steer'; r = steerDecision(root, game, a.decision, String(a.steer), { manifest }); }
|
|
230
|
+
else if (a.steer) { action = 'steer'; r = steerDecision(root, game, a.decision, String(a.steer), { manifest, by }); }
|
|
231
231
|
else if (a.value !== undefined) { action = 'set'; r = setDecision(root, game, a.decision, a.value, { by, why: null, words: a.words ?? null, unlock: a.unlock === true, reason: a.reason ?? null, confirm: a.confirm === true, manifest }); }
|
|
232
232
|
else return fail('say what to do: value, steer, lock, unlock, pick or mix');
|
|
233
233
|
} catch (error) { return fail(error.message); }
|
package/lib/check.mjs
CHANGED
|
@@ -387,6 +387,7 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
387
387
|
for(const name of ['wall','phone','phone-two'])await openSurface(name);
|
|
388
388
|
const textOf = (f) => f.$eval(proof.observe, (e) => e.textContent);
|
|
389
389
|
const actor = surfaces[1];
|
|
390
|
+
await appReady(actor);
|
|
390
391
|
await waitInApp(actor,proof.action,f=>f.waitForFunction((selector) => { const el = document.querySelector(selector); return el && !el.disabled && !el.hidden; }, {}, proof.action));
|
|
391
392
|
before = await waitInApp(actor,proof.observe,textOf);
|
|
392
393
|
await actor.frame.click(proof.action);
|
|
@@ -406,6 +407,7 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
406
407
|
report?.check?.('app-action', 'pass', { note: 'action reached wall and both phones; reconnect retained it' });
|
|
407
408
|
}
|
|
408
409
|
for(const need of plan?.needs??[])for(const item of [...(need.steps??[]),...(need.assertions??[])]) {
|
|
410
|
+
if(plan.needs.some(n=>(n.steps??[]).some(s=>s.popup===item.surface)))continue;
|
|
409
411
|
const role=item.role??need.role??meta.surfaces?.[item.surface]??(item.surface==='staff'?'staff':undefined);
|
|
410
412
|
if(!surfaces.some(s=>s.name===item.surface&&s.role===role))await openSurface(item.surface,role);
|
|
411
413
|
}
|
package/lib/decisions.mjs
CHANGED
|
@@ -130,7 +130,7 @@ export const CATALOGUE = Object.freeze([
|
|
|
130
130
|
['style.render', 'style', 'How is the world drawn?', [], (ctx) => {
|
|
131
131
|
if (ctx.drawn?.render && RENDERS[ctx.drawn.render]) return { value: ctx.drawn.render, label: RENDERS[ctx.drawn.render].label, why: 'what the game draws now (its style.json)', options: Object.entries(RENDERS).map(([k, v]) => ({ id: k, label: v.label, value: k })) };
|
|
132
132
|
const hit = best(RENDERS, ctx);
|
|
133
|
-
const key = hit?.[0] ?? 'lowpoly-flat';
|
|
133
|
+
const key = hit?.[0] ?? (ctx.kind === 'app' ? 'illustrated' : 'lowpoly-flat');
|
|
134
134
|
return { value: key, label: RENDERS[key].label, why: hit ? `your words ${said(ctx, RENDERS[key].words)}: ${RENDERS[key].note}` : `nothing named a look: ${RENDERS[key].note}`, options: Object.entries(RENDERS).map(([k, v]) => ({ id: k, label: v.label, value: k })) };
|
|
135
135
|
}],
|
|
136
136
|
['style.palette', 'style', 'Which colours, and what is each one for?', ['style.render', 'style.light'], (ctx) => {
|
|
@@ -141,6 +141,10 @@ export const CATALOGUE = Object.freeze([
|
|
|
141
141
|
v.ramp = rampFrom(v);
|
|
142
142
|
return { value: v, label: 'The codex\'s own colours', why: 'the Game Codex already names them', options: paletteOptions(ctx) };
|
|
143
143
|
}
|
|
144
|
+
if(ctx.kind === 'app' && !best(PALETTES,ctx)) {
|
|
145
|
+
const value=paletteValue('custom',{bg:ctx.theme.bg??'#f6f3ec',ink:ctx.theme.fg??'#243d36',accent:ctx.theme.accent??'#226b59',accent2:'#b87935'});
|
|
146
|
+
value.ramp=rampFrom(value);return {value,label:'The place’s colours',why:'a readable starting palette for the app; refine it to the organisation',options:paletteOptions(ctx)};
|
|
147
|
+
}
|
|
144
148
|
const hit = best(PALETTES, ctx);
|
|
145
149
|
const key = hit?.[0] ?? ({ gather: 'meadow-morning', brawl: 'candy-pop', race: 'desert-noon', rpg: 'heroic-dawn', shooter: 'space-ink', platformer: 'meadow-morning', party: 'candy-pop', puzzle: 'candy-pop', sports: 'meadow-morning' })[ctx.genre] ?? 'meadow-morning';
|
|
146
150
|
return { value: paletteValue(key), label: paletteLabel({ name: key }), why: hit ? `your words ${said(ctx, PALETTES[key].words)}` : `a ${ctx.genre} game reads best in bright, separate colours`, options: paletteOptions(ctx) };
|
|
@@ -479,7 +483,7 @@ export function setDecision(root, id, did, input, { by = 'ai', why = null, words
|
|
|
479
483
|
* Steer: the person's words, applied where the vocabulary knows them ("warmer", "less saturated", "golden hour",
|
|
480
484
|
* "closer", "chunkier", "add outlines", a hex colour), and always recorded. The decision becomes steered.
|
|
481
485
|
*/
|
|
482
|
-
export function steerDecision(root, id, did, words, { manifest = null } = {}) {
|
|
486
|
+
export function steerDecision(root, id, did, words, { manifest = null, by = 'person' } = {}) {
|
|
483
487
|
const doc = readDecisions(root, id);
|
|
484
488
|
if (!doc) throw new Error(`games/${id} has no decisions yet: homie-studio style init ${id}`);
|
|
485
489
|
const rec = doc.decisions[did];
|
|
@@ -489,7 +493,7 @@ export function steerDecision(root, id, did, words, { manifest = null } = {}) {
|
|
|
489
493
|
if (rec.state === 'locked') return { ok: false, command: 'style steer', id, decision: did, locked: true, why: `${did} is locked: unlock it (with a reason, after the blast radius) before steering it` };
|
|
490
494
|
const { value, applied } = applySteer(did, rec.value, w, doc);
|
|
491
495
|
const steer = [...(rec.steer ?? []), w].slice(-10);
|
|
492
|
-
change(rec, { value, label: did === 'style.palette' ? paletteLabel({ ...value, steered: steer.join(', ') }) : labelOf(did, value), steer, state: rec.state === 'pinned' ? 'pinned' : 'steered', why: `
|
|
496
|
+
change(rec, { value, label: did === 'style.palette' ? paletteLabel({ ...value, steered: steer.join(', ') }) : labelOf(did, value), steer, state: rec.state === 'pinned' ? 'pinned' : by === 'person' ? 'steered' : rec.state, why: `refined by ${by === 'person' ? 'the person' : 'the AI'}: "${w}"` }, { by, words: w });
|
|
493
497
|
if (did === 'style.palette' && doc.decisions['style.light'] && doc.decisions['style.light'].state !== 'locked') {
|
|
494
498
|
const L = doc.decisions['style.light'];
|
|
495
499
|
L.value = { ...L.value, ...lightColours(LIGHTS[L.value.time] ?? LIGHTS.morning, value) };
|
|
@@ -762,7 +766,7 @@ export function directionsFor(root, id, { prompt = '' } = {}) {
|
|
|
762
766
|
}
|
|
763
767
|
|
|
764
768
|
/** Pick a direction (all its style values) or Mix rows from several: { 'style.palette': 'b', … }. By the person. */
|
|
765
|
-
export function pickDirection(root, id, choice, { mixes = {}, manifest = null } = {}) {
|
|
769
|
+
export function pickDirection(root, id, choice, { mixes = {}, manifest = null, by: actor = 'person' } = {}) {
|
|
766
770
|
const doc = readDecisions(root, id);
|
|
767
771
|
if (!doc?.board?.directions?.length) throw new Error(`games/${id} has no style board yet: homie-studio style board ${id}`);
|
|
768
772
|
const by = Object.fromEntries(doc.board.directions.map((d) => [d.id, d]));
|
|
@@ -778,7 +782,7 @@ export function pickDirection(root, id, choice, { mixes = {}, manifest = null }
|
|
|
778
782
|
const value = from.values[did];
|
|
779
783
|
if (value === undefined || JSON.stringify(rec.value) === JSON.stringify(value)) continue;
|
|
780
784
|
if (rec.state === 'locked') { refused.push(did); continue; }
|
|
781
|
-
change(rec, { value, label: labelOf(did, value), why:
|
|
785
|
+
change(rec, { value, label: labelOf(did, value), why: `${actor === 'person' ? 'the person' : 'the AI'} picked direction ${from.id.toUpperCase()} on the style board`, state: rec.state === 'pinned' ? 'pinned' : actor === 'person' ? 'steered' : rec.state }, { by: actor });
|
|
782
786
|
changed.push(did);
|
|
783
787
|
}
|
|
784
788
|
doc.board.chosen = choice ?? doc.board.chosen ?? null;
|
package/lib/dev.mjs
CHANGED
|
@@ -209,6 +209,9 @@ export function devConfig(root, remoteAi) {
|
|
|
209
209
|
const copy = join(base, '.wrangler', 'homie-dev.wrangler.json');
|
|
210
210
|
mkdirSync(dirname(copy), { recursive: true });
|
|
211
211
|
writeFileSync(copy, `${JSON.stringify(json, null, 2)}\n`);
|
|
212
|
+
const localSecrets=join(root,'.dev.vars'), generatedSecrets=join(dirname(copy),'.dev.vars');
|
|
213
|
+
if(existsSync(localSecrets))writeFileSync(generatedSecrets,readFileSync(localSecrets),{mode:0o600});
|
|
214
|
+
else rmSync(generatedSecrets,{force:true});
|
|
212
215
|
const notes = [
|
|
213
216
|
...(routed ? [`Local dev leaves out the production route${stripped.length === 1 ? '' : 's'} in wrangler.jsonc (${stripped.join(', ') || 'route keys'}): rooms here use this computer's own address, never the live site's. wrangler.jsonc is unchanged.`] : []),
|
|
214
217
|
...(aiChanged ? [remoteAi ? aiNote : 'No Workers AI under dev (--remote-ai for the real one, billed): AI guides think with Clef on this computer when Ollama has it, else scripted.'] : aiNote ? [aiNote] : []),
|
package/lib/shop-links.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { LINKS_STRIPE_VERSION, linkSettings, paymentLinkURL } from '../worker/sh
|
|
|
6
6
|
import { HOOK_EVENTS, SHOP_HOOK_EVENTS } from './shop.mjs';
|
|
7
7
|
const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
8
8
|
const same = (a, b) => Object.keys(a ?? {}).length === Object.keys(b).length && Object.entries(b).every(([k, v]) => a?.[k] === v);
|
|
9
|
-
export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, receipt, saveReceipt, bookingSlots = [] }) {
|
|
9
|
+
export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, receipt, saveReceipt, bookingSlots = [], local = false }) {
|
|
10
10
|
const list = async (path, params = {}) => {
|
|
11
11
|
const all = []; let cursor;
|
|
12
12
|
do {
|
|
@@ -19,7 +19,8 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
19
19
|
} while (cursor);
|
|
20
20
|
return all;
|
|
21
21
|
};
|
|
22
|
-
|
|
22
|
+
if(local && mode!=='test')throw new Error('Local checkout requires Stripe test mode');
|
|
23
|
+
const secrets = local ? {code:0,stdout:'[]'} : await w(['secret', 'list']);
|
|
23
24
|
if (secrets.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Connect Cloudflare and deploy the studio, then run shop connect again.' };
|
|
24
25
|
const names = JSON.parse(secrets.stdout ?? secrets.out);
|
|
25
26
|
const has = (name) => names.some((s) => s.name === name);
|
|
@@ -134,6 +135,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
134
135
|
portal=await api('POST',portal?`/v1/billing_portal/configurations/${portal.id}`:'/v1/billing_portal/configurations',params);
|
|
135
136
|
config.portal=portal.login_page?.url;
|
|
136
137
|
}
|
|
138
|
+
if(local)return {ok:true,command:'shop connect',local:true,mode,config};
|
|
137
139
|
const purchases = root ? await (await import('./purchase-links.mjs')).syncPurchaseLinks({ root, api, slug, site, mode, list, till: shop.purchasesTill ?? shop.till }) : null;
|
|
138
140
|
const purchaseSecrets = {};
|
|
139
141
|
if (purchases?.config.offers.length && !has('PURCHASE_SIGNING_KEYS')) {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Real Stripe test links, delivered by the owner's Stripe CLI listener. */
|
|
2
|
+
import {readFileSync,writeFileSync,existsSync} from 'node:fs';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
import {createHash} from 'node:crypto';
|
|
5
|
+
import {stripeProcess} from './stripe-connect.mjs';
|
|
6
|
+
import {syncPaymentLinks} from './shop-links.mjs';
|
|
7
|
+
import {checkShop} from '../worker/shop-rules.mjs';
|
|
8
|
+
import {formEncode,redactStripe} from '../worker/stripe.mjs';
|
|
9
|
+
export async function shopConnectLocal(root,{url,profile,exec=stripeProcess}={}) {
|
|
10
|
+
const origin=new URL(url??'http://localhost:8787');
|
|
11
|
+
if(origin.protocol!=='http:' || !/^(localhost|127\.0\.0\.1|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)$/.test(origin.hostname))throw new Error('Local Stripe needs an HTTP localhost or Wi-Fi address');
|
|
12
|
+
if(!profile)throw new Error('Name the existing Stripe sandbox CLI profile with --profile');
|
|
13
|
+
const checked=checkShop(JSON.parse(readFileSync(join(root,'shop.json'),'utf8')));
|
|
14
|
+
if(!checked.ok)throw new Error('Fix shop.json before connecting Stripe');
|
|
15
|
+
const api=async(method,path,params={},idempotency)=>{
|
|
16
|
+
const args=['--project-name',profile,method.toLowerCase(),path];
|
|
17
|
+
for(const [k,v] of formEncode(params))args.push('-d',`${k}=${v}`);
|
|
18
|
+
if(idempotency)args.push('--idempotency',idempotency);
|
|
19
|
+
const r=await exec(args,{cwd:root});let body;try{body=JSON.parse(r.stdout);}catch{}
|
|
20
|
+
if(r.code!==0||body?.error||!body)throw new Error('Stripe test request failed: '+redactStripe(body?.error?.message??r.stderr??'no response').slice(0,500));
|
|
21
|
+
if(body.livemode===true||body.data?.some(o=>o.livemode===true))throw new Error('Local checkout refuses live Stripe objects');
|
|
22
|
+
return body;
|
|
23
|
+
};
|
|
24
|
+
const slug='local-'+createHash('sha256').update(root).digest('hex').slice(0,16);
|
|
25
|
+
const r=await syncPaymentLinks({api,shop:checked.shop,slug,site:origin.origin,mode:'test',local:true});
|
|
26
|
+
const file=join(root,'.dev.vars');let vars=existsSync(file)?readFileSync(file,'utf8'):'';
|
|
27
|
+
vars=vars.replace(/^STRIPE_(?:SHOP_LINKS|CHECKOUT_MODE)=.*\n?/gm,'');
|
|
28
|
+
vars+=`\nSTRIPE_CHECKOUT_MODE=links\nSTRIPE_SHOP_LINKS=base64:${Buffer.from(JSON.stringify(r.config)).toString('base64')}\n`;
|
|
29
|
+
writeFileSync(file,vars,{mode:0o600});
|
|
30
|
+
return {ok:true,command:'shop connect',local:true,mode:'test',url:origin.origin,message:'Test Payment Links saved to root .dev.vars.',next:[`stripe --project-name ${profile} listen --all-snapshot --forward-to http://localhost:${origin.port||80}/api/shop/hook`,'Put the listener signing secret in root .dev.vars as STRIPE_WEBHOOK_SECRET, then start/restart homie-studio dev --lan. Keep the listener running.','Open the same Wi-Fi address on phones; complete Stripe test checkout and verify the paid order and app event.']};
|
|
31
|
+
}
|
package/lib/style-presets.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
/** How the world is drawn. `family`: the starter library family that fits; `materials`: the material model. */
|
|
8
8
|
export const RENDERS = Object.freeze({
|
|
9
|
+
illustrated: { label: 'Illustrated place', words: /\b(illustrated|illustration|pictorial)\b/i, materials: 'flat', outline: false, family: 'kenney', fonts: { display: 'Fraunces', body: 'Inter' }, vfx: 'soft', note: 'readable objects, clear typography and purposeful motion for the work of this place' },
|
|
9
10
|
'lowpoly-flat': { label: 'Flat low-poly', words: /\b(low[- ]?poly|cozy|cosy|cute|toy|minimal|clean|simple|wholesome|chill)\b/i, materials: 'flat', outline: false, family: 'kenney', fonts: { display: 'Fredoka', body: 'Nunito' }, vfx: 'soft', note: 'faceted shapes in flat palette colours: the cheapest look on a phone, the most coherent, and the richest free library' },
|
|
10
11
|
toon: { label: 'Toon / cel', words: /\b(toon|cel|cartoon|anime|comic|neon|cyber|synth|arcade)\b/i, materials: 'toon', outline: true, family: 'kaykit', fonts: { display: 'Lilita One', body: 'Nunito' }, vfx: 'toon', note: 'banded light and ink outlines: reads at any size, loud on a TV' },
|
|
11
12
|
painted: { label: 'Stylised hand-painted', words: /\b(painterly|painted|storybook|watercolou?r|ghibli-like|fairy ?tale|whimsical|hand[- ]painted)\b/i, materials: 'hand-painted', outline: false, family: 'kaykit', fonts: { display: 'Fraunces', body: 'Lora' }, vfx: 'soft', note: 'soft painted albedo with the light baked gently in: warm and storybook' },
|
|
@@ -3624,6 +3624,48 @@
|
|
|
3624
3624
|
}
|
|
3625
3625
|
}
|
|
3626
3626
|
}
|
|
3627
|
+
},
|
|
3628
|
+
"0.51.3": {
|
|
3629
|
+
"files": {
|
|
3630
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3631
|
+
"README.md": "684b3cca8001673c",
|
|
3632
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3633
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3634
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3635
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3636
|
+
"posts/README.md": "37841aae476bd540",
|
|
3637
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3638
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3639
|
+
},
|
|
3640
|
+
"sections": {
|
|
3641
|
+
"AGENTS.md": {
|
|
3642
|
+
"(intro)": "394cbbe934e80722",
|
|
3643
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3644
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3645
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3646
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
3647
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3648
|
+
"## Making games": "bb715272fd1a615b",
|
|
3649
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3650
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3651
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3652
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3653
|
+
"## Selling things (the shop)": "43a3556be93d1235",
|
|
3654
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3655
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3656
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3657
|
+
"## Beta": "9d02638207a26e73"
|
|
3658
|
+
}
|
|
3659
|
+
},
|
|
3660
|
+
"named": {
|
|
3661
|
+
"Homie Arcade": {
|
|
3662
|
+
"sections": {
|
|
3663
|
+
"AGENTS.md": {
|
|
3664
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
}
|
|
3668
|
+
}
|
|
3627
3669
|
}
|
|
3628
3670
|
}
|
|
3629
3671
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.3",
|
|
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/shop/SHOP.md
CHANGED
|
@@ -333,3 +333,9 @@ Use Stripe's published CLI test fixtures/tokens to complete Checkout via API;
|
|
|
333
333
|
never type card details into a form. Stop on an expired profile and ask the owner
|
|
334
334
|
to approve `stripe login --project-name <profile>` once. Stop the adapter and remove
|
|
335
335
|
dev bindings and disposable studios when finished.
|
|
336
|
+
|
|
337
|
+
## Local Stripe test payments
|
|
338
|
+
|
|
339
|
+
See `apps/APPS.md`, “Real Stripe test checkout on this computer”, for the supported
|
|
340
|
+
`shop connect --local` and Stripe CLI forwarding recipe. Every paid monthly cycle
|
|
341
|
+
creates a paid order and `order.paid` with the subscription’s original app reference.
|
package/worker/app-records.mjs
CHANGED
|
@@ -97,7 +97,7 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
|
97
97
|
if (op !== 'read' && !sameOrigin(request, url) && !(appOriginOf(request) && !meta.roles[access.role].signIn)) return fail(403, 'same origin required');
|
|
98
98
|
if (op === 'read') {
|
|
99
99
|
const rows = id ? [await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3').bind(recordApp, collection, id).first()].filter(Boolean)
|
|
100
|
-
: (await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id
|
|
100
|
+
: (await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id').bind(recordApp, collection).all()).results;
|
|
101
101
|
const records=[];
|
|
102
102
|
for(const r of rows) {
|
|
103
103
|
const record={id:r.id,data:JSON.parse(r.value),version:r.version,updatedAt:r.updated_at};
|
|
@@ -124,11 +124,11 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
|
124
124
|
if (op !== 'delete') { const bad = recordProblem(meta.records.collections[collection], body.data, { create: op === 'create' }); if (bad) return fail(400, bad); }
|
|
125
125
|
if (op !== 'create' && (!Number.isSafeInteger(body.version) || body.version < 1)) return fail(400, 'record version required');
|
|
126
126
|
const stmt = op === 'create'
|
|
127
|
-
? env.DB.prepare('INSERT OR IGNORE INTO app_records (app, collection, id, value, version, updated_at)
|
|
127
|
+
? env.DB.prepare('INSERT OR IGNORE INTO app_records (app, collection, id, value, version, updated_at) VALUES (?1, ?2, ?3, ?4, 1, ?5)').bind(recordApp, collection, id, JSON.stringify(body.data), Date.now())
|
|
128
128
|
: op === 'update' ? env.DB.prepare('UPDATE app_records SET value = ?4, version = version + 1, updated_at = ?5 WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?6').bind(recordApp, collection, id, JSON.stringify(body.data), Date.now(), body.version)
|
|
129
129
|
: env.DB.prepare('DELETE FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?4').bind(recordApp, collection, id, body.version);
|
|
130
130
|
const result = await stmt.run();
|
|
131
|
-
return result.meta.changes ? json({ ok: true, id, version: op === 'create' ? 1 : body.version + 1 }) : fail(409, 'record changed
|
|
131
|
+
return result.meta.changes ? json({ ok: true, id, version: op === 'create' ? 1 : body.version + 1 }) : fail(409, 'record changed or already exists; reload');
|
|
132
132
|
} catch (error) {
|
|
133
133
|
if (/booking capacity|capacity below|slot has active/.test(error.message)) return fail(409, error.message);
|
|
134
134
|
if (error instanceof SyntaxError || error.message === 'body too large') return fail(400, error.message);
|
package/worker/arrival.mjs
CHANGED
|
@@ -72,14 +72,14 @@ export function arrivalCard(cat, g, { screen = false, off = false } = {}) {
|
|
|
72
72
|
const art = wide || tall
|
|
73
73
|
? `<picture class="arrive-art">${wide && tall ? `<source media="(min-aspect-ratio: 3/4)" srcset="${esc(wide)}">` : ''}<img src="${esc(tall ?? wide)}" alt="" decoding="async" fetchpriority="high" style="object-position:${esc(focus)}"></picture>`
|
|
74
74
|
: '<div class="arrive-art bare" aria-hidden="true"></div>';
|
|
75
|
-
const html = `<div class="arrive${screen ? ' big' : ''}" data-arrive data-from="${esc(c.from)}">
|
|
75
|
+
const html = `<div class="arrive${screen ? ' big' : ''}" data-arrive data-from="${esc(c.from)}"${g?.kind === 'app' ? ' data-app' : ''}>
|
|
76
76
|
${art}<div class="arrive-shade" aria-hidden="true"></div>
|
|
77
77
|
<div class="arrive-in">
|
|
78
78
|
${studio ? `<p class="arrive-kicker">${esc(studio)}</p>` : ''}
|
|
79
79
|
<h1 class="arrive-title">${esc(g?.name ?? '')}</h1>
|
|
80
80
|
${pitch ? `<p class="arrive-pitch">${esc(pitch)}</p>` : ''}
|
|
81
81
|
<div class="arrive-meter" aria-hidden="true"><i data-arrive-bar></i></div>
|
|
82
|
-
<p class="arrive-step" role="status" aria-live="polite"><span data-arrive-step
|
|
82
|
+
<p class="arrive-step" role="status" aria-live="polite"><span data-arrive-step>${g?.kind === 'app' ? 'Opening the app…' : 'Finding a room…'}</span><span class="arrive-room" data-arrive-room></span></p>
|
|
83
83
|
${keys.map(([k, v]) => `<p class="arrive-keys" data-for="${k}">${esc(String(v).slice(0, 160))}</p>`).join('')}
|
|
84
84
|
</div>
|
|
85
85
|
</div>`;
|
|
@@ -166,9 +166,10 @@ export const ARRIVAL_JS = String.raw`(function () {
|
|
|
166
166
|
function later(ms, why) { timers.push(setTimeout(function () { api.lift(why); }, ms)); }
|
|
167
167
|
// Whatever happens, the game gets the screen within 30 s of the page opening.
|
|
168
168
|
later(30000, 'timeout');
|
|
169
|
+
var app=el.getAttribute('data-app') !== null;
|
|
169
170
|
var roomLabel = '';
|
|
170
171
|
// A full server's wait ("you're next for a seat") stays said while its room loads.
|
|
171
|
-
api.room = function (label) { roomLabel = label || ''; if (a.phase === 'wait') set('wait', a.step, 0.22); else set('game', 'Loading the game…', 0.22); if (!roomEl.textContent) roomEl.textContent = roomLabel; };
|
|
172
|
+
api.room = function (label) { roomLabel = label || ''; if (a.phase === 'wait') set('wait', a.step, 0.22); else set('game', app ? 'Opening the app…' : 'Loading the game…', 0.22); if (!roomEl.textContent) roomEl.textContent = roomLabel; };
|
|
172
173
|
api.facts = function (text) { if (!api.done && text) roomEl.textContent = text; };
|
|
173
174
|
api.full = function (text) { set('wait', text, 0.3); };
|
|
174
175
|
var attachedAt = 0;
|
|
@@ -185,7 +186,7 @@ export const ARRIVAL_JS = String.raw`(function () {
|
|
|
185
186
|
set('join', 'Joining ' + (roomLabel || 'the room') + '…', 0.36);
|
|
186
187
|
later(15000, 'cap');
|
|
187
188
|
} else if (m.what === 'role' || m.what === 'token') {
|
|
188
|
-
set('world', 'Loading the world…', 0.5);
|
|
189
|
+
set('world', app ? 'Opening the app…' : 'Loading the world…', 0.5);
|
|
189
190
|
// A game built with a helper older than the arrival says nothing more: it has the screen once it has a seat.
|
|
190
191
|
if (a.mode === 'seat' && !seatedTimer) seatedTimer = setTimeout(function () { api.lift('seated'); }, 600);
|
|
191
192
|
} else if (m.what === 'loading') {
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.51.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.51.3","sha256":"ac8867ee4a8f906ed64a106d28d60921552d1caad2d99dd70467ae42e70aea75"});
|
package/worker/index.mjs
CHANGED
|
@@ -607,9 +607,15 @@ function finish(res, path) {
|
|
|
607
607
|
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
608
608
|
}
|
|
609
609
|
|
|
610
|
+
// Repeat after migration: an older isolate may recreate these during a rolling deploy.
|
|
611
|
+
async function removeReferralLeftovers(env) {
|
|
612
|
+
if(env.DB)await env.DB.prepare("DELETE FROM meta WHERE key GLOB 'referral_*' OR key GLOB 'referral-*'").run();
|
|
613
|
+
}
|
|
614
|
+
|
|
610
615
|
export default {
|
|
611
616
|
async scheduled(event, env, ctx) {
|
|
612
617
|
const maintenance=(async () => {
|
|
618
|
+
await removeReferralLeftovers(env);
|
|
613
619
|
const cat = await catalogue(env, 'https://studio.invalid');
|
|
614
620
|
await notifyTools(env,cat,cat.studio?.url??'https://studio.invalid').catch(error=>console.warn('Tool catalogue refresh:',error.message));
|
|
615
621
|
await reconcileOrders(env, shopOf(cat));
|
|
@@ -619,6 +625,7 @@ export default {
|
|
|
619
625
|
await maintenance;
|
|
620
626
|
},
|
|
621
627
|
async fetch(request, env, ctx) {
|
|
628
|
+
await removeReferralLeftovers(env);
|
|
622
629
|
const url = new URL(request.url);
|
|
623
630
|
await (await import('./functions.mjs')).syncFunctions(env);
|
|
624
631
|
const dispatch=()=>import('./functions.mjs').then(m=>m.runFunctions(env,()=>catalogue(env,url.origin),url.origin));
|
package/worker/pages.mjs
CHANGED
|
@@ -125,7 +125,7 @@ export function playPage(cat, g, { embed = false, origin = '', preview = false,
|
|
|
125
125
|
const shopControl = Boolean(shop && !screen && g.screen?.shop !== false);
|
|
126
126
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
127
127
|
const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
|
|
128
|
-
const places = sharePlaces(g.screen?.share);
|
|
128
|
+
const places = sharePlaces(g.screen?.share ?? (g.kind === 'app' ? 'top-right' : undefined));
|
|
129
129
|
// The phone's glass belongs to the game: no page pan, pinch-zoom, text selection or callout under a thumb
|
|
130
130
|
// (a pinch between a stick thumb and a button thumb made the browser cancel both touches).
|
|
131
131
|
// A game with an art direction (games.json `ui`, from its style.json): the pills wear its paper and text, like its HUD.
|
|
@@ -448,6 +448,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
448
448
|
place();
|
|
449
449
|
addEventListener('resize', place);
|
|
450
450
|
function labelOf(room) {
|
|
451
|
+
if(boot.kind === 'app') return boot.name;
|
|
451
452
|
var m = /^pub-(\d+)$/.exec(room);
|
|
452
453
|
if (m) return 'Room ' + m[1];
|
|
453
454
|
// A room of this page's server (s-<server>-<n>) is "Room <n>" too: the server pill beside the button names it.
|
|
@@ -816,6 +817,11 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
816
817
|
}
|
|
817
818
|
|
|
818
819
|
function paint() {
|
|
820
|
+
if(boot.kind === 'app') {
|
|
821
|
+
say(state.link?.state === 'reconnecting' ? 'Reconnecting…' : 'Connected');
|
|
822
|
+
var count=ui && ui.querySelector('[data-room-count]'); if(count)count.hidden=true;
|
|
823
|
+
serverUi(); return;
|
|
824
|
+
}
|
|
819
825
|
var f = state.facts;
|
|
820
826
|
var n = f && f.counts ? f.counts.players : null;
|
|
821
827
|
var bits = [];
|
|
@@ -861,7 +867,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
861
867
|
function serverUi() {
|
|
862
868
|
if (!sToggle || !sSheet) return;
|
|
863
869
|
var f = state.facts; var pol = f && f.policy ? f.policy : null;
|
|
864
|
-
var show = Boolean(state.server) || (hasAi() && readsDial());
|
|
870
|
+
var show = boot.kind === 'app' ? Boolean(state.server && state.server.id !== 'public') : Boolean(state.server) || (hasAi() && readsDial());
|
|
865
871
|
sToggle.hidden = !show;
|
|
866
872
|
if (!show) return;
|
|
867
873
|
var name = state.server ? state.server.name : 'Quick play';
|
|
@@ -1300,7 +1306,7 @@ const WATCH_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
1300
1306
|
}
|
|
1301
1307
|
sizeUp();
|
|
1302
1308
|
addEventListener('resize', sizeUp);
|
|
1303
|
-
function labelOf(room) { var m = /^pub-(\d+)$/.exec(room || ''); return m ? 'Room ' + m[1] : (room || ''); }
|
|
1309
|
+
function labelOf(room) { if(boot.kind === 'app') return boot.name; var m = /^pub-(\d+)$/.exec(room || ''); return m ? 'Room ' + m[1] : (room || ''); }
|
|
1304
1310
|
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = String(text); return e; }
|
|
1305
1311
|
function svg(path) { var s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true'); s.setAttribute('fill', 'none'); s.setAttribute('stroke', 'currentColor'); s.setAttribute('stroke-width', '2'); s.setAttribute('stroke-linecap', 'round'); s.setAttribute('stroke-linejoin', 'round'); s.innerHTML = path; return s; }
|
|
1306
1312
|
var AUTO_ICON = '<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M6 18l2.5-2.5M15.5 8.5 18 6"/>';
|
package/worker/shop-page.mjs
CHANGED
|
@@ -90,7 +90,7 @@ export function thanksPage(cat, shop, { session = null, game = null } = {}) {
|
|
|
90
90
|
<p class="kicker">${esc(studio)}</p>
|
|
91
91
|
<h1 data-thanks-title>Thank you</h1>
|
|
92
92
|
<p class="lead" data-thanks-line>Stripe is confirming the payment. ${shop.giving ? 'Thank you for supporting our work.' : 'This page says when it is yours (a few seconds).'}</p>
|
|
93
|
-
<p>${g ? `<a class="btn" href="/${esc(g.id)}
|
|
93
|
+
<p>${g ? `<a class="btn" href="/${esc(g.id)}/${g.kind === 'app' ? 'open' : 'play'}">Back to ${esc(g.name)}</a>` : `<a class="btn" href="/">Back to ${esc(studio)}</a>`} <a href="/account/">Your account</a></p>
|
|
94
94
|
<p class="note">Your receipt comes from Stripe by email. ${shop.giving ? esc(shop.giving.receiptText ?? '') : refundWords(shop)}</p>
|
|
95
95
|
</section>
|
|
96
96
|
<script type="application/json" id="shop-boot">${JSON.stringify(boot).replace(/</g, '\\u003c')}</script>
|
|
@@ -298,7 +298,7 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
298
298
|
(function poll() {
|
|
299
299
|
tries += 1;
|
|
300
300
|
api('GET', '/api/shop/order?session=' + encodeURIComponent(boot.thanks)).then(function (r) {
|
|
301
|
-
if (r.status === 'paid') { returnToPage(false); $('[data-thanks-title]').textContent = boot.giving ? 'Thank you for your gift' : 'It\'s yours'; $('[data-thanks-line]').textContent = boot.giving ? 'Your gift is confirmed.
|
|
301
|
+
if (r.status === 'paid') { returnToPage(false); $('[data-thanks-title]').textContent = boot.giving ? 'Thank you for your gift' : 'It\'s yours'; $('[data-thanks-line]').textContent = boot.giving ? 'Your gift is confirmed.' : 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; if (r.simulated) { $('[data-thanks-title]').textContent = 'Simulation confirmed'; $('[data-thanks-line]').textContent = 'This was an offline test. No money moved and no Stripe receipt was sent.'; } return; }
|
|
302
302
|
if (tries < 40) setTimeout(poll, 1500);
|
|
303
303
|
else $('[data-thanks-line]').textContent = boot.giving ? 'Stripe has not confirmed your gift yet. Check your email for its receipt.' : 'Stripe has not confirmed it yet. It will be on your account as soon as it does; your receipt comes by email.';
|
|
304
304
|
}).catch(function () { if (tries < 40) setTimeout(poll, 3000); });
|
|
@@ -31,6 +31,8 @@ export async function bindSubscription(env, order, session, shop) {
|
|
|
31
31
|
env.DB.prepare(`INSERT OR IGNORE INTO shop_subscription_events(id,subscription,type,invoice,amount,currency,at)
|
|
32
32
|
VALUES(?1,?2,'subscription.started',NULL,?3,?4,?5)`).bind(`started:${subscription}`,subscription,order.amount,order.currency,order.paid_at),
|
|
33
33
|
]);
|
|
34
|
+
const firstInvoice=idOf(session.invoice);
|
|
35
|
+
if(firstInvoice)await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO NOTHING').bind(`subscription-first-invoice:${subscription}`,firstInvoice).run();
|
|
34
36
|
await reconcileMembership(env, subscription, shop);
|
|
35
37
|
if(!hasStripeKey(env)) {
|
|
36
38
|
const invoice=typeof session.invoice==='string'?session.invoice:session.invoice?.id;
|
|
@@ -70,6 +72,7 @@ export async function subscriptionEvent(env, event, shop) {
|
|
|
70
72
|
else if (event.type === 'invoice.paid') { if (obj.billing_reason === 'subscription_create') return 'initial'; type='subscription.renewed'; state='active'; }
|
|
71
73
|
else if (event.type === 'invoice.payment_failed') { type='subscription.failed'; state='past_due'; }
|
|
72
74
|
else { return 'updated'; } // Scheduling cancellation is not cancellation; Stripe sends deleted when it ends.
|
|
75
|
+
if(type==='subscription.renewed')await recordRenewal(env,row,obj,at);
|
|
73
76
|
const eventId=type === 'subscription.renewed' ? `paid:${obj.id}` : event.id;
|
|
74
77
|
await env.DB.batch([
|
|
75
78
|
env.DB.prepare(`UPDATE shop_subscriptions SET state=?2,event_at=?3 WHERE id=?1 AND state!='cancelled' AND event_at<=?3 AND ?4=0`).bind(subscription,state,at,hasStripeKey(env)?1:0),
|
|
@@ -196,3 +199,42 @@ async function invoiceMembership(env,subscription,invoice) {
|
|
|
196
199
|
}
|
|
197
200
|
if(writes.length)await env.DB.batch(writes);
|
|
198
201
|
}
|
|
202
|
+
|
|
203
|
+
/** The invoice is the financial identity; delivery retries never count twice.
|
|
204
|
+
* Access stays on the original subscription; renewal orders are payment receipts. */
|
|
205
|
+
async function recordRenewal(env, subscription, invoice, at) {
|
|
206
|
+
if(invoice.status!=='paid')return;
|
|
207
|
+
if(await env.DB.prepare('SELECT id FROM shop_subscription_events WHERE id=?1').bind(`paid:${invoice.id}`).first())return;
|
|
208
|
+
const first=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`subscription-first-invoice:${subscription.id}`).first();
|
|
209
|
+
if(first?.value===invoice.id || invoice.billing_reason!=='subscription_cycle')return;
|
|
210
|
+
if(hasStripeKey(env)) {
|
|
211
|
+
const invoiceId=invoice.id;
|
|
212
|
+
invoice=await stripeCall(env,'GET',`/v1/invoices/${invoiceId}`,{expand:['lines.data.price.product']});
|
|
213
|
+
if(invoice.id!==invoiceId || idOf(invoice.parent?.subscription_details?.subscription??invoice.subscription)!==subscription.id || idOf(invoice.customer)!==subscription.customer || invoice.livemode!==(subscription.mode==='live'))throw new Error('Renewal invoice identity mismatch');
|
|
214
|
+
if(invoice.lines?.has_more)invoice.lines=await allPages(env,`/v1/invoices/${invoiceId}/lines`,{expand:['data.price.product']});
|
|
215
|
+
}
|
|
216
|
+
const original=await env.DB.prepare('SELECT * FROM shop_orders WHERE id=?1').bind(subscription.order_id).first();
|
|
217
|
+
const lines=(await env.DB.prepare('SELECT * FROM shop_order_lines WHERE order_id=?1').bind(original.id).all()).results;
|
|
218
|
+
// A subscription without shop lines has no shop fulfilment to repeat.
|
|
219
|
+
if(!lines.length)return;
|
|
220
|
+
if(invoice.status!=='paid' || invoice.lines?.has_more || invoice.currency!==original.currency || !Number.isSafeInteger(invoice.amount_paid) || invoice.amount_paid<0)throw new Error('Complete paid renewal invoice required');
|
|
221
|
+
const paidLines=(invoice.lines?.data??[]).map(l=>{
|
|
222
|
+
const price=idOf(l.price ?? l.pricing?.price_details?.price);
|
|
223
|
+
const product=l.price?.product;
|
|
224
|
+
const source=lines.find(s=>{const snapshot=JSON.parse(s.snapshot??'{}');return snapshot.stripePrice===price || hasStripeKey(env) && (product?.metadata?.line===s.id || [product?.metadata?.homie_item,product?.metadata?.item].includes(s.item) || snapshot.stripeProduct && snapshot.stripeProduct===idOf(product));});
|
|
225
|
+
if(!source || !Number.isSafeInteger(l.quantity) || l.quantity<1 || !Number.isSafeInteger(l.amount) || l.amount<0)throw new Error('Unknown renewal invoice line');
|
|
226
|
+
return {...source,quantity:l.quantity,amount:l.amount};
|
|
227
|
+
});
|
|
228
|
+
if(!paidLines.length)throw new Error('Renewal invoice has no lines');
|
|
229
|
+
const amount=paidLines.reduce((n,l)=>n+l.amount,0),total=invoice.total??invoice.amount_paid;
|
|
230
|
+
const id=`renewal:${invoice.id}`;
|
|
231
|
+
const writes=[env.DB.prepare(`INSERT OR IGNORE INTO shop_orders(id,player,item,game,amount,currency,total,till,mode,status,created_at,updated_at)
|
|
232
|
+
VALUES(?1,?2,?3,?4,?5,?6,?7,'stripe',?8,'started',?9,?9)`)
|
|
233
|
+
.bind(id,original.player,original.item,original.game,amount,original.currency,total,original.mode,at),
|
|
234
|
+
env.DB.prepare(`INSERT OR IGNORE INTO meta(key,value) SELECT ?1,value FROM meta WHERE key=?2`).bind(`shop-reference:${id}`,`shop-reference:${original.id}`),
|
|
235
|
+
...paidLines.map((l,i)=>env.DB.prepare(`INSERT OR IGNORE INTO shop_order_lines(id,order_id,position,item,quantity,unit_amount,amount,total,snapshot,status)
|
|
236
|
+
VALUES(?1,?2,?3,?4,?5,?6,?7,?7,?8,'paid')`).bind(`${id}_${i}`,id,i,l.item,l.quantity,l.amount/l.quantity,l.amount,l.snapshot)),
|
|
237
|
+
env.DB.prepare("UPDATE shop_orders SET paid_at=?2,status='paid',payment=?3,tax=?4 WHERE id=?1 AND paid_at IS NULL")
|
|
238
|
+
.bind(id,at,idOf(invoice.payment_intent)??null,total-amount)];
|
|
239
|
+
await env.DB.batch(writes);
|
|
240
|
+
}
|
package/worker/site.mjs
CHANGED
|
@@ -556,14 +556,16 @@ const studioName = (cat) => cat.studio?.name ?? 'Studio';
|
|
|
556
556
|
export function sectionsOf(cat) {
|
|
557
557
|
const games = (cat.games ?? []).some((g) => g.kind !== 'app');
|
|
558
558
|
const apps = (cat.games ?? []).some((g) => g.kind === 'app');
|
|
559
|
+
const ownMenu = ['business','cause'].includes(cat.studio?.kind);
|
|
559
560
|
return [
|
|
560
|
-
...(cat.site?.pages ?? []).filter(p => /^\/[a-z0-9][a-z0-9._-]*\/$/.test(p) && !['/shop/', '/apps/', '/games/', '/rooms/', '/posts/', '/music/', '/videos/', '/lounge/'].includes(p)).map(p => ({ key: p, href: p, label: p.slice(1, -1).replaceAll('-', ' ').replace(/^./, c => c.toUpperCase()) })),
|
|
561
|
+
...(cat.site?.pages ?? []).filter(p => /^\/[a-z0-9][a-z0-9._-]*\/$/.test(p) && !['/shop/', '/apps/', '/games/', '/rooms/', '/posts/', '/music/', '/videos/', '/lounge/'].includes(p)).map(p => ({ key: p, href: p, label: (ownMenu && (cat.games??[]).find(g=>g.kind==='app' && `/${g.id}/`===p)?.name) || p.slice(1, -1).replaceAll('-', ' ').replace(/^./, c => c.toUpperCase()) })),
|
|
561
562
|
cat.shop && { key: 'shop', href: '/shop/', label: cat.shop.giving ? 'Give' : 'Shop' },
|
|
562
|
-
|
|
563
|
+
...((ownMenu ? cat.games ?? [] : []).filter(g=>g.kind==='app' && !(cat.site?.pages??[]).includes(`/${g.id}/`)).map(g=>({key:g.id,href:`/${g.id}/`,label:g.name}))),
|
|
564
|
+
apps && !ownMenu && { key: 'apps', href: '/apps/', label: 'Apps' },
|
|
563
565
|
games && { key: 'games', href: '/games/', label: 'Games' },
|
|
564
566
|
(cat.songs ?? []).length > 0 && { key: 'music', href: '/music/', label: 'Music' },
|
|
565
567
|
(cat.videos ?? []).length > 0 && { key: 'videos', href: '/videos/', label: 'Videos' },
|
|
566
|
-
(games || apps) && { key: 'rooms', href: '/rooms/', label: 'Rooms' },
|
|
568
|
+
(games || (apps && !ownMenu)) && { key: 'rooms', href: '/rooms/', label: 'Rooms' },
|
|
567
569
|
(cat.posts ?? []).length > 0 && { key: 'posts', href: '/posts/', label: 'Posts' },
|
|
568
570
|
// The Lounge (0.29.0): the studio's own community room, when studio.json turns it on.
|
|
569
571
|
cat.studio?.lounge && { key: 'lounge', href: '/lounge/', label: cat.studio.lounge.tab ?? 'Lounge' },
|
|
@@ -732,7 +734,7 @@ function gameCard(g, playing = 0) {
|
|
|
732
734
|
<h3><a href="/${esc(g.id)}/">${esc(g.name)}</a></h3>
|
|
733
735
|
<p>${esc(g.landing?.pitch ?? g.blurb)}</p>
|
|
734
736
|
<div class="meta"><span>${esc(g.kind === 'app' ? 'One screen · live together' : playersText(g))}</span>${minutes(g.roundSeconds) ? `<span>${esc(minutes(g.roundSeconds))}</span>` : ''}<span>phone, computer or TV</span></div>
|
|
735
|
-
<div class="acts"><a class="btn" href="${esc(openPath(g))}" data-play>${PLAY_ICON}${esc(appWords(g).open)}</a><a class="ghost" href="/${esc(g.id)}/">About</a></div>
|
|
737
|
+
<div class="acts"><a class="btn" href="${esc(openPath(g))}" data-play>${PLAY_ICON}${esc(g.kind === 'app' ? g.landing?.action ?? `Open ${g.name}` : appWords(g).open)}</a><a class="ghost" href="/${esc(g.id)}/">About</a></div>
|
|
736
738
|
</div>
|
|
737
739
|
</article>`;
|
|
738
740
|
}
|
|
@@ -836,7 +838,7 @@ export function homeHero(cat, f, { playing = 0 } = {}) {
|
|
|
836
838
|
<p class="kicker">${esc(name)}<span class="k-opt"> · ${esc(tagline || (f.kind === 'app' ? 'featured app' : 'featured game'))}</span></p>
|
|
837
839
|
<h1 class="title ${titleClass(f.name)}" id="hero-title">${esc(f.name)}</h1>
|
|
838
840
|
<p class="line">${esc(f.landing?.pitch ?? f.blurb)}</p>
|
|
839
|
-
<div class="row"><a class="play" href="${esc(openPath(f))}" data-play>${PLAY_ICON}<span>${f.kind === 'app' ? esc(
|
|
841
|
+
<div class="row"><a class="play" href="${esc(openPath(f))}" data-play>${PLAY_ICON}<span>${f.kind === 'app' ? esc(f.landing?.action ?? `Open ${f.name}`) : 'Play now — free'}</span></a>${liveLine(f, playing, { idle: f.kind === 'app' ? 'One screen, together' : 'Bots hold every empty seat · press Play and you are in a round' })}</div>
|
|
840
842
|
<a class="also" href="/${esc(f.id)}/">${icon('arrow')}<span>About ${esc(f.name)}</span></a>${games.length > 1 ? `<a class="also" href="/${f.kind === 'app' ? 'apps' : 'games'}/">${icon('games')}<span>All ${games.length} ${f.kind === 'app' ? 'apps' : 'games'}</span></a>` : ''}
|
|
841
843
|
</div>
|
|
842
844
|
</section>`;
|
|
@@ -1199,7 +1201,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
|
|
|
1199
1201
|
${lineage ? `<p class="based-on">Based on ${ofWhat}</p>` : ''}
|
|
1200
1202
|
<p class="line">${esc(L.pitch ?? g.blurb)}</p>
|
|
1201
1203
|
<div class="row">
|
|
1202
|
-
<a class="play" href="${esc(openPath(g))}" data-play>${PLAY_ICON}<span>${app ? esc(
|
|
1204
|
+
<a class="play" href="${esc(openPath(g))}" data-play>${PLAY_ICON}<span>${app ? esc(L.action ?? `Open ${g.name}`) : 'Play now — free'}</span></a>
|
|
1203
1205
|
${liveLine(g, playing, { idle: app ? 'One screen, live together' : `Bots hold every empty seat · one tap and you are in` })}
|
|
1204
1206
|
</div>
|
|
1205
1207
|
${tvOn ? `<a class="also" href="#anywhere">${icon('tv')}<span>Or put it on the big screen</span></a>` : ''}${playing > 0 && rooms.length && watchOf(g) !== 'off' ? `<a class="also" href="/${esc(g.id)}/watch">${icon('eye')}<span>Watch a live room</span></a>` : ''}${trailer ? `<a class="also" href="/videos/${esc(trailer.slug)}/">${icon('videos')}<span>Watch the trailer</span></a>` : ''}${g.saves ? `<a class="also" href="/account/?next=${encodeURIComponent(`/${g.id}/play`)}" data-account>${icon('spark')}<span>Your progress follows you: sign in</span></a>` : ''}
|
|
@@ -1270,7 +1272,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
|
|
|
1270
1272
|
head: `${h.tallImage || h.wideImage ? `<link rel="preload" as="image" href="${esc(h.tallImage ?? h.wideImage)}"${h.tallImage && h.wideImage ? ' media="(max-aspect-ratio: 3/4)"' : ''}>${h.tallImage && h.wideImage ? `<link rel="preload" as="image" href="${esc(h.wideImage)}" media="(min-aspect-ratio: 3/4)">` : ''}` : ''}`,
|
|
1271
1273
|
// A game that is not public yet (private, or an invite-only beta) is never indexed, and offers nothing.
|
|
1272
1274
|
ld: landingLd(cat, g, origin, { listed, shop }), ...(listed ? {} : { extraHeaders: { 'x-robots-tag': 'noindex' } }),
|
|
1273
|
-
main: `${hero}${app ? `<section class="band"><div class="band-in"><h2>One screen. Your place in it.</h2><p>${esc(L.about ?? g.blurb)}</p><div class="keys"><a class="btn" href="${esc(openPath(g))}">${esc(
|
|
1275
|
+
main: `${hero}${app ? `<section class="band"><div class="band-in"><h2>One screen. Your place in it.</h2><p>${esc(L.about ?? g.blurb)}</p><div class="keys"><a class="btn" href="${esc(openPath(g))}">${esc(L.action ?? `Open ${g.name}`)}</a><a class="ghost" href="/${esc(g.id)}/tv">Wall screen</a></div>${qr ? `<div class="scan"><span class="qr">${qr}</span><p>${esc(words.join)}</p></div>` : ''}</div></section>` : ways + liveBand}${servers ? serversBand(g, servers) : ''}${app ? '' : howBand}${shotsBand}${band(partial(cat, 'game', vars))}${band(partial(cat, `game-${g.id}`, vars))}${creditsBand}`,
|
|
1274
1276
|
});
|
|
1275
1277
|
}
|
|
1276
1278
|
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.51.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.51.3';
|