@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
|
@@ -1,13 +1,100 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
2
|
+
"public": {
|
|
3
|
+
"knockRange": {
|
|
4
|
+
"value": 2.2,
|
|
5
|
+
"min": 1.2,
|
|
6
|
+
"max": 4.0,
|
|
7
|
+
"step": 0.02,
|
|
8
|
+
"unit": "m",
|
|
9
|
+
"group": "Knock",
|
|
10
|
+
"note": "How near a body must be for a wave to bump it"
|
|
11
|
+
},
|
|
12
|
+
"hitStopMs": {
|
|
13
|
+
"value": 70,
|
|
14
|
+
"min": 0,
|
|
15
|
+
"max": 200,
|
|
16
|
+
"step": 5,
|
|
17
|
+
"unit": "ms",
|
|
18
|
+
"group": "Knock",
|
|
19
|
+
"note": "The hit lands: the bumped body holds, squashed and shaking, before it flies"
|
|
20
|
+
},
|
|
21
|
+
"knockDistance": {
|
|
22
|
+
"value": 3.7,
|
|
23
|
+
"min": 0.8,
|
|
24
|
+
"max": 8.0,
|
|
25
|
+
"step": 0.1,
|
|
26
|
+
"unit": "m",
|
|
27
|
+
"group": "Knock",
|
|
28
|
+
"note": "How far a bump sends a body, the same at any frame rate"
|
|
29
|
+
},
|
|
30
|
+
"knockMs": {
|
|
31
|
+
"value": 420,
|
|
32
|
+
"min": 120,
|
|
33
|
+
"max": 1000,
|
|
34
|
+
"step": 10,
|
|
35
|
+
"unit": "ms",
|
|
36
|
+
"group": "Knock",
|
|
37
|
+
"note": "How long the slide takes"
|
|
38
|
+
},
|
|
39
|
+
"knockEase": {
|
|
40
|
+
"value": 4,
|
|
41
|
+
"min": 1,
|
|
42
|
+
"max": 6,
|
|
43
|
+
"step": 0.1,
|
|
44
|
+
"group": "Knock",
|
|
45
|
+
"note": "1 slides at one speed; higher leaves faster and eases into the stop"
|
|
46
|
+
},
|
|
47
|
+
"squash": {
|
|
48
|
+
"value": 0.35,
|
|
49
|
+
"min": 0,
|
|
50
|
+
"max": 0.8,
|
|
51
|
+
"step": 0.01,
|
|
52
|
+
"group": "Body",
|
|
53
|
+
"note": "How much a flying body stretches along the hit, and squashes when it stops"
|
|
54
|
+
},
|
|
55
|
+
"settleMs": {
|
|
56
|
+
"value": 260,
|
|
57
|
+
"min": 0,
|
|
58
|
+
"max": 700,
|
|
59
|
+
"step": 10,
|
|
60
|
+
"unit": "ms",
|
|
61
|
+
"group": "Body",
|
|
62
|
+
"note": "The wobble after the stop (overlap)"
|
|
63
|
+
},
|
|
64
|
+
"flashMs": {
|
|
65
|
+
"value": 110,
|
|
66
|
+
"min": 0,
|
|
67
|
+
"max": 300,
|
|
68
|
+
"step": 5,
|
|
69
|
+
"unit": "ms",
|
|
70
|
+
"group": "Hit",
|
|
71
|
+
"note": "How long the bumped body flashes white"
|
|
72
|
+
},
|
|
73
|
+
"shake": {
|
|
74
|
+
"value": 7,
|
|
75
|
+
"min": 0,
|
|
76
|
+
"max": 24,
|
|
77
|
+
"step": 0.5,
|
|
78
|
+
"unit": "px",
|
|
79
|
+
"group": "Hit",
|
|
80
|
+
"note": "The camera's kick when you bump or are bumped"
|
|
81
|
+
},
|
|
82
|
+
"sparks": {
|
|
83
|
+
"value": 9,
|
|
84
|
+
"min": 0,
|
|
85
|
+
"max": 24,
|
|
86
|
+
"step": 1,
|
|
87
|
+
"group": "Hit",
|
|
88
|
+
"note": "Sparks thrown off the hit"
|
|
89
|
+
},
|
|
90
|
+
"ringMs": {
|
|
91
|
+
"value": 900,
|
|
92
|
+
"min": 200,
|
|
93
|
+
"max": 2000,
|
|
94
|
+
"step": 10,
|
|
95
|
+
"unit": "ms",
|
|
96
|
+
"group": "Wave",
|
|
97
|
+
"note": "How long a wave's ring lasts"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
13
100
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Optional selling integration. Only the selling entry installs provider adapters. */
|
|
2
|
+
export const selling = {
|
|
3
|
+
enabled: false,
|
|
4
|
+
machineCapabilities: () => ({ machine: {} }),
|
|
5
|
+
priceWords: () => 'Free',
|
|
6
|
+
purchaseRoutes: () => null,
|
|
7
|
+
purchasePaymentEvent: () => null,
|
|
8
|
+
resourceOrder: () => null,
|
|
9
|
+
listOffers: () => [],
|
|
10
|
+
paidFile: () => new Response('not found', { status: 404 }),
|
|
11
|
+
jobsSQL: () => '0',
|
|
12
|
+
};
|
|
13
|
+
export function useSelling(adapters) { Object.assign(selling, adapters); }
|
package/worker/index.mjs
CHANGED
|
@@ -346,6 +346,7 @@ function etagMatches(header, etag) {
|
|
|
346
346
|
* object stored without one.
|
|
347
347
|
*/
|
|
348
348
|
async function mediaObject(request, env, key, { cache = 'public, max-age=3600', type = null } = {}) {
|
|
349
|
+
if (key.startsWith('paid-parts/')) return new Response('not found', { status: 404 });
|
|
349
350
|
const range = request.headers.get('range');
|
|
350
351
|
const inm = request.headers.get('if-none-match');
|
|
351
352
|
const headOnly = request.method === 'HEAD';
|
|
@@ -399,7 +400,7 @@ async function heldInR2(env, getCat, path) {
|
|
|
399
400
|
try { want = decodeURIComponent(path); } catch { return null; }
|
|
400
401
|
for (const e of [...(cat.songs ?? []), ...(cat.videos ?? [])]) {
|
|
401
402
|
for (const f of e.files ?? []) {
|
|
402
|
-
if (typeof f.r2 !== 'string' || !f.r2 || f.r2.startsWith('players/') || typeof f.url !== 'string') continue;
|
|
403
|
+
if (typeof f.r2 !== 'string' || !f.r2 || (f.r2.startsWith('players/') || f.r2.startsWith('paid-parts/')) || typeof f.url !== 'string') continue;
|
|
403
404
|
let at;
|
|
404
405
|
try { at = decodeURIComponent(f.url); } catch { continue; }
|
|
405
406
|
if (at === want) return f;
|
|
@@ -749,7 +750,7 @@ async function route(request, env, ctx) {
|
|
|
749
750
|
if (!env.MEDIA) return new Response('this studio keeps no media in R2 yet', { status: 404 });
|
|
750
751
|
const key = decodeURIComponent(path.slice('/media/'.length));
|
|
751
752
|
// A player's large saves live under players/ in the same bucket: never served here (saves/SAVES.md).
|
|
752
|
-
if (!key || key.includes('..') || key.startsWith('players/')) return new Response('not found', { status: 404 });
|
|
753
|
+
if (!key || key.includes('..') || (key.startsWith('players/') || key.startsWith('paid-parts/'))) return new Response('not found', { status: 404 });
|
|
753
754
|
return mediaObject(request, env, key);
|
|
754
755
|
}
|
|
755
756
|
if (path === '/posts/feed.xml' || path === '/posts/feed.json') {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { journalFacilitator, rpcFor } from './settlement-journal.mjs';
|
|
2
|
+
import { machineCapabilities } from './payment-capabilities.mjs';
|
|
3
|
+
/** Standards adapters: the libraries own challenges, credentials and settlement. */
|
|
4
|
+
import Stripe from "stripe";
|
|
5
|
+
import { Mppx, stripe, Transport } from "mppx/server";
|
|
6
|
+
import { HTTPFacilitatorClient } from "@x402/core/server";
|
|
7
|
+
import { createFacilitatorConfig } from "@coinbase/x402";
|
|
8
|
+
import { paymentStore } from "./purchase-core.mjs";
|
|
9
|
+
import { digest } from "./purchase-crypto.mjs";
|
|
10
|
+
|
|
11
|
+
import { modeOf, apiBase } from "./stripe.mjs";
|
|
12
|
+
|
|
13
|
+
export function stripeClient(env) {
|
|
14
|
+
const base = new URL(apiBase(env));
|
|
15
|
+
return new Stripe(env.STRIPE_KEY, {
|
|
16
|
+
httpClient: Stripe.createFetchHttpClient(),
|
|
17
|
+
host: base.hostname,
|
|
18
|
+
port: base.port ? Number(base.port) : undefined,
|
|
19
|
+
protocol: base.protocol.slice(0, -1),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function paymentProtocol(
|
|
24
|
+
env,
|
|
25
|
+
order,
|
|
26
|
+
{ mcp = false, client, facilitator, realm, capabilities, chainRpc } = {},
|
|
27
|
+
) {
|
|
28
|
+
const mode = modeOf(env.STRIPE_KEY);
|
|
29
|
+
const config = JSON.parse(env.PURCHASE_MACHINE_PAYMENTS ?? "{}")[mode];
|
|
30
|
+
if (!config?.profile)
|
|
31
|
+
throw new Error("Configure the studio Stripe profile for machine payments");
|
|
32
|
+
client ??= stripeClient(env);
|
|
33
|
+
const payments = stripe.create({
|
|
34
|
+
client,
|
|
35
|
+
networkId: config.profile,
|
|
36
|
+
livemode: mode === "live",
|
|
37
|
+
metadata: { homie: "purchase-v1", order: order.id },
|
|
38
|
+
store: paymentStore(env.DB, mode),
|
|
39
|
+
});
|
|
40
|
+
capabilities ??= await machineCapabilities(env);
|
|
41
|
+
let methods = capabilities.machine.card ? [payments.spt.charge()] : [];
|
|
42
|
+
if (config.base && capabilities.machine.base && !mcp) {
|
|
43
|
+
facilitator ??= new HTTPFacilitatorClient(
|
|
44
|
+
mode === "live"
|
|
45
|
+
? createFacilitatorConfig(env.CDP_API_KEY_ID, env.CDP_API_KEY_SECRET)
|
|
46
|
+
: { url: "https://x402.org/facilitator" },
|
|
47
|
+
);
|
|
48
|
+
methods = [
|
|
49
|
+
...methods,
|
|
50
|
+
payments.base.charge({
|
|
51
|
+
recipient: config.base,
|
|
52
|
+
x402: { facilitator: journalFacilitator(env, order, facilitator, chainRpc ?? rpcFor(env, 'base', mode)), routeBinding: "resource" },
|
|
53
|
+
}),
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
methods = methods.map((method) => {
|
|
57
|
+
// mppx's two-phase methods call broadcast after validate; card methods use
|
|
58
|
+
// verify. Capture the bound challenge before either terminal operation.
|
|
59
|
+
const operation = method.broadcast ? 'broadcast' : 'verify';
|
|
60
|
+
return { ...method, async [operation](context) {
|
|
61
|
+
order.challengeId = context.credential.challenge.id;
|
|
62
|
+
return method[operation](context);
|
|
63
|
+
} };
|
|
64
|
+
});
|
|
65
|
+
const protocol = Mppx.create({
|
|
66
|
+
methods,
|
|
67
|
+
realm,
|
|
68
|
+
secretKey: await digest(`${env.STRIPE_KEY}:purchase-payment-challenges`),
|
|
69
|
+
...(mcp ? { transport: Transport.mcpSdk() } : {}),
|
|
70
|
+
});
|
|
71
|
+
return protocol;
|
|
72
|
+
}
|
package/worker/office.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { selling } from './extensions.mjs';
|
|
2
|
+
import { purchaseOrderIfPresent } from './shop.mjs';
|
|
1
3
|
/**
|
|
2
4
|
* THE STUDIO'S BACK OFFICE (@homie-rocks/studio 0.13.0): the studio's owner, and the owner's AI, see and run the
|
|
3
5
|
* studio's live games. Everything lives in the studio's own Worker and D1; homie.rocks stores none of it.
|
|
@@ -1255,6 +1257,7 @@ async function api(request, env, url, cat) {
|
|
|
1255
1257
|
return lounge ? json({ ok: true, lounge }) : json({ ok: false, error: 'no-lounge', message: 'This studio has no Lounge: add "lounge": true to studio.json and deploy.' }, 404);
|
|
1256
1258
|
}
|
|
1257
1259
|
// The shop (0.24.0): the owner's view, the accountant's CSV, the referral statements.
|
|
1260
|
+
if (selling.enabled) { const result = await selling.officeRoutes(request, env, path, body); if (result) return result; }
|
|
1258
1261
|
if (path === '/_studio/api/shop' && request.method === 'GET') return json(await shopOffice(env, cat, url.origin, url.searchParams.get('cursor') ?? '', /^\d+$/.test(url.searchParams.get('itemCursor') ?? '') ? Number(url.searchParams.get('itemCursor')) : 0, /^\d+$/.test(url.searchParams.get('orderCursor') ?? '') ? Math.min(Number(url.searchParams.get('orderCursor')), Number.MAX_SAFE_INTEGER) : 0));
|
|
1259
1262
|
if (path === '/_studio/api/shop/orders.csv' && request.method === 'GET') {
|
|
1260
1263
|
try {
|
|
@@ -1283,7 +1286,7 @@ async function api(request, env, url, cat) {
|
|
|
1283
1286
|
if (op === 'server-create' || op === 'pass' || op === 'lounge-night') action.origin = url.origin;
|
|
1284
1287
|
if (op === 'refund') {
|
|
1285
1288
|
// The ask and the confirm page say what the order is (its item and price), read from the books now.
|
|
1286
|
-
const o = await orderById(env, action.order);
|
|
1289
|
+
const o = await orderById(env, action.order) ?? await purchaseOrderIfPresent(env, action.order);
|
|
1287
1290
|
if (!o) return json({ ok: false, error: 'order', message: 'No such order (homie-studio shop orders lists them).' }, 404);
|
|
1288
1291
|
Object.assign(action, { item: o.item, amount: Number(o.amount), currency: o.currency });
|
|
1289
1292
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { hasStripeKey, shopMode } from './shop-links.mjs';
|
|
2
|
+
/** Studio licences use the shop's Stripe, order book, webhook and refund office. */
|
|
3
|
+
import {
|
|
4
|
+
orderState,
|
|
5
|
+
recordOrderState,
|
|
6
|
+
entitlement,
|
|
7
|
+
} from "./purchase-core.mjs";
|
|
8
|
+
|
|
9
|
+
import { signingKey, publicKeys } from "./purchase-keys.mjs";
|
|
10
|
+
|
|
11
|
+
import { GRANT_AUDIENCE, PROOF_AUDIENCE, manifestHash, signGrant, verifyGrant } from "./purchase-crypto.mjs";
|
|
12
|
+
import { legalTerms, objectKey, publicFiles } from "./parts-sale.mjs";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
json,
|
|
16
|
+
fail,
|
|
17
|
+
byId,
|
|
18
|
+
bearer,
|
|
19
|
+
purchasedResource,
|
|
20
|
+
active,
|
|
21
|
+
privateRelease,
|
|
22
|
+
coveredPurchase,
|
|
23
|
+
deliveryAllowed,
|
|
24
|
+
} from "./purchase-store.mjs";
|
|
25
|
+
/** Protected content is never in ASSETS; withdrawn releases remain reachable by their buyer. */
|
|
26
|
+
export async function paidFile(...args) {
|
|
27
|
+
try {
|
|
28
|
+
return await servePaidFile(...args);
|
|
29
|
+
} catch {
|
|
30
|
+
return fail(
|
|
31
|
+
503,
|
|
32
|
+
"Purchase service temporarily unavailable. Retry with the same download credential.",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function servePaidFile(
|
|
37
|
+
request,
|
|
38
|
+
env,
|
|
39
|
+
url,
|
|
40
|
+
id,
|
|
41
|
+
version,
|
|
42
|
+
path,
|
|
43
|
+
type = "application/octet-stream",
|
|
44
|
+
) {
|
|
45
|
+
if (!env.PURCHASE_MEDIA) return null;
|
|
46
|
+
if (!env.PURCHASE_RATE_LIMITER) return fail(503, 'Deploy the purchase rate limiter');
|
|
47
|
+
if (!(await env.PURCHASE_RATE_LIMITER.limit({ key: `files:${request.headers.get('cf-connecting-ip') ?? 'unknown'}` })).success) return fail(429, 'Too many file requests; retry shortly');
|
|
48
|
+
const p = await privateRelease(env, 'part', id, version);
|
|
49
|
+
if (!p?.sale) return null;
|
|
50
|
+
const f = p.files.find((f) => f.path === path);
|
|
51
|
+
if (path !== "part.json" && !f) return fail(404, "No such file");
|
|
52
|
+
if (path === "part.json" || publicFiles(p).has(path)) {
|
|
53
|
+
if (path === "part.json") return json(p);
|
|
54
|
+
const obj = await env.PURCHASE_MEDIA.get(objectKey(f.sha256));
|
|
55
|
+
return obj
|
|
56
|
+
? new Response(request.method === "HEAD" ? null : obj.body, {
|
|
57
|
+
headers: {
|
|
58
|
+
"content-type": type,
|
|
59
|
+
"access-control-allow-origin": "*",
|
|
60
|
+
"content-security-policy":
|
|
61
|
+
"sandbox allow-scripts allow-pointer-lock",
|
|
62
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
63
|
+
"x-content-type-options": "nosniff",
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
: fail(503, "File missing from storage");
|
|
67
|
+
}
|
|
68
|
+
let claims;
|
|
69
|
+
try {
|
|
70
|
+
for (const key of await publicKeys(env)) {
|
|
71
|
+
try {
|
|
72
|
+
claims = await verifyGrant(bearer(request), key, {
|
|
73
|
+
issuer: url.origin,
|
|
74
|
+
resource: id,
|
|
75
|
+
kind: 'part',
|
|
76
|
+
version,
|
|
77
|
+
});
|
|
78
|
+
break;
|
|
79
|
+
} catch {
|
|
80
|
+
/* retained verification key */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
return fail(503, "Seller signing keys unavailable");
|
|
85
|
+
}
|
|
86
|
+
if (!claims) return fail(402, "Buy this part before downloading its files");
|
|
87
|
+
const o = await byId(env, claims.jti);
|
|
88
|
+
const core = o && (await orderState(env, o.id));
|
|
89
|
+
if (
|
|
90
|
+
!o || o.resource_kind !== 'part' ||
|
|
91
|
+
!(await deliveryAllowed(env, o)) ||
|
|
92
|
+
!active(o) ||
|
|
93
|
+
(core && !["paid", "fulfilled", "disputed"].includes(core.state)) ||
|
|
94
|
+
o.buyer !== claims.sub ||
|
|
95
|
+
o.claim_hash !== claims.claimVersion ||
|
|
96
|
+
o.mode !== claims.mode ||
|
|
97
|
+
!(await coveredPurchase(env, url.origin, o, p)) ||
|
|
98
|
+
claims.manifest !== (await manifestHash(p))
|
|
99
|
+
)
|
|
100
|
+
return fail(403, "This purchase does not permit this download");
|
|
101
|
+
const obj = await env.PURCHASE_MEDIA.get(objectKey(f.sha256));
|
|
102
|
+
if (!obj) return fail(503, "File missing from storage");
|
|
103
|
+
return new Response(request.method === "HEAD" ? null : obj.body, {
|
|
104
|
+
headers: {
|
|
105
|
+
"content-type": "application/octet-stream",
|
|
106
|
+
"cache-control": "no-store, private",
|
|
107
|
+
"x-content-type-options": "nosniff",
|
|
108
|
+
"content-security-policy": "sandbox",
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { selling } from './extensions.mjs';
|
|
2
|
+
import { partsIndex } from './parts.mjs';
|
|
3
|
+
const CORS = { 'access-control-allow-origin': '*', 'x-content-type-options': 'nosniff' };
|
|
4
|
+
export function productDescription(p, origin) {
|
|
5
|
+
const sale = p.sale;
|
|
6
|
+
const digits = sale ? new Intl.NumberFormat('en', { style: 'currency', currency: sale.currency }).resolvedOptions().maximumFractionDigits : 2;
|
|
7
|
+
return { '@context': 'https://schema.org', '@type': 'Product', name: p.name, description: p.summary, url: `${origin}/parts/${p.id}/`, ...(p.preview?.image ? { image: new URL(`${p.url}${p.preview.image}`, origin).href } : {}), ...(sale ? { offers: { '@type': 'Offer', price: (sale.amount / 10 ** digits).toFixed(digits), priceCurrency: sale.currency.toUpperCase(), url: `${origin}/parts/${p.id}/`, availability: p.releases?.some((r) => r.onSale) ? 'https://schema.org/InStock' : 'https://schema.org/Discontinued', ...(sale.billing !== 'one-time' ? { priceSpecification: { '@type': 'UnitPriceSpecification', price: (sale.amount / 10 ** digits).toFixed(digits), priceCurrency: sale.currency.toUpperCase(), billingDuration: ({day:'P1D',week:'P1W',month:'P1M',year:'P1Y'})[sale.billing], unitText: sale.scope } } : {}), description: `${sale.billing}; per ${sale.scope}; ${sale.refund}` } } : { offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD', availability: 'https://schema.org/InStock', url: `${origin}/parts/${p.id}/` } }) };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function entryDescription(p,cat,url,capabilities) { return {
|
|
11
|
+
product: productDescription(p, url.origin),
|
|
12
|
+
purchase: { methods: (cat.shop?.purchasesTill ?? cat.shop?.till) !== 'stripe-managed' && p.sale?.amount > 0 && p.sale?.billing === 'one-time' && p.sale?.currency === 'usd' && !p.sale?.automaticTax && p.sale?.taxBehavior !== 'exclusive' ? Object.entries(capabilities.machine).filter(([, enabled]) => enabled).map(([method]) => method) : [], hostedCheckout: { url: `${url.origin}/api/purchases/intent`, label: 'Hosted Checkout fallback' }, http: `${url.origin}/api/purchases/resource`, mcp: `${url.origin}/api/purchases/mcp`, keys: `${url.origin}/purchases/keys.json` },
|
|
13
|
+
}; }
|
|
14
|
+
export async function discoveryRoutes(request, env, url, { catalogueOf }) {
|
|
15
|
+
const path=url.pathname;
|
|
16
|
+
if (path === '/purchases/keys.json') return selling.purchaseRoutes(request, env, url, {});
|
|
17
|
+
if (path === '/openapi.json') {
|
|
18
|
+
|
|
19
|
+
return selling.purchaseDiscovery(env, url.origin, await catalogueOf());
|
|
20
|
+
}
|
|
21
|
+
const read = request.method === 'GET' || request.method === 'HEAD';
|
|
22
|
+
if (path === '/.well-known/api-catalog') {
|
|
23
|
+
if (!read) return new Response(null, { status: 405, headers: { allow: 'GET, HEAD' } });
|
|
24
|
+
const headers = { 'content-type': 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"', link: `<${url.origin}/.well-known/api-catalog>; rel="api-catalog"`, ...CORS };
|
|
25
|
+
const index = await partsIndex(env, url.origin);
|
|
26
|
+
const item = [{ href: `${url.origin}/parts/catalog.json`, type: 'application/json', title: 'Resources' }];
|
|
27
|
+
if (index.parts.some((p) => p.releases?.some((r) => r.onSale))) {
|
|
28
|
+
item.push({ href: `${url.origin}/api/purchases/mcp`, title: 'Purchases over MCP' });
|
|
29
|
+
if (env.PURCHASE_SIGNING_KEYS) item.push({ href: `${url.origin}/purchases/keys.json`, type: 'application/jwk-set+json', title: 'Purchase verification keys' });
|
|
30
|
+
}
|
|
31
|
+
return new Response(request.method === 'HEAD' ? null : JSON.stringify({ linkset: [{ anchor: `${url.origin}/.well-known/api-catalog`, 'service-desc': [{ href: `${url.origin}/openapi.json`, type: 'application/vnd.oai.openapi+json' }], item }] }), { headers });
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** A published part release is one purchasable resource kind. */
|
|
2
|
+
import { quoteHash, legalTerms, coversRelease, compareVersions, saleProblems, objectKey, releaseKey } from "./parts-sale.mjs";
|
|
3
|
+
export const partResource = Object.freeze({
|
|
4
|
+
async list(env, origin) {
|
|
5
|
+
const response = await env.ASSETS.fetch(new Request(`${origin}/parts/index.json`));
|
|
6
|
+
if (!response.ok) return [];
|
|
7
|
+
return ((await response.json()).parts ?? []).flatMap((p) => (p.releases ?? []).filter((r) => r.onSale && r.offer).map((r) => ({ id:p.id, name:p.name, version:r.version, offer:r.offer, offerVersion:r.offerVersion })));
|
|
8
|
+
},
|
|
9
|
+
async get(env, id, version) {
|
|
10
|
+
const object = await env.PURCHASE_MEDIA?.get(releaseKey(id,version));
|
|
11
|
+
return object ? object.json() : null;
|
|
12
|
+
},
|
|
13
|
+
quote: quoteHash,
|
|
14
|
+
terms: legalTerms,
|
|
15
|
+
validate(resource, selection) {
|
|
16
|
+
const errors = saleProblems(resource);
|
|
17
|
+
if (!selection) return errors;
|
|
18
|
+
const { quantity, game } = selection;
|
|
19
|
+
if (!Number.isSafeInteger(quantity) || quantity < 1 || !Number.isSafeInteger(quantity * resource.sale.amount)) errors.push('Invalid quantity');
|
|
20
|
+
if ((resource.sale.scope === 'game' && !/^[a-z0-9][a-z0-9-]{0,47}$/.test(game ?? '')) || (resource.sale.scope !== 'game' && game != null)) errors.push('Invalid game scope');
|
|
21
|
+
return errors;
|
|
22
|
+
},
|
|
23
|
+
termsUrl: (resource) => `/parts/${encodeURIComponent(resource.id)}/${encodeURIComponent(resource.version)}/${resource.licenseTerms.split('/').map(encodeURIComponent).join('/')}`,
|
|
24
|
+
readFile: (env,file,{head=false}={}) => head && env.PURCHASE_MEDIA?.head ? env.PURCHASE_MEDIA.head(objectKey(file.sha256)) : env.PURCHASE_MEDIA?.get(objectKey(file.sha256)),
|
|
25
|
+
async covers(env,origin,bought,wanted) {
|
|
26
|
+
if (coversRelease(bought,wanted)) return true;
|
|
27
|
+
const response = await env.ASSETS.fetch(new Request(`${origin}/parts/index.json`));
|
|
28
|
+
if (!response.ok) return false;
|
|
29
|
+
const release = (await response.json()).parts?.find((p)=>p.id===wanted.id)?.releases?.find((r)=>r.version===wanted.version);
|
|
30
|
+
return compareVersions(wanted.version,bought.version)>=0 && release?.upgrade==='included';
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { amountError } from './shop-rules.mjs';
|
|
2
|
+
import { digest, manifestHash } from "./purchase-crypto.mjs";
|
|
3
|
+
/** Paid-part metadata shared by the builder, buyer and Worker. No payment policy ceilings. */
|
|
4
|
+
import { canonicalJson } from './referrals.mjs';
|
|
5
|
+
|
|
6
|
+
export const quoteHash = async (part, quantity = 1, game = null, offer = part.sale) => digest(canonicalJson({ manifest: await manifestHash(part), part: part.id, release: part.version, scope: offer.scope, quantity, game, interval: offer.billing, currency: offer.currency, amount: offer.amount * quantity, offer }));
|
|
7
|
+
export const legalTerms = (p) => ({ license: p.license, terms: p.files?.find((f) => f.path === p.licenseTerms)?.sha256, scope: p.sale?.scope, source: p.sale?.source, commercialUse: p.sale?.commercialUse, transferable: p.sale?.transferable, onRefund: p.sale?.onRefund, onExpiry: p.sale?.onExpiry, refund: p.sale?.refund, updates: p.sale?.updates });
|
|
8
|
+
export const sameTerms = (a, b) => canonicalJson(legalTerms(a)) === canonicalJson(legalTerms(b));
|
|
9
|
+
export const publicFiles = (p) => new Set([p.preview?.page, p.preview?.image, p.licenseTerms, ...(p.files ?? []).filter((f) => /^LICENSES\/[^/]+\.txt$/.test(f.path)).map((f) => f.path), ...(p.sale?.publicFiles ?? [])].filter(Boolean));
|
|
10
|
+
export const objectKey = (hash) => `paid-parts/files/${hash}`;
|
|
11
|
+
export const releaseKey = (id, version) => `paid-parts/releases/${id}/${version}.json`;
|
|
12
|
+
|
|
13
|
+
export function saleProblems(p) {
|
|
14
|
+
if (p.sale === undefined) return [];
|
|
15
|
+
const s = p.sale; const errors = [];
|
|
16
|
+
if (!s || typeof s !== 'object' || Array.isArray(s)) return ['sale must be an object'];
|
|
17
|
+
const invalidAmount = amountError(s.amount, s.currency);
|
|
18
|
+
if (invalidAmount) errors.push(invalidAmount);
|
|
19
|
+
if (!Intl.supportedValuesOf('currency').map((v) => v.toLowerCase()).includes(s.currency) || /^(xau|xag|xpt|xpd|xxx|xts)$/.test(s.currency)) errors.push('currency must be a lower-case ISO currency code');
|
|
20
|
+
if (s.taxBehavior !== undefined && !['inclusive', 'exclusive', 'unspecified'].includes(s.taxBehavior)) errors.push('taxBehavior must be inclusive, exclusive or unspecified');
|
|
21
|
+
for (const key of ['immediateDelivery', 'automaticTax', 'adaptivePricing', 'promotionCodes', 'refundEndsSubscription', 'invoiceCreation', 'customerCreation', 'taxIdCollection']) if (s[key] !== undefined && typeof s[key] !== 'boolean') errors.push(`${key} must be true or false`);
|
|
22
|
+
if (s.refundWindowDays !== undefined && (!Number.isFinite(s.refundWindowDays) || s.refundWindowDays < 0)) errors.push('refundWindowDays must be a non-negative finite number');
|
|
23
|
+
if (s.renewalGraceMinutes !== undefined && (!Number.isFinite(s.renewalGraceMinutes) || s.renewalGraceMinutes < 0)) errors.push('renewalGraceMinutes must be a non-negative finite number');
|
|
24
|
+
if (s.intentMinutes !== undefined && (!Number.isFinite(s.intentMinutes) || s.intentMinutes <= 0)) errors.push('intentMinutes must be a positive finite number');
|
|
25
|
+
for (const [key, values] of Object.entries({ billing: ['one-time', 'day', 'week', 'month', 'year'], updates: ['none', 'major', 'all'], onRefund: ['retain', 'terminate'], onExpiry: ['retain', 'terminate'] })) if (!values.includes(s[key])) errors.push(`${key} must be ${values.join(', ')}`);
|
|
26
|
+
if (typeof s.scope !== 'string' || !s.scope.trim()) errors.push('scope must describe the licensed use');
|
|
27
|
+
for (const key of ['source', 'commercialUse', 'transferable']) if (typeof s[key] !== 'boolean') errors.push(`${key} must be true or false`);
|
|
28
|
+
if (typeof s.refund !== 'string' || !s.refund.trim()) errors.push('refund must state the seller refund terms');
|
|
29
|
+
if ((s.taxCode !== undefined || s.automaticTax === true) && !/^txcd_\d{8}$/.test(s.taxCode ?? '')) errors.push('taxCode must name the Stripe product tax code');
|
|
30
|
+
if (!p.licenseTerms || !p.files?.some((f) => f.path === p.licenseTerms)) errors.push('licenseTerms must name a hashed file containing the full licence');
|
|
31
|
+
if (s.publicFiles !== undefined && (!Array.isArray(s.publicFiles) || s.publicFiles.some((path) => !p.files?.some((f) => f.path === path)))) errors.push('publicFiles must list only hashed files deliberately offered before purchase');
|
|
32
|
+
return errors;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function coversRelease(bought, wanted) {
|
|
36
|
+
if (bought.id !== wanted.id) return false;
|
|
37
|
+
if (bought.version === wanted.version) return true;
|
|
38
|
+
if (bought.version.includes('-') || wanted.version.includes('-')) return false;
|
|
39
|
+
if (compareVersions(wanted.version, bought.version) < 0) return false;
|
|
40
|
+
return bought.sale.updates === 'all' || (bought.sale.updates === 'major' && wanted.version.split('.')[0] === bought.version.split('.')[0]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Semver precedence, including numeric versus text prerelease identifiers. */
|
|
44
|
+
export function compareVersions(a, b) {
|
|
45
|
+
const parse = (v) => { const dash = v.indexOf('-'); const core = dash < 0 ? v : v.slice(0, dash); const pre = dash < 0 ? undefined : v.slice(dash + 1); return { core: core.split('.').map(Number), pre: pre?.split('.') }; };
|
|
46
|
+
const left = parse(a); const right = parse(b);
|
|
47
|
+
for (let i = 0; i < 3; i++) if (left.core[i] !== right.core[i]) return left.core[i] < right.core[i] ? -1 : 1;
|
|
48
|
+
if (!left.pre || !right.pre) return left.pre ? -1 : right.pre ? 1 : 0;
|
|
49
|
+
for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i++) {
|
|
50
|
+
const x = left.pre[i]; const y = right.pre[i];
|
|
51
|
+
if (x === y) continue;
|
|
52
|
+
if (x === undefined || y === undefined) return x === undefined ? -1 : 1;
|
|
53
|
+
const nx = /^\d+$/.test(x); const ny = /^\d+$/.test(y);
|
|
54
|
+
if (nx && ny) return Number(x) < Number(y) ? -1 : 1;
|
|
55
|
+
if (nx !== ny) return nx ? -1 : 1;
|
|
56
|
+
return x < y ? -1 : 1;
|
|
57
|
+
}
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
package/worker/parts.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { selling } from './extensions.mjs';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* THE STUDIO'S SHARED GAME PARTS, AS ITS SITE SERVES THEM (parts/PARTS.md section 4).
|
|
3
5
|
*
|
|
@@ -17,7 +19,7 @@ const VERSION = /^\d{1,6}\.\d{1,6}\.\d{1,6}(?:-[0-9A-Za-z.-]{1,40})?$/;
|
|
|
17
19
|
/** A packed part's files: framed by the part's own page and by the hub, so they keep their own frame rule (index.mjs finish). */
|
|
18
20
|
export const PART_FILES = /^\/parts\/[a-z0-9][a-z0-9-]{0,47}\/\d{1,6}\.\d{1,6}\.\d{1,6}[^/]*\//;
|
|
19
21
|
export const PARTS_WELL_KNOWN = '/.well-known/homie-parts.json';
|
|
20
|
-
export const isPartsPath = (path) => path === PARTS_WELL_KNOWN || path === '/parts' || path.startsWith('/parts/');
|
|
22
|
+
export const isPartsPath = (path) => path === '/openapi.json' || path === '/.well-known/api-catalog' || path === '/parts/catalog.json' || path === '/purchases/keys.json' || path === PARTS_WELL_KNOWN || path === '/parts' || path.startsWith('/parts/');
|
|
21
23
|
|
|
22
24
|
const CORS = { 'access-control-allow-origin': '*', 'x-content-type-options': 'nosniff' };
|
|
23
25
|
const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json; charset=utf-8', ...headers } });
|
|
@@ -37,25 +39,29 @@ export async function partsIndex(env, origin) {
|
|
|
37
39
|
* One index entry as the world sees it: how to add it, and the game it came out of with its page and Play address.
|
|
38
40
|
* Only a PUBLIC game is named (a private or invite-only game's parts are still shared, without saying where from).
|
|
39
41
|
*/
|
|
40
|
-
function entryOf(p, cat, url) {
|
|
42
|
+
function entryOf(p, cat, url, capabilities = { machine: {} }) {
|
|
41
43
|
const g = (p.from?.game ?? p.from?.app) ? (cat.games ?? []).find((x) => x.id === (p.from.game ?? p.from.app)) : null;
|
|
42
44
|
const { from, ...rest } = p;
|
|
43
45
|
return {
|
|
44
46
|
...rest,
|
|
45
|
-
...(!g && from &&
|
|
47
|
+
...(!g && from && !from.game && !from.app ? { from } : {}),
|
|
46
48
|
...(g ? { from: { [g.kind === 'app' ? 'app' : 'game']: g.id, name: g.name ?? g.id, studio: cat.studio?.name ?? from.studio ?? null, page: `${url.origin}/${g.id}/`, [g.kind === 'app' ? 'open' : 'play']: `${url.origin}/${g.id}/${g.kind === 'app' ? 'open' : 'play'}` } } : {}),
|
|
47
49
|
add: `${url.host}/${p.id}`,
|
|
50
|
+
...(selling.entryDescription?.(p, cat, url, capabilities) ?? {}),
|
|
48
51
|
};
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
export async function partsRoutes(request, env, url, { catalogueOf }) {
|
|
52
55
|
const path = url.pathname;
|
|
56
|
+
if (!selling.enabled && ['/purchases/keys.json','/openapi.json','/.well-known/api-catalog'].includes(path)) return null;
|
|
57
|
+
if (selling.enabled) { const extra = await selling.discoveryRoutes(request, env, url, { catalogueOf }); if (extra) return extra; }
|
|
53
58
|
const read = request.method === 'GET' || request.method === 'HEAD';
|
|
54
|
-
if (path === PARTS_WELL_KNOWN) {
|
|
59
|
+
if (path === PARTS_WELL_KNOWN || path === '/parts/catalog.json') {
|
|
55
60
|
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: { ...CORS, 'access-control-allow-methods': 'GET, HEAD' } });
|
|
56
61
|
if (!read) return json({ ok: false, error: 'method' }, 405, { allow: 'GET, HEAD', ...CORS });
|
|
57
62
|
const [index, cat] = await Promise.all([partsIndex(env, url.origin), catalogueOf()]);
|
|
58
|
-
|
|
63
|
+
const capabilities = await selling.machineCapabilities(env);
|
|
64
|
+
return json({ v: 1, studio: { name: cat.studio?.name ?? index.studio?.name ?? null, url: url.origin }, parts: index.parts.map((p) => entryOf(p, cat, url, capabilities)) }, 200, { ...CORS, 'cache-control': 'public, max-age=300' });
|
|
59
65
|
}
|
|
60
66
|
const segs = path.split('/').filter(Boolean);
|
|
61
67
|
// /parts/<id>/<version>/<file…>: a packed file, only when the index names that part and that version.
|
|
@@ -67,6 +73,9 @@ export async function partsRoutes(request, env, url, { catalogueOf }) {
|
|
|
67
73
|
let file;
|
|
68
74
|
try { file = segs.slice(3).map(decodeURIComponent).join('/'); } catch { file = '..'; }
|
|
69
75
|
const gone = () => new Response('not found', { status: 404, headers: { ...CORS, 'cache-control': 'no-store' } });
|
|
76
|
+
if (file.split('/').some((s) => !s || s === '..' || s.startsWith('.')) || file.includes('\\')) return gone();
|
|
77
|
+
const protectedFile = part && !part.sale && !part.releases?.some((r) => r.version === segs[2] && r.sale) ? null : await selling.paidFile(request, env, url, segs[1], segs[2], file, TYPES[file.split('.').pop().toLowerCase()]);
|
|
78
|
+
if (protectedFile) return protectedFile;
|
|
70
79
|
if (!part || !(part.versions ?? []).includes(segs[2]) || file.split('/').some((s) => !s || s === '..' || s.startsWith('.'))) return gone();
|
|
71
80
|
const res = await env.ASSETS.fetch(new Request(`${url.origin}/parts/${segs[1]}/${segs[2]}/${file.split('/').map(encodeURIComponent).join('/')}`));
|
|
72
81
|
if (!res.ok) return gone();
|
|
@@ -104,7 +113,7 @@ export async function partsRoutes(request, env, url, { catalogueOf }) {
|
|
|
104
113
|
/* ------------------------------------------------------------------ the pages */
|
|
105
114
|
|
|
106
115
|
const KIND_WORDS = { character: 'Character', rig: 'Rig', clips: 'Animation clips', environment: 'Environment', effect: 'Effect', sound: 'Sound', ui: 'UI', mechanic: 'Mechanic', shader: 'Shader', 'level-generator': 'Level generator', 'bot-brain': 'Bot brain', 'audio-pack': 'Audio pack', 'set-piece': 'Set piece' };
|
|
107
|
-
const kindOf = (p) => KIND_WORDS[p.kind] ?? 'Part';
|
|
116
|
+
const kindOf = (p) => KIND_WORDS[p.kind] ?? p.kind ?? 'Part';
|
|
108
117
|
const CSS = `<style>
|
|
109
118
|
.pgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:18px;margin:8px 0 0;padding:0;list-style:none}
|
|
110
119
|
.pcard{display:flex;flex-direction:column;gap:8px;padding:18px;border:1px solid color-mix(in srgb,var(--fg) 14%,transparent);border-radius:16px;text-decoration:none;color:inherit;background:color-mix(in srgb,var(--fg) 3%,transparent)}
|
|
@@ -134,12 +143,12 @@ const head = (kicker, title, lead, extra = '') => `<header class="head"><p class
|
|
|
134
143
|
const studioOf = (cat) => cat.studio?.name ?? 'Studio';
|
|
135
144
|
const fromLine = (p) => ((p.from?.game ?? p.from?.app) ? `From <a href="/${esc((p.from.game ?? p.from.app))}/">${esc(p.from.name ?? (p.from.game ?? p.from.app))}</a>` : '');
|
|
136
145
|
/** What a person says to their AI to get the part, in plain words with its name and reference: no command to type. */
|
|
137
|
-
const sayAdd = (p) => `Add the "${p.name}" part from ${p.add} to my
|
|
146
|
+
const sayAdd = (p) => `Add the "${p.name}" part from ${p.add} to my studio`;
|
|
138
147
|
const sayRow = (label, say) => `<div class="psay"><div><span class="ptag">${esc(label)}</span><code>${esc(say)}</code></div><button class="pcopy" type="button" data-copy="${esc(say)}">${icon('copy')}<span data-copy-word>Copy</span></button></div>`;
|
|
139
148
|
|
|
140
149
|
function card(p) {
|
|
141
150
|
const img = p.preview?.image ? `<img src="${esc(`${p.url}${p.preview.image}`)}" alt="" loading="lazy">` : '';
|
|
142
|
-
return `<li><a class="pcard" href="/parts/${esc(p.id)}/">${img}<span class="ptag">${esc(kindOf(p))} · ${esc(p.license ?? '')}</span><h3>${esc(p.name)}</h3><p>${esc(p.summary ?? '')}</p>${(p.from?.game ?? p.from?.app) ? `<p>From ${esc(p.from.name ?? (p.from.game ?? p.from.app))}</p>` : ''}</a></li>`;
|
|
151
|
+
return `<li><a class="pcard" href="/parts/${esc(p.id)}/">${img}<span class="ptag">${esc(kindOf(p))} · ${esc(p.license ?? '')} · ${esc(selling.priceWords(p.sale))}</span><h3>${esc(p.name)}</h3><p>${esc(p.summary ?? '')}</p>${(p.from?.game ?? p.from?.app) ? `<p>From ${esc(p.from.name ?? (p.from.game ?? p.from.app))}</p>` : ''}</a></li>`;
|
|
143
152
|
}
|
|
144
153
|
|
|
145
154
|
export function partsPage(cat, parts, url) {
|
|
@@ -149,7 +158,7 @@ export function partsPage(cat, parts, url) {
|
|
|
149
158
|
for (const p of parts) { const k = (p.from?.game ?? p.from?.app) ?? ''; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(p); }
|
|
150
159
|
const body = [...groups].sort(([a], [b]) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b))).map(([k, list]) => `<p class="sec"${k ? ` id="from-${esc(k)}"` : ''}>${k ? `From ${esc(list[0].from.name ?? k)}` : 'More parts'}</p><ul class="pgrid">${list.map(card).join('')}</ul>`).join('');
|
|
151
160
|
return layout(cat, {
|
|
152
|
-
title: `Game parts · ${name}`, description: `Pieces of ${name}'s games that other studios can use in their own: creatures, levels, mechanics, sounds. Each with its licence.`, origin: url.origin, path: '/parts/', page: 'parts', head: CSS,
|
|
161
|
+
title: `Game parts · ${name}`, description: `Pieces of ${name}'s games that other studios can use in their own: creatures, levels, mechanics, sounds. Each with its licence.`, origin: url.origin, path: '/parts/', page: 'parts', head: CSS + (selling.enabled ? `<script type="application/ld+json">${JSON.stringify({ '@context': 'https://schema.org', '@type': 'ItemList', itemListElement: parts.map((p, i) => ({ '@type': 'ListItem', position: i + 1, item: productDescription(p, url.origin) })) }).replaceAll('<', '\\u003c')}</script>` : ''),
|
|
153
162
|
main: `${head('Game parts', 'Take a piece, make a game', `Pieces of ${name}'s games, shared one by one. Take the creature from one game, the level from another and the bot brain from a third, and make something new. Each part says its licence and who to credit.`)}
|
|
154
163
|
<div class="wrap">${body}</div>`,
|
|
155
164
|
});
|
|
@@ -207,6 +216,8 @@ function previewOf(p) {
|
|
|
207
216
|
<a class="popen" href="${src}" target="_blank" rel="noopener noreferrer">${icon('arrow')}<span>Open the preview in its own tab</span></a>`;
|
|
208
217
|
}
|
|
209
218
|
|
|
219
|
+
export const productDescription = (p, origin) => selling.productDescription?.(p, origin);
|
|
220
|
+
|
|
210
221
|
export function partPage(cat, p, url) {
|
|
211
222
|
const name = studioOf(cat);
|
|
212
223
|
const preview = p.preview?.page
|
|
@@ -216,6 +227,8 @@ export function partPage(cat, p, url) {
|
|
|
216
227
|
['Kind', esc(kindOf(p))],
|
|
217
228
|
p.uses?.length ? ['For', esc(p.uses.join(', '))] : null,
|
|
218
229
|
(p.from?.game ?? p.from?.app) ? [p.from.app ? 'From the app' : 'From the game', `<a href="/${esc((p.from.game ?? p.from.app))}/">${esc(p.from.name ?? (p.from.game ?? p.from.app))}</a> · <a href="/${esc((p.from.game ?? p.from.app))}/${p.from.app ? 'open' : 'play'}">${p.from.app ? 'Open it' : 'Play it'}</a>`] : null,
|
|
230
|
+
['Price', esc(selling.priceWords(p.sale))],
|
|
231
|
+
p.sale ? ['Purchase terms', esc(`${p.sale.refund} Source: ${p.sale.source ? 'included' : 'not included'}. Updates: ${p.sale.updates}.`)] : null,
|
|
219
232
|
['Licence', `${esc(p.license ?? 'none named')}${p.license && !/^LicenseRef-/.test(p.license) ? ` · <a href="https://spdx.org/licenses/${esc(p.license)}.html" rel="noopener">what it allows</a>` : ''}`],
|
|
220
233
|
p.attribution ? ['Credit', esc(p.attribution)] : null,
|
|
221
234
|
['Version', `${esc(p.version)}${(p.versions ?? []).length > 1 ? ` (also ${esc(p.versions.filter((v) => v !== p.version).join(', '))})` : ''}`],
|
|
@@ -228,7 +241,7 @@ export function partPage(cat, p, url) {
|
|
|
228
241
|
(p.tags ?? []).length ? ['Tags', esc(p.tags.join(', '))] : null,
|
|
229
242
|
].filter(Boolean);
|
|
230
243
|
return layout(cat, {
|
|
231
|
-
title: `${p.name} · a game part from ${name}`, description: `${p.summary ?? ''} A ${kindOf(p).toLowerCase()} from ${name}${p.from?.name ? `'s ${p.from.name}` : ''}, shared under ${p.license ?? 'its own terms'}.`.trim(), origin: url.origin, path: `/parts/${p.id}/`, page: 'part', head: CSS,
|
|
244
|
+
title: `${p.name} · a game part from ${name}`, description: `${p.summary ?? ''} A ${kindOf(p).toLowerCase()} from ${name}${p.from?.name ? `'s ${p.from.name}` : ''}, shared under ${p.license ?? 'its own terms'}.`.trim(), origin: url.origin, path: `/parts/${p.id}/`, page: 'part', head: CSS + (selling.enabled ? `<script type="application/ld+json">${JSON.stringify(productDescription(p, url.origin)).replaceAll('<', '\\u003c')}</script>` : ''),
|
|
232
245
|
image: p.preview?.image ? `${p.url}${p.preview.image}` : null,
|
|
233
246
|
main: `${head(`${kindOf(p)} · shared part`, p.name, p.summary ?? '', `<div class="keys"><a class="ghost" href="/parts/">${icon('arrow')}<span>All parts</span></a>${(p.from?.game ?? p.from?.app) ? `<a class="ghost" href="/${esc((p.from.game ?? p.from.app))}/${p.from.app ? 'open' : 'play'}">${icon('games')}<span>${p.from.app ? 'Open' : 'Play'} ${esc(p.from.name ?? (p.from.game ?? p.from.app))}</span></a>` : ''}</div>`)}
|
|
234
247
|
<div class="wrap">
|
|
@@ -236,7 +249,7 @@ ${preview ? `<p class="sec">Try it</p>${preview}` : ''}
|
|
|
236
249
|
<p class="sec">Use it in your game</p>
|
|
237
250
|
<p class="plead">In a chat with your Homie studio open, ask for it by name:</p>
|
|
238
251
|
${sayRow('Say', sayAdd(p))}
|
|
239
|
-
<p class="plead"
|
|
252
|
+
<p class="plead">${p.sale ? 'Your AI shows the price and asks first. After your wallet pays, it receives the files,' : 'Your AI fetches the files,'} checks every one, copies them into your studio and credits ${esc(name)} in your game. They are yours to tune from there.</p>
|
|
240
253
|
<p class="sec">About this part</p>
|
|
241
254
|
<dl class="pfacts">${facts.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join('')}</dl>
|
|
242
255
|
</div>`,
|