@homie-rocks/studio 0.37.1 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/bin/homie-studio.mjs +7 -6
- package/lib/cloudflare.mjs +28 -3
- package/lib/dev.mjs +1 -1
- package/lib/parts-build.mjs +23 -4
- package/lib/parts-cli.mjs +54 -3
- package/lib/parts-purchase.mjs +174 -0
- package/lib/parts-store.mjs +50 -19
- package/lib/parts-tools.mjs +15 -12
- package/lib/parts-upload.mjs +43 -0
- package/lib/parts.mjs +49 -5
- package/lib/perf.mjs +17 -10
- package/lib/port-check.mjs +77 -18
- package/lib/purchase-links.mjs +42 -0
- package/lib/purchase-wallet.mjs +114 -0
- package/lib/rules-check-worker.mjs +15 -2
- package/lib/scaffold.mjs +25 -5
- package/lib/shoot.mjs +12 -6
- package/lib/shop-links.mjs +15 -6
- package/lib/shop.mjs +91 -21
- package/lib/stripe-connect.mjs +2 -2
- package/lib/template-history.json +85 -0
- package/netplay/NETPLAY.md +20 -6
- package/netplay/netplay.ts +3 -1
- package/package.json +12 -2
- package/parts/COMMERCIAL-LICENCE.md +21 -0
- package/parts/PARTS.md +238 -10
- package/port/probe.ts +2 -1
- package/rules/core.ts +3 -1
- package/rules/host.ts +3 -1
- package/rules/pack.ts +30 -3
- package/rules/view.ts +232 -40
- package/starters/coin-dash/lab.json +17 -0
- package/starters/coin-dash/src/rules.ts +3 -3
- package/starters/gem-rush/game.json +19 -4
- package/starters/gem-rush/map/main.json +50 -0
- package/starters/gem-rush/src/move.ts +23 -0
- package/starters/gem-rush/src/rules.ts +118 -0
- package/starters/gem-rush/src/view.ts +429 -0
- package/starters/gem-rush/tunables.json +98 -11
- package/worker/extensions.mjs +13 -0
- package/worker/index.mjs +3 -2
- package/worker/machine-payments.mjs +72 -0
- package/worker/office.mjs +4 -1
- package/worker/parts-protected.mjs +111 -0
- package/worker/parts-purchase-catalogue.mjs +34 -0
- package/worker/parts-resource.mjs +32 -0
- package/worker/parts-sale.mjs +59 -0
- package/worker/parts.mjs +24 -11
- package/worker/payment-capabilities.mjs +70 -0
- package/worker/payment-dry-exchange.mjs +30 -0
- package/worker/payment-manual-refund.mjs +40 -0
- package/worker/payment-policy.mjs +40 -0
- package/worker/payment-recovery.mjs +163 -0
- package/worker/payment-test-exchange.mjs +19 -0
- package/worker/purchase-core.mjs +140 -0
- package/worker/purchase-crypto.mjs +17 -0
- package/worker/purchase-delivery.mjs +108 -0
- package/worker/purchase-discovery.mjs +26 -0
- package/worker/purchase-hosted-readiness.mjs +16 -0
- package/worker/purchase-keys.mjs +20 -0
- package/worker/purchase-links.mjs +9 -0
- package/worker/purchase-machine.mjs +279 -0
- package/worker/purchase-mcp.mjs +42 -0
- package/worker/purchase-office-routes.mjs +17 -0
- package/worker/purchase-office-script.mjs +38 -0
- package/worker/purchase-office.mjs +35 -0
- package/worker/purchase-owner.mjs +243 -0
- package/worker/purchase-pricing.mjs +8 -0
- package/worker/purchase-problem.mjs +4 -0
- package/worker/purchase-reconciliation.mjs +582 -0
- package/worker/purchase-response.mjs +82 -0
- package/worker/purchase-routes.mjs +611 -0
- package/worker/purchase-schema.mjs +50 -0
- package/worker/purchase-store.mjs +148 -0
- package/worker/purchases.mjs +6 -0
- package/worker/resource-kinds.mjs +20 -0
- package/worker/selling/index.mjs +8 -0
- package/worker/selling/runtime.mjs +42 -0
- package/worker/settlement-journal.mjs +138 -0
- package/worker/shop-page.mjs +7 -4
- package/worker/shop-refunds.mjs +22 -0
- package/worker/shop-rules.mjs +1 -1
- package/worker/shop.mjs +18 -18
- package/worker/stripe.mjs +36 -3
- package/worker/version.mjs +1 -1
- package/starters/gem-rush/src/main.ts +0 -1087
package/lib/port-check.mjs
CHANGED
|
@@ -60,6 +60,25 @@ function yawDeg(a, b) {
|
|
|
60
60
|
const d = a[5] * b[5] + a[6] * b[6]; const la = Math.hypot(a[5], a[6]) || 1; const lb = Math.hypot(b[5], b[6]) || 1;
|
|
61
61
|
return (Math.acos(Math.max(-1, Math.min(1, d / (la * lb)))) * 180) / Math.PI;
|
|
62
62
|
}
|
|
63
|
+
/** A server room keeps its authority and advances; a browser room must promote a host. */
|
|
64
|
+
export function roomContinued(before, after, serverHosted) {
|
|
65
|
+
return serverHosted
|
|
66
|
+
? after?.hosted === 'server' && after?.role === 'replica' && Number.isFinite(before?.tick) && after?.tick > before.tick && after?.frames > before.frames
|
|
67
|
+
: after?.role === 'host';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Compare two observations on the same browser clock, including time spent taking screenshots. */
|
|
71
|
+
export function roundClockContinued(before, after) {
|
|
72
|
+
return !before?.round || !after?.round || before.round.n !== after.round.n
|
|
73
|
+
|| Math.abs((before.round.leftMs - after.round.leftMs) - (after.observedAt - before.observedAt)) < 6000;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A wall can leave too little runway; retry the same hold after moving away, never excuse steering faults. */
|
|
77
|
+
export function needsRunway(res) {
|
|
78
|
+
return !res.ok && (res.why === 'barely moved' || res.why?.startsWith('blocked at once'))
|
|
79
|
+
&& res.yawChangeDeg < 10 && (res.dirErrDeg === null || res.dirErrDeg < 15);
|
|
80
|
+
}
|
|
81
|
+
|
|
63
82
|
const finite = (r) => r && Number.isFinite(r[1]) && Number.isFinite(r[2]);
|
|
64
83
|
/** A frame the player steers (the probe's `busy` says when a knockback, a stun or a respawn has the body). */
|
|
65
84
|
const steerable = (r) => finite(r) && !r[8];
|
|
@@ -134,6 +153,9 @@ export function judgePresses(rows, presses, size, view) {
|
|
|
134
153
|
const out = presses.map((p) => {
|
|
135
154
|
const seg = rows.filter((r) => r[0] >= p.a && r[0] <= p.b + 60 && steerable(r));
|
|
136
155
|
if (seg.length < 4) return rows.some((r) => r[0] >= p.a && r[0] <= p.b && finite(r) && r[8]) ? { dir: p.dir, ok: false, blocked: true, busy: true } : { dir: p.dir, ok: false, why: 'no samples' };
|
|
156
|
+
// The first post-input frame can already show the response. Keep its preceding drawn pose.
|
|
157
|
+
const before = rows.filter((r) => r[0] < p.a).at(-1);
|
|
158
|
+
if (before && steerable(before)) seg.unshift(before);
|
|
137
159
|
const u = DIRV[p.dir]; const r0 = seg[0];
|
|
138
160
|
let matchAt = null, after = 0, afterOk = 0, maxYaw = 0, peakAlong = 0, contact = false;
|
|
139
161
|
for (let i = 1; i < seg.length; i++) {
|
|
@@ -258,7 +280,7 @@ async function webkit(pw) {
|
|
|
258
280
|
const frameOf = (h) => h.page.frames().find((f) => f.url().includes('/__game/')) ?? null;
|
|
259
281
|
async function inFrame(h, src, v = null) { const f = frameOf(h); return f ? T(f.evaluate(src), 10_000, v) : v; }
|
|
260
282
|
const shell = (h) => T(h.page.evaluate(() => { const s = window.__shell; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, results: s.results ?? [], facts: s.facts ? { counts: s.facts.counts } : null } : null; }), 8000, null);
|
|
261
|
-
const info = (h) => inFrame(h, '(() => { const p = window.__homiePort; if (!p) return null; const i = p.info(); const n = window.__homieNet; i.net = n ? { snapHzIn: n.stats().snapHzIn, snapHzOut: n.stats().snapHzOut, rtt: n.stats().rtt, promotions: n.stats().promotions } : null; i.v = p.view; i.size = p.size; i.keys = p.keys; i.thumb = p.thumb; return i; })()');
|
|
283
|
+
const info = (h) => inFrame(h, '(() => { const p = window.__homiePort; if (!p) return null; const i = p.info(); i.observedAt = performance.now(); const n = window.__homieNet; i.net = n ? { snapHzIn: n.stats().snapHzIn, snapHzOut: n.stats().snapHzOut, rtt: n.stats().rtt, promotions: n.stats().promotions } : null; i.hosted = n?.probe?.hosted?.() ?? null; i.tick = n?.probe?.tick?.() ?? null; i.v = p.view; i.size = p.size; i.keys = p.keys; i.thumb = p.thumb; return i; })()');
|
|
262
284
|
const rowsSince = (h, t) => inFrame(h, `(window.__homiePort ? window.__homiePort.rows(${Number(t) || 0}) : [])`, []);
|
|
263
285
|
const frameNow = (h) => inFrame(h, '(window.__homiePort ? window.__homiePort.now() : performance.now())', 0);
|
|
264
286
|
|
|
@@ -285,13 +307,14 @@ async function focusGame(h) {
|
|
|
285
307
|
|
|
286
308
|
/* ------------------------------------------------------------------ input */
|
|
287
309
|
|
|
288
|
-
async function holdKey(h, code, ms) { await h.page.keyboard.down(code); await sleep(ms); await h.page.keyboard.up(code); }
|
|
310
|
+
async function holdKey(h, code, ms, ready) { await h.page.keyboard.down(code); if (ready) await ready(); await sleep(ms); await h.page.keyboard.up(code); }
|
|
289
311
|
|
|
290
312
|
function thumbAt(h, i) { const t = i?.thumb ?? [0.24, 0.74]; return [Math.round(h.vp.width * t[0]), Math.round(h.vp.height * t[1])]; }
|
|
291
|
-
async function touchDrag(h, from, dir, reach, holdMs) {
|
|
313
|
+
async function touchDrag(h, from, dir, reach, holdMs, ready) {
|
|
292
314
|
const [x0, y0] = from; const u = DIRV[dir];
|
|
293
315
|
await h.touch('touchStart', [{ id: 1, x: x0, y: y0 }]);
|
|
294
316
|
for (let k = 1; k <= 8; k++) { await h.touch('touchMove', [{ id: 1, x: Math.round(x0 + (u[0] * reach * k) / 8), y: Math.round(y0 + (u[1] * reach * k) / 8) }]); await sleep(12); }
|
|
317
|
+
if (ready) await ready();
|
|
295
318
|
const end = Date.now() + holdMs;
|
|
296
319
|
let w = 0;
|
|
297
320
|
while (Date.now() < end) {
|
|
@@ -302,6 +325,31 @@ async function touchDrag(h, from, dir, reach, holdMs) {
|
|
|
302
325
|
}
|
|
303
326
|
await h.touch('touchEnd', h.touchAll ? [] : [{ id: 1, x: Math.round(x0 + u[0] * reach), y: Math.round(y0 + u[1] * reach) }]);
|
|
304
327
|
}
|
|
328
|
+
/** Timestamp real input delivery, then keep it held until four frames can judge its direction.
|
|
329
|
+
* The response limit stays 600 ms; slow automation delivery and the thumb's ramp are not game latency. */
|
|
330
|
+
export async function measuredPress(h, how, code, from, dir) {
|
|
331
|
+
await inFrame(h, `(() => {
|
|
332
|
+
if (!window.__homieCheckInput) {
|
|
333
|
+
const state = window.__homieCheckInput = {};
|
|
334
|
+
addEventListener('keydown', e => { if (state.code === e.code && state.key === null) state.key = performance.now(); }, true);
|
|
335
|
+
addEventListener('touchmove', () => { state.touch = performance.now(); }, { capture: true, passive: true });
|
|
336
|
+
}
|
|
337
|
+
Object.assign(window.__homieCheckInput, { code: ${JSON.stringify(code)}, key: null, touch: null });
|
|
338
|
+
})()`);
|
|
339
|
+
let a = null, b = null;
|
|
340
|
+
const ready = async () => {
|
|
341
|
+
a = await inFrame(h, `window.__homieCheckInput.${how === 'keys' ? 'key' : 'touch'}`);
|
|
342
|
+
if (!Number.isFinite(a)) throw new Error('the game frame did not receive the measured input');
|
|
343
|
+
await sleep(420);
|
|
344
|
+
const until = Date.now() + 3000;
|
|
345
|
+
while (Date.now() < until && (await rowsSince(h, a)).length < 4) await sleep(50);
|
|
346
|
+
b = await frameNow(h);
|
|
347
|
+
};
|
|
348
|
+
if (how === 'keys') await holdKey(h, code, 0, ready);
|
|
349
|
+
else await touchDrag(h, from, dir, 70, 0, ready);
|
|
350
|
+
return { dir, a, b };
|
|
351
|
+
}
|
|
352
|
+
|
|
305
353
|
async function swipe(h, dir) {
|
|
306
354
|
const x0 = Math.round(h.vp.width * 0.5); const y0 = Math.round(h.vp.height * 0.55); const u = DIRV[dir]; const reach = Math.round(Math.min(h.vp.width, h.vp.height) * 0.3);
|
|
307
355
|
await h.touch('touchStart', [{ id: 2, x: x0, y: y0 }]);
|
|
@@ -365,11 +413,22 @@ async function ownerTests(h, how, log) {
|
|
|
365
413
|
const holds = view === 'side' ? ['right', 'left'] : ['down', 'left'];
|
|
366
414
|
out.holds = [];
|
|
367
415
|
for (const dir of holds) {
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
416
|
+
const hold = async () => {
|
|
417
|
+
const a = await frameNow(h);
|
|
418
|
+
if (how === 'keys') await holdKey(h, keys[dir], 5000); else await touchDrag(h, thumbAt(h, i), dir, 70, 5000);
|
|
419
|
+
const b = await frameNow(h);
|
|
420
|
+
await sleep(250);
|
|
421
|
+
return judgeHold(await rowsSince(h, a - 50), dir, a, b, size, view);
|
|
422
|
+
};
|
|
423
|
+
let res = await hold();
|
|
424
|
+
if (needsRunway(res)) {
|
|
425
|
+
const first = res;
|
|
426
|
+
const away = { up: 'down', down: 'up', left: 'right', right: 'left' }[dir];
|
|
427
|
+
log(` ${h.engine}-${h.kind} ${how} hold ${dir}: ${first.why}; move ${away} and retry the same hold`);
|
|
428
|
+
if (how === 'keys') await holdKey(h, keys[away], 1100); else await touchDrag(h, thumbAt(h, i), away, 70, 1100);
|
|
429
|
+
await sleep(400);
|
|
430
|
+
res = { ...await hold(), runwayRetry: first };
|
|
431
|
+
}
|
|
373
432
|
out.holds.push(res);
|
|
374
433
|
log(` ${h.engine}-${h.kind} ${how} hold ${dir}: ${res.ok ? 'ok' : `FAIL (${res.why})`} travel ${res.travelBodies} bodies, yaw ${res.yawChangeDeg}°`);
|
|
375
434
|
await sleep(400);
|
|
@@ -383,12 +442,9 @@ async function ownerTests(h, how, log) {
|
|
|
383
442
|
const presses = [];
|
|
384
443
|
const a0 = await frameNow(h);
|
|
385
444
|
const until = Date.now() + 10_000;
|
|
386
|
-
for (let k = 0; Date.now() < until; k++) {
|
|
445
|
+
for (let k = 0; k < alt.length || Date.now() < until; k++) {
|
|
387
446
|
const dir = alt[k % alt.length];
|
|
388
|
-
|
|
389
|
-
if (how === 'keys') await holdKey(h, keys[dir], 420); else await touchDrag(h, thumbAt(h, i), dir, 70, 380);
|
|
390
|
-
const b = await frameNow(h);
|
|
391
|
-
presses.push({ dir, a, b });
|
|
447
|
+
presses.push(await measuredPress(h, how, keys[dir], thumbAt(h, i), dir));
|
|
392
448
|
await sleep(170);
|
|
393
449
|
}
|
|
394
450
|
await sleep(200);
|
|
@@ -595,10 +651,13 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
595
651
|
if (!oa.ok || !ob.ok) { row('host-kill', false, { why: 'a browser was never seated' }); row('late-join', null, { why: 'skipped: no room' }); collect(A); collect(B); await close(A); await close(B); }
|
|
596
652
|
else {
|
|
597
653
|
await sleep(3000);
|
|
598
|
-
const ia = await info(A);
|
|
599
|
-
|
|
654
|
+
const ia = await info(A); let ib = await info(B);
|
|
655
|
+
const serverHosted = ia?.hosted === 'server' && ib?.hosted === 'server';
|
|
656
|
+
if (ia?.role !== 'host' && !serverHosted) row('host-kill', null, { why: `the first browser was not the host (it was ${ia?.role}); skipped`, ia: ia?.role, ib: ib?.role });
|
|
600
657
|
else {
|
|
601
658
|
await shot(B, 'life-before');
|
|
659
|
+
// Screenshot encoding may span a round on a software renderer. Observe the room we actually leave.
|
|
660
|
+
ib = await info(B);
|
|
602
661
|
const pids = A.pid ? [A.pid, ...descendants(A.pid)] : [];
|
|
603
662
|
const killAt = Date.now();
|
|
604
663
|
for (const p of pids) { try { process.kill(p, 'SIGKILL'); } catch { /* gone */ } }
|
|
@@ -606,14 +665,14 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
606
665
|
collect(A);
|
|
607
666
|
void A.close();
|
|
608
667
|
let promoted = null; let ibAfter = null;
|
|
609
|
-
while (Date.now() - killAt < 15_000) { ibAfter = await info(B); if (ibAfter
|
|
668
|
+
while (Date.now() - killAt < 15_000) { ibAfter = await info(B); if (roomContinued(ib, ibAfter, serverHosted)) { promoted = Date.now() - killAt; break; } await sleep(100); }
|
|
610
669
|
await sleep(1500);
|
|
611
670
|
const ib2 = await info(B);
|
|
612
671
|
const sameRound = ib?.round && ib2?.round && (ib2.round.n === ib.round.n || (ib.round.phase === 'over' || ib.round.leftMs < 3000));
|
|
613
|
-
const clockOk =
|
|
672
|
+
const clockOk = roundClockContinued(ib, ib2);
|
|
614
673
|
const running = (ib2?.frames ?? 0) > (ibAfter?.frames ?? 0) && (ib2?.net?.snapHzOut ?? 0) >= 0;
|
|
615
674
|
await shot(B, 'life-after-kill');
|
|
616
|
-
row('host-kill', Boolean(promoted !== null && promoted <= 5000 && sameRound && clockOk && running), { promotedMs: promoted, roundBefore: ib?.round ?? null, roundAfter: ib2?.round ?? null, why: promoted === null ? 'nobody took over as host within 15 s' : promoted > 5000 ? `the phone took over after ${promoted} ms (over 5 s)` : !sameRound ? 'the round did not continue (a new round started)' : !clockOk ? 'the round clock jumped at the takeover' : undefined });
|
|
675
|
+
row('host-kill', Boolean(promoted !== null && promoted <= 5000 && sameRound && clockOk && running), { hosted: serverHosted ? 'server' : 'browser', promotedMs: serverHosted ? null : promoted, continuedMs: serverHosted ? promoted : null, roundBefore: ib?.round ?? null, roundAfter: ib2?.round ?? null, why: promoted === null ? (serverHosted ? 'the server room stopped after a browser left' : 'nobody took over as host within 15 s') : promoted > 5000 ? `the phone took over after ${promoted} ms (over 5 s)` : !sameRound ? 'the round did not continue (a new round started)' : !clockOk ? 'the round clock jumped at the takeover' : undefined });
|
|
617
676
|
// Late joiner into the same, running room.
|
|
618
677
|
const C = await launch('desk');
|
|
619
678
|
const joinAt = Date.now();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Uses the shop's authorized Stripe API transport; no API credential goes to the Worker. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { sharedPartsOf, readPart, packedDir } from './parts.mjs';
|
|
6
|
+
import { canonicalJson } from '../worker/referrals.mjs';
|
|
7
|
+
import { paymentLinkURL } from '../worker/shop-links.mjs';
|
|
8
|
+
const hash = (v) => createHash('sha256').update(canonicalJson(v)).digest('hex');
|
|
9
|
+
export async function syncPurchaseLinks({ root, api, slug, site, mode, list, till }) {
|
|
10
|
+
const file = join(root, 'parts', 'offers.json');
|
|
11
|
+
const settings = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
|
|
12
|
+
const offers = sharedPartsOf(root).flatMap(({id, versions, part}) => versions.flatMap((version) => {
|
|
13
|
+
const p = readPart(packedDir(root,id,version)), config = settings[id], selected = config?.releases?.[version];
|
|
14
|
+
return p.sale && config?.active !== false && selected?.active !== false && (version === versions.at(-1) || selected?.keepOnSale === true) ? [{kind:'part',resource:id,version,name:part.name,sale:{...p.sale,...config?.price}}] : [];
|
|
15
|
+
}));
|
|
16
|
+
const configured = offers.flatMap(o => {
|
|
17
|
+
const quantities = settings[o.resource]?.quantities ?? [1];
|
|
18
|
+
if (!Array.isArray(quantities) || !quantities.length || quantities.some(q=>!Number.isSafeInteger(q) || q<1 || !Number.isSafeInteger(q*o.sale.amount))) throw new Error(`Invalid Payment Link quantities for ${o.resource}`);
|
|
19
|
+
return [...new Set(quantities)].map(quantity=>({...o,quantity}));
|
|
20
|
+
});
|
|
21
|
+
const links = (await list('/v1/payment_links')).filter((l) => l.metadata?.homie === 'purchase-links-v1' && l.metadata?.studio === slug && l.metadata?.origin === site);
|
|
22
|
+
const config = {v:1,mode,offers:[]}, keep = new Set();
|
|
23
|
+
for (const o of configured) {
|
|
24
|
+
const revision = hash({o,till});
|
|
25
|
+
let link = links.find((l)=>l.metadata?.revision === revision);
|
|
26
|
+
if (!link) {
|
|
27
|
+
const metadata = {homie:'purchase-links-v1',studio:slug,origin:site,revision};
|
|
28
|
+
const product = (await list('/v1/products')).find(p=>p.metadata?.homie === 'purchase-links-v1' && p.metadata?.studio === slug && p.metadata?.origin === site && p.metadata?.revision === revision) ?? await api('POST','/v1/products',{name:`${o.name} ${o.version}`, ...(o.sale.taxCode ? {tax_code:o.sale.taxCode}:{}),metadata},`purchase-product-${revision}`);
|
|
29
|
+
const price = (await list('/v1/prices')).find(p=>p.product === product.id && p.metadata?.revision === revision) ?? await api('POST','/v1/prices',{product:product.id,currency:o.sale.currency,unit_amount:o.sale.amount,...(o.sale.taxBehavior ? {tax_behavior:o.sale.taxBehavior}:{}),...(o.sale.billing !== 'one-time' ? {recurring:{interval:o.sale.billing}}:{}),metadata},`purchase-price-${revision}`);
|
|
30
|
+
link = await api('POST','/v1/payment_links',{line_items:[{price:price.id,quantity:o.quantity}],metadata,
|
|
31
|
+
...(o.sale.billing === 'one-time' ? {payment_intent_data:{metadata}}:{subscription_data:{metadata}}),
|
|
32
|
+
...(till === 'stripe-managed' ? {managed_payments:{enabled:true}} : {automatic_tax:{enabled:o.sale.automaticTax === true},...(o.sale.taxIdCollection?{tax_id_collection:{enabled:true}}:{}),...(o.sale.billing === 'one-time' && o.sale.invoiceCreation?{invoice_creation:{enabled:true}}:{}),...(o.sale.adaptivePricing === undefined?{}:{adaptive_pricing:{enabled:o.sale.adaptivePricing}})}),
|
|
33
|
+
...(o.sale.billing === 'one-time' && o.sale.customerCreation?{customer_creation:'always'}:{}),allow_promotion_codes:o.sale.promotionCodes === true,
|
|
34
|
+
after_completion:{type:'redirect',redirect:{url:`${site}/purchases/thanks`}}},`purchase-link-${revision}`);
|
|
35
|
+
}
|
|
36
|
+
if (!paymentLinkURL(link.url)) throw new Error('Stripe returned an invalid Payment Link');
|
|
37
|
+
if (!link.active) await api('POST',`/v1/payment_links/${link.id}`,{active:true});
|
|
38
|
+
keep.add(link.id);
|
|
39
|
+
config.offers.push({kind:o.kind,resource:o.resource,version:o.version,quantity:o.quantity,offer:canonicalJson(o.sale),id:link.id,url:link.url,revision});
|
|
40
|
+
}
|
|
41
|
+
return { config, retire:async()=>{ for (const l of links) if(l.active && !keep.has(l.id)) await api('POST',`/v1/payment_links/${l.id}`,{active:false}); } };
|
|
42
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** Payment authority and limits stay in the person's existing wallet client. */
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
const execute = promisify(execFile);
|
|
5
|
+
export async function payWithWallet(
|
|
6
|
+
url,
|
|
7
|
+
body,
|
|
8
|
+
{ client = process.env.HOMIE_PARTS_WALLET, pending, run = execute } = {},
|
|
9
|
+
) {
|
|
10
|
+
if (!client) return null;
|
|
11
|
+
if (!["link-cli", "purl", "tempo"].includes(client))
|
|
12
|
+
return { data: { detail: "Choose link-cli, purl or tempo as the wallet; hosted Checkout is available." } };
|
|
13
|
+
let args;
|
|
14
|
+
if (client === "link-cli") {
|
|
15
|
+
args = [
|
|
16
|
+
"mpp",
|
|
17
|
+
"pay",
|
|
18
|
+
url,
|
|
19
|
+
"-X",
|
|
20
|
+
"POST",
|
|
21
|
+
"-d",
|
|
22
|
+
JSON.stringify(body),
|
|
23
|
+
"--format",
|
|
24
|
+
"json",
|
|
25
|
+
];
|
|
26
|
+
if (pending?.id)
|
|
27
|
+
args.push(
|
|
28
|
+
"--spend-request-id",
|
|
29
|
+
pending.id,
|
|
30
|
+
"--approved-challenge",
|
|
31
|
+
pending.challenge,
|
|
32
|
+
);
|
|
33
|
+
else
|
|
34
|
+
args.push(
|
|
35
|
+
"--amount",
|
|
36
|
+
String(body.amount),
|
|
37
|
+
"--context",
|
|
38
|
+
`Purchase the digital resource ${body.resource} version ${body.version} from ${new URL(url).host} under the exact price and licence terms approved in this conversation. The selling studio delivers the files and signed purchase proof.`,
|
|
39
|
+
);
|
|
40
|
+
} else {
|
|
41
|
+
const atomic = BigInt(body.amount) * 10000n;
|
|
42
|
+
const purlLimit = process.env.PURL_MAX_AMOUNT;
|
|
43
|
+
if (purlLimit && !/^\d+$/.test(purlLimit))
|
|
44
|
+
return { data: { detail: "Invalid wallet maximum amount; use hosted Checkout or correct the wallet setting." } };
|
|
45
|
+
const tempoLimit = process.env.TEMPO_MAX_SPEND;
|
|
46
|
+
if (
|
|
47
|
+
tempoLimit &&
|
|
48
|
+
(!Number.isFinite(Number(tempoLimit)) || Number(tempoLimit) < 0)
|
|
49
|
+
)
|
|
50
|
+
return { data: { detail: "Invalid wallet maximum spend; use hosted Checkout or correct the wallet setting." } };
|
|
51
|
+
// Use the wallet's native per-request ceiling in its documented units.
|
|
52
|
+
const ceiling =
|
|
53
|
+
client === "tempo"
|
|
54
|
+
? [
|
|
55
|
+
"--max-spend",
|
|
56
|
+
String(
|
|
57
|
+
Math.min(
|
|
58
|
+
body.amount / 100,
|
|
59
|
+
tempoLimit ? Number(tempoLimit) : Infinity,
|
|
60
|
+
),
|
|
61
|
+
),
|
|
62
|
+
]
|
|
63
|
+
: [
|
|
64
|
+
"--max-amount",
|
|
65
|
+
String(
|
|
66
|
+
purlLimit && BigInt(purlLimit) < atomic
|
|
67
|
+
? BigInt(purlLimit)
|
|
68
|
+
: atomic,
|
|
69
|
+
),
|
|
70
|
+
"--network",
|
|
71
|
+
"base,base-sepolia",
|
|
72
|
+
"--output-format",
|
|
73
|
+
"json",
|
|
74
|
+
];
|
|
75
|
+
args = [
|
|
76
|
+
...(client === "tempo" ? ["request"] : []),
|
|
77
|
+
...ceiling,
|
|
78
|
+
"-X",
|
|
79
|
+
"POST",
|
|
80
|
+
"--json",
|
|
81
|
+
JSON.stringify(body),
|
|
82
|
+
url,
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
let stdout;
|
|
86
|
+
try { ({ stdout } = await run(client, args, {
|
|
87
|
+
encoding: "utf8",
|
|
88
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
89
|
+
timeout: 60000,
|
|
90
|
+
})); } catch (error) {
|
|
91
|
+
if (error.code === 'ENOENT') return { data: { detail: `The ${client} wallet is not installed; use hosted Checkout.` } };
|
|
92
|
+
// Wallets often return a structured refusal with a nonzero exit status.
|
|
93
|
+
// A process timeout or broken output is ambiguous: never start a second payment.
|
|
94
|
+
if (error.stdout?.trim()) stdout = error.stdout;
|
|
95
|
+
else if (/declined|insufficient funds|spend limit|not authenticated|not logged in|unsupported (network|method)|user rejected|user denied/i.test(error.stderr ?? ''))
|
|
96
|
+
return { data: { detail: String(error.stderr).trim(), paymentStatus: 'not_paid' } };
|
|
97
|
+
else return { uncertain: true, data: { detail: 'The wallet stopped without a payment result. Retry this purchase to reconcile it; do not pay again.' } };
|
|
98
|
+
}
|
|
99
|
+
let result;
|
|
100
|
+
try { result = JSON.parse(stdout); }
|
|
101
|
+
catch { return { uncertain: true, data: { detail: 'The wallet returned an unreadable payment result. Retry this purchase to reconcile it; do not pay again.' } }; }
|
|
102
|
+
if (result._next?.pay_argv) {
|
|
103
|
+
const next = result._next.pay_argv.args;
|
|
104
|
+
return {
|
|
105
|
+
pending: {
|
|
106
|
+
id: result.id,
|
|
107
|
+
challenge: next[next.indexOf("--approved-challenge") + 1],
|
|
108
|
+
},
|
|
109
|
+
approval: result.approval_url,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const value = result.body ?? result;
|
|
113
|
+
return { data: typeof value === "string" ? JSON.parse(value) : value };
|
|
114
|
+
}
|
|
@@ -183,12 +183,14 @@ function play(c, companions, allow) {
|
|
|
183
183
|
const free = (seat) => { if (!seated.has(seat)) return; seated.delete(seat); did.push(`seat ${seat} left`); frames.push({ t: 'free', seat }); };
|
|
184
184
|
const bots = (mode) => { policy = { ...(policy ?? {}), bots: mode }; frames.push({ t: 'policy', policy }); };
|
|
185
185
|
|
|
186
|
-
|
|
186
|
+
const movesToCheck = [];
|
|
187
|
+
const moved = (...args) => movesToCheck.push(args);
|
|
188
|
+
let A = open({ observe, noted, moved, send });
|
|
187
189
|
for (const m of told()) A.frame(m);
|
|
188
190
|
/** The room stops and starts again as a deploy restarts it: a new epoch, everybody away until they are back. */
|
|
189
191
|
const restart = () => {
|
|
190
192
|
const bytes = A.save(); const epoch = A.epoch + 1; A.stop();
|
|
191
|
-
A = open({ observe, noted, send, restore: bytes, restoreEpoch: epoch });
|
|
193
|
+
A = open({ observe, noted, moved, send, restore: bytes, restoreEpoch: epoch });
|
|
192
194
|
for (const m of told()) A.frame(m);
|
|
193
195
|
// Everybody is back at once, but for the highest seat, which is a second late.
|
|
194
196
|
const top = Math.max(...seated.keys());
|
|
@@ -342,6 +344,17 @@ function play(c, companions, allow) {
|
|
|
342
344
|
const stats = A.core.stats; const cut = stats.ticksCut; const held = stats.held;
|
|
343
345
|
A.tickNow();
|
|
344
346
|
units += A.core.stats.tickUnits;
|
|
347
|
+
for (const [name, tick, input, before, after] of movesToCheck.splice(0)) {
|
|
348
|
+
const kind = c.kinds.find(k => k.name === name);
|
|
349
|
+
moveTick = tick;
|
|
350
|
+
const result = L.P.stepMove(kind.move, before, input, moveCtx.get(name), Math.max(1, Math.floor(budget / 4)), kind.motion, c.dims,
|
|
351
|
+
error => { fault ??= `${siteOf(name, 'move')} ${name}.move: prediction failed: ${error.message}.${when()}`; });
|
|
352
|
+
units += result.used;
|
|
353
|
+
const r = kind.body.radius, bounds = c.map.bounds;
|
|
354
|
+
result.pos = L.P.vec3({ x: Math.max(bounds.min.x + r, Math.min(bounds.max.x - r, result.pos.x)), y: Math.max(bounds.min.y + r, Math.min(bounds.max.y - r, result.pos.y)), z: result.pos.z }, c.dims);
|
|
355
|
+
delete result.used;
|
|
356
|
+
if (JSON.stringify(result) !== JSON.stringify(after)) fault ??= `${siteOf(name, 'move')} ${name}.move: prediction and the server produced different bodies from the same input.${when()}`;
|
|
357
|
+
}
|
|
345
358
|
if (A.core.stats.tickUnits > peak) peak = A.core.stats.tickUnits;
|
|
346
359
|
if (A.core.stats.maxUnits > most) { most = A.core.stats.maxUnits; worst = A.core.stats.worst; }
|
|
347
360
|
const facts = A.facts();
|
package/lib/scaffold.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
|
|
2
|
+
import { PURCHASE_MIGRATION, PURCHASE_MIGRATION_FILE, PURCHASE_STATE, PURCHASE_STATE_FILE } from '../worker/purchase-schema.mjs';
|
|
2
3
|
/**
|
|
3
4
|
* `homie-studio new <folder> --name "<Studio Name>"` — a studio is ONE
|
|
4
5
|
* monorepo: a visible folder the person opens in Claude or Codex.
|
|
@@ -65,7 +66,7 @@ export function slugify(name) {
|
|
|
65
66
|
* No `database_id` until one is known: Wrangler (4.45.0+) and the Deploy to Cloudflare flow create the database
|
|
66
67
|
* the binding names and keep it linked.
|
|
67
68
|
*/
|
|
68
|
-
export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null, triggers = null }) {
|
|
69
|
+
export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null, paidParts = false, partsRateLimit = null, triggers = null }) {
|
|
69
70
|
const at = layout === 'site' ? { schema: '../node_modules', main: 'src/worker.mjs', dist: './dist', migrations: 'migrations' }
|
|
70
71
|
: { schema: 'node_modules', main: 'site/src/worker.mjs', dist: './site/dist', migrations: 'site/migrations' };
|
|
71
72
|
const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }];
|
|
@@ -79,19 +80,20 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
|
|
|
79
80
|
// subdomain) reaches it instead of failing with 1042.
|
|
80
81
|
// `disallow_eval_during_startup`: no code is made from a string while the Worker loads (Cloudflare never allows it
|
|
81
82
|
// later). A game's rules run in this Worker (NETPLAY.md section 29), and nothing in them is ever made from text.
|
|
82
|
-
compatibility_flags: ['global_fetch_strictly_public', 'disallow_eval_during_startup'],
|
|
83
|
+
compatibility_flags: ['global_fetch_strictly_public', 'disallow_eval_during_startup', ...(paidParts ? ['nodejs_compat'] : [])],
|
|
84
|
+
...(paidParts ? { alias: { '@homie-rocks/studio/worker': '@homie-rocks/studio/worker/selling' }, triggers: { crons: ['*/5 * * * *'] }, ratelimits: [partsRateLimit ?? { name: 'PURCHASE_RATE_LIMITER', namespace_id: '1001', simple: { limit: 60, period: 60 } }] } : {}),
|
|
83
85
|
workers_dev: true,
|
|
84
86
|
preview_urls: true,
|
|
85
87
|
// The studio's own custom-domain and exact-host routes, as its owner wrote them: this file is written again by
|
|
86
88
|
// every deploy, and one written without them sends the studio's domain back to whatever else the zone routes
|
|
87
89
|
// (lib/routes.mjs). A studio without routes gets no key at all.
|
|
88
90
|
...(routes?.length ? { routes } : {}),
|
|
89
|
-
...(triggers ? { triggers } : {}),
|
|
91
|
+
...(triggers ? { triggers: paidParts ? { ...triggers, crons: [...new Set([...(triggers.crons ?? []), '*/5 * * * *'])] } : triggers } : {}),
|
|
90
92
|
assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: true },
|
|
91
93
|
durable_objects: { bindings: rooms },
|
|
92
94
|
migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }],
|
|
93
95
|
d1_databases: [{ binding: 'DB', database_name: d1, ...(d1Id ? { database_id: d1Id } : {}), migrations_dir: at.migrations }],
|
|
94
|
-
...(r2 ? { r2_buckets: [{ binding: 'MEDIA', bucket_name: r2 }] } : {}),
|
|
96
|
+
...(r2 ? { r2_buckets: [{ binding: 'MEDIA', bucket_name: r2 }, ...(paidParts ? [{ binding: 'PURCHASE_MEDIA', bucket_name: `${r2.slice(0, 52)}-purchases` }] : [])] } : {}),
|
|
95
97
|
// Workers AI, only when a server's AI guides think with it (agents_brain workers-ai; deploy adds it, 0.17.0).
|
|
96
98
|
// Never in Previews: they do not inherit it, so a Preview's guides answer from the game's script.
|
|
97
99
|
...(ai ? { ai: { binding: 'AI' } } : {}),
|
|
@@ -747,6 +749,8 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
|
747
749
|
[`site/migrations/${SERVERS_MIGRATION_FILE}`]: SERVERS_MIGRATION,
|
|
748
750
|
[`site/migrations/${CHAT_MIGRATION_FILE}`]: CHAT_MIGRATION,
|
|
749
751
|
[`site/migrations/${SHOP_MIGRATION_FILE}`]: SHOP_MIGRATION,
|
|
752
|
+
[`site/migrations/${PURCHASE_MIGRATION_FILE}`]: PURCHASE_MIGRATION,
|
|
753
|
+
[`site/migrations/${PURCHASE_STATE_FILE}`]: PURCHASE_STATE,
|
|
750
754
|
[`site/migrations/${SHOP_RESERVATIONS_FILE}`]: SHOP_RESERVATIONS,
|
|
751
755
|
[`site/migrations/${SHOP_STATEMENTS_FILE}`]: SHOP_STATEMENTS,
|
|
752
756
|
[`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
|
|
@@ -906,8 +910,24 @@ function ensureAppsMigration(root) {
|
|
|
906
910
|
mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, APPS_MIGRATION);
|
|
907
911
|
return `site/migrations/${APPS_MIGRATION_FILE}`;
|
|
908
912
|
}
|
|
913
|
+
export function ensurePartsMigration(root) {
|
|
914
|
+
const file = join(root, 'site', 'migrations', PURCHASE_MIGRATION_FILE);
|
|
915
|
+
if (existsSync(file)) return null;
|
|
916
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
917
|
+
writeFileSync(file, PURCHASE_MIGRATION);
|
|
918
|
+
return `site/migrations/${PURCHASE_MIGRATION_FILE}`;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function ensurePartsStateMigration(root) {
|
|
922
|
+
const file = join(root, 'site', 'migrations', PURCHASE_STATE_FILE);
|
|
923
|
+
if (existsSync(file)) return null;
|
|
924
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
925
|
+
writeFileSync(file, PURCHASE_STATE);
|
|
926
|
+
return `site/migrations/${PURCHASE_STATE_FILE}`;
|
|
927
|
+
}
|
|
928
|
+
|
|
909
929
|
export function ensureMigrations(root) {
|
|
910
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root)].filter(Boolean);
|
|
930
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root)].filter(Boolean);
|
|
911
931
|
}
|
|
912
932
|
|
|
913
933
|
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
package/lib/shoot.mjs
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*
|
|
28
28
|
* `--preview` (no --url): THE FRAMES HALF WITHOUT THE WHOLE SITE. It starts the light preview server itself
|
|
29
29
|
* (lib/preview.mjs: the one built game's files on 127.0.0.1, no Wrangler, no rooms), opens the game's own page there
|
|
30
|
-
* and shoots it.
|
|
30
|
+
* and shoots it. A game with offline play enabled plays alone, with its bots, which is what pictures of the game itself want. The
|
|
31
31
|
* smoke is NOT RUN (there is no room to meet in: that half needs `dev`), and there is no loading cover to wait for.
|
|
32
32
|
* The result says which build the frames are of (`build`: its hash, the one `build` printed) and that the page
|
|
33
33
|
* really loaded that build's hashed bundle (`bundle`: lib/build.mjs bundleOf, never a guess at assets/main.js).
|
|
@@ -115,7 +115,7 @@ export const VIRTUAL_CLOCK = `(() => {
|
|
|
115
115
|
window.__homieClock = {
|
|
116
116
|
get frozen() { return frozen; },
|
|
117
117
|
now: perfNow,
|
|
118
|
-
freeze() { if (frozen) return vPerf; /* a whole millisecond (never earlier than the last real reading): stepped time is then base + n steps exactly, with no float dust between a step asked for and the time handed out */ vPerf = Math.ceil(R.perf()); vDate = R.D.now(); frozen = true; stepAnims(0); return vPerf; },
|
|
118
|
+
freeze() { if (frozen) return vPerf; /* a whole millisecond (never earlier than the last real reading): stepped time is then base + n steps exactly, with no float dust between a step asked for and the time handed out */ vPerf = Math.ceil(R.perf()); vDate = R.D.now(); frozen = true; runTimers(vPerf); stepAnims(0); return vPerf; },
|
|
119
119
|
step(ms) {
|
|
120
120
|
if (!frozen) this.freeze();
|
|
121
121
|
const d = Math.max(0, Number(ms) || 0);
|
|
@@ -274,7 +274,11 @@ export async function shoot({ url, game, frames = 60, fps = 30, device = 'comput
|
|
|
274
274
|
const inAll = (fn, arg) => Promise.all(all().map((f) => T(f.evaluate(fn, arg), 20_000)));
|
|
275
275
|
const frozen = await inAll(() => (window.__homieClock ? (window.__homieClock.freeze(), true) : false));
|
|
276
276
|
if (!frozen.some(Boolean)) { result.why = 'the virtual clock is not in the page (it was opened before the script could be injected)'; return result; }
|
|
277
|
-
if (hold)
|
|
277
|
+
if (hold) {
|
|
278
|
+
// The play page owns the top-level focus; the game's key listener lives in its iframe.
|
|
279
|
+
if (gf) await T(gf.evaluate(() => window.focus()), 5000);
|
|
280
|
+
await first.page.keyboard.down(hold).catch(() => {});
|
|
281
|
+
}
|
|
278
282
|
const rows = [];
|
|
279
283
|
for (let n = 1; n <= frames; n++) {
|
|
280
284
|
if (left() < 3000) { result.partial = `stopped at frame ${n - 1} of ${frames}: the ${Math.round(timeoutMs / 1000)} s bound was reached (a slow renderer; ask for fewer frames or a longer --timeout)`; break; }
|
|
@@ -287,6 +291,7 @@ export async function shoot({ url, game, frames = 60, fps = 30, device = 'comput
|
|
|
287
291
|
rows.push({ n, file, virtualMs: +(n * (1000 / fps)).toFixed(3), clockMs: nows.find((v) => Number.isFinite(v)) ?? null, realMs: Date.now() - t, ...(await frameFacts(first) ?? {}) });
|
|
288
292
|
}
|
|
289
293
|
if (hold) await first.page.keyboard.up(hold).catch(() => {});
|
|
294
|
+
result.rules = gf ? await T(gf.evaluate(() => { const p = window.__homieNet?.probe; return typeof p?.hosted === 'function' ? { hosted: p.hosted(), status: p.status?.(), tick: p.tick?.() } : null; }), 5000) : null;
|
|
290
295
|
result.frames = rows.length;
|
|
291
296
|
result.realSeconds = +((Date.now() - started) / 1000).toFixed(1);
|
|
292
297
|
result.virtualSeconds = +((rows.length * 1000) / fps / 1000).toFixed(3);
|
|
@@ -294,10 +299,11 @@ export async function shoot({ url, game, frames = 60, fps = 30, device = 'comput
|
|
|
294
299
|
result.limits = 'Stepped: requestAnimationFrame, performance.now, Date, setTimeout, setInterval, CSS and Web animations, in the page and the game\'s frame. NOT stepped: the room\'s socket and its server clock (other players and the round arrive in real time), Web Audio\'s clock, <video>, workers. These frames show what was drawn; they are not a frame rate.';
|
|
295
300
|
const probed = rows.some((r) => r.phase !== undefined);
|
|
296
301
|
if (!probed) result.note = [result.note, 'the game exposes no port probe (exposePort): the frames carry no round phase or position'].filter(Boolean).join('; ');
|
|
297
|
-
writeFileSync(join(out, 'shoot.json'), `${JSON.stringify({ v: 1, kind: 'homie-shoot', ...result, rows }, null, 1)}\n`);
|
|
298
302
|
const smokeOk = result.smoke.ok !== false;
|
|
299
|
-
result.
|
|
300
|
-
|
|
303
|
+
const rulesOk = !result.rules || (result.rules.status === 'playing' && result.rules.tick > 0);
|
|
304
|
+
result.ok = rows.length === frames && smokeOk && rulesOk && !result.errors.length;
|
|
305
|
+
if (!result.ok) result.why = !smokeOk ? `smoke: ${result.smoke.why}` : !rulesOk ? 'The rules game is not playing. A server-only build needs its room: run homie-studio dev and shoot with --url (or explicitly enable room.offline for offline play).' : result.errors.length ? `game errors: ${result.errors.join('; ')}` : result.partial;
|
|
306
|
+
writeFileSync(join(out, 'shoot.json'), `${JSON.stringify({ v: 1, kind: 'homie-shoot', ...result, rows }, null, 1)}\n`);
|
|
301
307
|
return result;
|
|
302
308
|
} finally {
|
|
303
309
|
if (served?.server) { served.server.closeAllConnections?.(); served.server.close(); }
|
package/lib/shop-links.mjs
CHANGED
|
@@ -3,10 +3,10 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { productIdOf, WEBHOOK_SECRET_SHAPE } from '../worker/stripe.mjs';
|
|
4
4
|
import { defaultTaxCode } from '../worker/shop-rules.mjs';
|
|
5
5
|
import { LINKS_STRIPE_VERSION, linkSettings, paymentLinkURL } from '../worker/shop-links.mjs';
|
|
6
|
-
import { HOOK_EVENTS } from './shop.mjs';
|
|
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({ api, shop, slug, site, mode, w, receipt, saveReceipt }) {
|
|
9
|
+
export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, receipt, saveReceipt }) {
|
|
10
10
|
const list = async (path, params = {}) => {
|
|
11
11
|
const all = []; let cursor;
|
|
12
12
|
do {
|
|
@@ -62,26 +62,35 @@ export async function syncPaymentLinks({ api, shop, slug, site, mode, w, receipt
|
|
|
62
62
|
keepLinks.add(link.id);
|
|
63
63
|
config.items.push({ item: item.id, id: link.id, url: link.url, revision: linkRevision });
|
|
64
64
|
}
|
|
65
|
+
const purchases = root ? await (await import('./purchase-links.mjs')).syncPurchaseLinks({ root, api, slug, site, mode, list, till: shop.purchasesTill ?? shop.till }) : null;
|
|
66
|
+
const purchaseSecrets = {};
|
|
67
|
+
if (purchases?.config.offers.length && !has('PURCHASE_SIGNING_KEYS')) {
|
|
68
|
+
const { generateKeyPair, exportJWK } = await import('jose');
|
|
69
|
+
const pair = await generateKeyPair('Ed25519', { extractable: true });
|
|
70
|
+
purchaseSecrets.PURCHASE_SIGNING_KEYS = JSON.stringify([await exportJWK(pair.privateKey)]);
|
|
71
|
+
}
|
|
72
|
+
const events = purchases?.config.offers.length ? HOOK_EVENTS : SHOP_HOOK_EVENTS;
|
|
65
73
|
const endpoints = await list('/v1/webhook_endpoints');
|
|
66
74
|
const url = `${site}/api/shop/hook`;
|
|
67
|
-
const current = endpoints.find((e) => e.id === receipt?.endpoint && e.url === url && e.status === 'enabled' &&
|
|
75
|
+
const current = endpoints.find((e) => e.id === receipt?.endpoint && e.url === url && e.status === 'enabled' && events.every((event) => e.enabled_events?.includes(event)));
|
|
68
76
|
let endpoint = current, secret;
|
|
69
77
|
if (!endpoint || !has('STRIPE_WEBHOOK_SECRET')) {
|
|
70
78
|
const pending = receipt?.url === url && receipt?.pending ? receipt.pending : randomUUID();
|
|
71
79
|
saveReceipt({ ...receipt, pending, mode, url }); // Retry the same private creation response; never persist the secret.
|
|
72
|
-
endpoint = await api('POST', '/v1/webhook_endpoints', { url, enabled_events: [...
|
|
80
|
+
endpoint = await api('POST', '/v1/webhook_endpoints', { url, enabled_events: [...events], api_version: LINKS_STRIPE_VERSION, metadata: { homie: 'shop-v1' }, description: 'Studio shop' }, `webhook-${pending}`);
|
|
73
81
|
if (!WEBHOOK_SECRET_SHAPE.test(endpoint.secret ?? '')) throw new Error('Missing signing secret');
|
|
74
82
|
secret = endpoint.secret;
|
|
75
83
|
}
|
|
76
|
-
const fingerprint = hash(config);
|
|
84
|
+
const fingerprint = hash({config, purchases:purchases?.config});
|
|
77
85
|
const unchanged = current && !secret && receipt?.fingerprint === fingerprint && has('STRIPE_SHOP_LINKS') && !has('STRIPE_KEY');
|
|
78
86
|
if (!unchanged) {
|
|
79
|
-
const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_SHOP_LINKS: JSON.stringify(config), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
|
|
87
|
+
const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_SHOP_LINKS: JSON.stringify(config), ...purchaseSecrets, ...(purchases && (purchases.config.offers.length || has('PURCHASE_LINKS')) ? { PURCHASE_LINKS: JSON.stringify(purchases.config) } : {}), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
|
|
80
88
|
if (saved.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Cloudflare could not save the shop connection. Run shop connect again; existing webhooks remain active.' };
|
|
81
89
|
// Explicitly running connect selects keyless; manual selects the existing keyed checkout.
|
|
82
90
|
if (has('STRIPE_KEY') && (await w(['secret', 'delete', 'STRIPE_KEY'], { input: 'y\n' })).code !== 0) return { ok: false, needs: 'cloudflare', why: 'The links are ready; run shop connect again to finish removing the old checkout credential.' };
|
|
83
91
|
saveReceipt({ mode, endpoint: endpoint.id, url, fingerprint, keyless: true });
|
|
84
92
|
}
|
|
93
|
+
await purchases?.retire();
|
|
85
94
|
// Retire only after the replacement configuration and signing secret are installed.
|
|
86
95
|
for (const link of links.filter((p) => own(p) && p.active && !keepLinks.has(p.id))) await api('POST', `/v1/payment_links/${link.id}`, { active: false });
|
|
87
96
|
for (const price of prices.filter((p) => own(p) && p.active && !keepPrices.has(p.id))) await api('POST', `/v1/prices/${price.id}`, { active: false });
|