@homie-rocks/studio 0.22.0 → 0.24.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +59 -0
  3. package/bin/homie-studio.mjs +68 -2
  4. package/chat/CHAT.md +101 -0
  5. package/chat/OWNERS.md +78 -0
  6. package/lib/build.mjs +20 -2
  7. package/lib/chat-cli.mjs +89 -0
  8. package/lib/cloudflare.mjs +5 -2
  9. package/lib/scaffold.mjs +59 -2
  10. package/lib/shop.mjs +270 -0
  11. package/lib/template-history.json +77 -0
  12. package/netplay/NETPLAY.md +194 -4
  13. package/netplay/netplay.ts +156 -3
  14. package/package.json +7 -1
  15. package/port/index.ts +3 -2
  16. package/port/view.ts +238 -0
  17. package/shop/SELLING.md +83 -0
  18. package/shop/SHOP.md +154 -0
  19. package/shop/shop.ts +130 -0
  20. package/site/SITE.md +22 -1
  21. package/starters/ember-vale/src/main.ts +22 -2
  22. package/starters/gem-rush/src/main.ts +21 -1
  23. package/starters/gem-rush-3d/src/main.ts +22 -1
  24. package/worker/account-page.mjs +14 -2
  25. package/worker/agents.mjs +3 -3
  26. package/worker/chat-page.mjs +556 -0
  27. package/worker/chat-store.mjs +161 -0
  28. package/worker/chat-words.mjs +46 -0
  29. package/worker/chat.mjs +419 -0
  30. package/worker/index.mjs +189 -7
  31. package/worker/office-page.mjs +120 -5
  32. package/worker/office.mjs +173 -6
  33. package/worker/pages.mjs +25 -8
  34. package/worker/players.mjs +8 -0
  35. package/worker/referrals.mjs +328 -0
  36. package/worker/room.mjs +291 -11
  37. package/worker/servers.mjs +5 -1
  38. package/worker/shop-page.mjs +532 -0
  39. package/worker/shop-rules.mjs +277 -0
  40. package/worker/shop-store.mjs +217 -0
  41. package/worker/shop.mjs +686 -0
  42. package/worker/site.mjs +4 -1
  43. package/worker/stats.mjs +4 -1
  44. package/worker/stripe.mjs +149 -0
  45. package/worker/version.mjs +1 -1
package/port/view.ts CHANGED
@@ -11,6 +11,10 @@
11
11
  * label is placed first and nothing covers it; a moved label keeps its new spot while it is
12
12
  * free (no flicker), and labels fade in and out rather than pop.
13
13
  *
14
+ * createBubbles() speech bubbles over characters (room chat, NETPLAY.md section 19): what a player said, over
15
+ * their body for a few seconds, the player's own placed first, never covering another bubble;
16
+ * `paintBubbles` draws them on a canvas, and their boxes keep the name labels off them.
17
+ *
14
18
  * Everything is in CSS pixels (screen) and world units; the game draws. See the starters (gem-rush, ember-vale).
15
19
  */
16
20
 
@@ -211,3 +215,237 @@ export function createLabels(opts: LabelOptions = {}): { place(labels: LabelIn[]
211
215
  },
212
216
  };
213
217
  }
218
+
219
+ /* ------------------------------------------------------------------ speech bubbles (room chat, NETPLAY.md section 19) */
220
+
221
+ export interface BubbleOptions {
222
+ /** A line's width as drawn, CSS px: `(t) => ctx.measureText(t).width` with the bubble's font set (paintBubbles uses BUBBLE_FONT). */
223
+ measure: (text: string) => number;
224
+ /** A text line's height, CSS px (default 17). */
225
+ lineHeight?: number;
226
+ /** The widest a bubble's text gets before it wraps, CSS px (default 190). */
227
+ maxWidth?: number;
228
+ /** Lines at most; the rest is cut with "…" (default 3). */
229
+ maxLines?: number;
230
+ /** How long a message stays, ms: default 4 s and 50 ms a character (at most 9 s); an emoji 2.6 s. */
231
+ ms?: (b: { text: string; kind: 'text' | 'line' | 'react' }) => number;
232
+ /** Space inside the bubble, CSS px (default x 8, y 5). */
233
+ pad?: { x: number; y: number };
234
+ /** The tail under the bubble, CSS px (default 7). */
235
+ tail?: number;
236
+ /** CSS px kept clear between two bubbles (default 4). */
237
+ gap?: number;
238
+ /** The screen, so a bubble is never pushed off it (default the window). */
239
+ screen?: () => { w: number; h: number };
240
+ /** Milliseconds now (default performance.now()). */
241
+ now?: () => number;
242
+ /** Boxes bubbles keep clear of, CSS px (a game's own speech bubbles, a panel): a bubble moves up past them. */
243
+ avoid?: () => LabelBox[];
244
+ }
245
+
246
+ export interface BubbleIn {
247
+ /** Whose bubble: the key `say(key, …)` was given (a seat, a slot). */
248
+ key: string | number;
249
+ /** Where the tail points, CSS px: just over the body's name label (its box's top middle) or its head. */
250
+ x: number;
251
+ y: number;
252
+ /** The player's own (or the player a watcher follows): placed first. */
253
+ self?: boolean;
254
+ }
255
+
256
+ export interface BubbleOut extends LabelBox {
257
+ key: string | number;
258
+ id: string;
259
+ kind: 'text' | 'line' | 'react';
260
+ /** The text, wrapped: one string per line (one line with the glyph for an emoji). */
261
+ lines: string[];
262
+ /** An emoji-only bubble (a reaction): draw it bigger, no words. */
263
+ emoji: boolean;
264
+ lineHeight: number;
265
+ pad: { x: number; y: number };
266
+ /** Where the tail points (the anchor), and the bubble's middle. */
267
+ tip: { x: number; y: number };
268
+ cx: number;
269
+ cy: number;
270
+ /** 0..1: fading in and out. `scale` pops in from 0.86. */
271
+ alpha: number;
272
+ scale: number;
273
+ }
274
+
275
+ export interface Bubbles {
276
+ /** A message over `key`'s character: it replaces what that key was saying. `id` lets `remove(id)` take it down. */
277
+ say(key: string | number, text: string, o?: { id?: string; kind?: 'text' | 'line' | 'react' }): void;
278
+ /** The studio took a message down (net.on('unchat')): its bubble goes at once. */
279
+ remove(id: string): void;
280
+ /** Every bubble, or one key's, goes. */
281
+ clear(key?: string | number): void;
282
+ /** Each frame: where every speaking key's anchor is now. Keys not in the list are not drawn (off screen). */
283
+ place(anchors: BubbleIn[], dt?: number): BubbleOut[];
284
+ /** The boxes placed last frame: give them to createLabels' `avoid` so names keep off the bubbles. */
285
+ boxes(): LabelBox[];
286
+ /** How many keys are speaking now. */
287
+ readonly size: number;
288
+ }
289
+
290
+ /** The bubble's font (paintBubbles draws with it; measure with it too). */
291
+ export const BUBBLE_FONT = '600 14px ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif';
292
+
293
+ /** Words to lines no wider than `max` (a word wider than a line is broken), at most `n` lines (the last cut with …). */
294
+ export function wrapText(text: string, max: number, measure: (t: string) => number, n = 3): string[] {
295
+ const words = String(text ?? '').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
296
+ const lines: string[] = [];
297
+ let cur = '';
298
+ const push = (s: string): void => { lines.push(s); };
299
+ for (const w0 of words) {
300
+ let w = w0;
301
+ // A word wider than a line: broken by characters.
302
+ while (measure(w) > max) {
303
+ let cut = [...w].length - 1;
304
+ const chars = [...w];
305
+ while (cut > 1 && measure(chars.slice(0, cut).join('')) > max) cut -= 1;
306
+ if (cur) { push(cur); cur = ''; }
307
+ push(chars.slice(0, cut).join(''));
308
+ w = chars.slice(cut).join('');
309
+ }
310
+ const next = cur ? `${cur} ${w}` : w;
311
+ if (measure(next) <= max) cur = next; else { if (cur) push(cur); cur = w; }
312
+ }
313
+ if (cur) push(cur);
314
+ if (lines.length <= n) return lines;
315
+ const kept = lines.slice(0, n);
316
+ let last = `${kept[n - 1]}…`;
317
+ while (measure(last) > max && last.length > 2) last = `${[...last].slice(0, -2).join('')}…`;
318
+ kept[n - 1] = last;
319
+ return kept;
320
+ }
321
+
322
+ /**
323
+ * Speech bubbles over characters: what a player said in the room's chat, over their body, for a few seconds. Feed it
324
+ * from the netplay helper (`net.on('say', (s) => bubbles.say(s.seat, s.text, { id: s.id, kind: s.kind }))` and
325
+ * `net.on('unchat', (e) => e.ids.forEach((id) => bubbles.remove(id)))`), place it each frame with every speaking
326
+ * body's anchor (over its name label), and draw what comes back (`paintBubbles` on a canvas, or your own). Bubbles
327
+ * never cover each other: a bubble that would sits above the one in its way; the player's own is placed first.
328
+ */
329
+ export function createBubbles(opts: BubbleOptions): Bubbles {
330
+ const lh = opts.lineHeight ?? 17;
331
+ const maxW = opts.maxWidth ?? 190;
332
+ const maxLines = opts.maxLines ?? 3;
333
+ const pad = opts.pad ?? { x: 8, y: 5 };
334
+ const tail = opts.tail ?? 7;
335
+ const gap = opts.gap ?? 4;
336
+ const clock = opts.now ?? ((): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()));
337
+ const screen = opts.screen ?? ((): { w: number; h: number } => ({ w: typeof innerWidth === 'number' ? innerWidth : 1e9, h: typeof innerHeight === 'number' ? innerHeight : 1e9 }));
338
+ const life = opts.ms ?? ((b: { text: string; kind: string }): number => (b.kind === 'react' ? 2600 : Math.min(9000, 4000 + 50 * [...b.text].length)));
339
+ type Live = { id: string; key: string | number; kind: 'text' | 'line' | 'react'; text: string; lines: string[]; w: number; h: number; at: number; until: number };
340
+ const live = new Map<string | number, Live>();
341
+ let last: LabelBox[] = [];
342
+ let seq = 0;
343
+ return {
344
+ say(key, text, o = {}) {
345
+ const kind = o.kind ?? 'text';
346
+ const t = String(text ?? '').trim();
347
+ if (!t) return;
348
+ const emoji = kind === 'react';
349
+ const lines = emoji ? [t] : wrapText(t, maxW, opts.measure, maxLines);
350
+ const w = emoji ? lh * 1.6 : Math.max(...lines.map((l) => opts.measure(l)));
351
+ const h = emoji ? lh * 1.6 : lines.length * lh;
352
+ const at = clock();
353
+ seq += 1;
354
+ live.set(key, { id: o.id ?? `b${seq}`, key, kind, text: t, lines, w, h, at, until: at + life({ text: t, kind }) });
355
+ },
356
+ remove(id) { for (const [k, b] of live) if (b.id === id) live.delete(k); },
357
+ clear(key) { if (key === undefined) live.clear(); else live.delete(key); },
358
+ get size() { return live.size; },
359
+ boxes() { return last; },
360
+ place(anchors) {
361
+ const t = clock();
362
+ for (const [k, b] of live) if (t >= b.until) live.delete(k);
363
+ const { w: sw, h: sh } = screen();
364
+ const keep = opts.avoid?.() ?? [];
365
+ const placed: LabelBox[] = [];
366
+ const out: BubbleOut[] = [];
367
+ // The player's own first, then the newest: a new message keeps its spot and an older one moves up.
368
+ const order = anchors.filter((a) => live.has(a.key)).sort((a, b) => Number(Boolean(b.self)) - Number(Boolean(a.self)) || (live.get(b.key)!.at - live.get(a.key)!.at));
369
+ for (const a of order) {
370
+ const b = live.get(a.key)!;
371
+ const bw = b.w + pad.x * 2;
372
+ const bh = b.h + pad.y * 2;
373
+ const left = Math.max(2, Math.min(sw - bw - 2, a.x - bw / 2));
374
+ let bottom = a.y - tail;
375
+ let box = { left, top: bottom - bh, right: left + bw, bottom };
376
+ const hits = (x: LabelBox): boolean => box.left < x.right + gap && x.left < box.right + gap && box.top < x.bottom + gap && x.top < box.bottom + gap;
377
+ for (let i = 0; i < 6; i += 1) {
378
+ const other = placed.find(hits) ?? keep.find(hits);
379
+ if (!other) break;
380
+ bottom = other.top - gap;
381
+ box = { left, top: bottom - bh, right: left + bw, bottom };
382
+ }
383
+ if (box.top < 0 || box.left > sw || box.right < 0 || a.y > sh + bh) continue;
384
+ placed.push(box);
385
+ const age = t - b.at;
386
+ const leftMs = b.until - t;
387
+ const alpha = Math.max(0, Math.min(1, age / 120, leftMs / 400));
388
+ const scale = 0.86 + 0.14 * Math.min(1, age / 160);
389
+ out.push({ ...box, key: b.key, id: b.id, kind: b.kind, lines: b.lines, emoji: b.kind === 'react', lineHeight: lh, pad, tip: { x: Math.max(box.left + 8, Math.min(box.right - 8, a.x)), y: a.y }, cx: (box.left + box.right) / 2, cy: (box.top + box.bottom) / 2, alpha: Math.round(alpha * 100) / 100, scale: Math.round(scale * 1000) / 1000 });
390
+ }
391
+ last = placed;
392
+ return out;
393
+ },
394
+ };
395
+ }
396
+
397
+ export interface BubbleStyle {
398
+ font?: string;
399
+ /** The bubble's paper and ink (default white paper, near-black ink), its edge, and the player's own bubble's edge. */
400
+ paper?: string;
401
+ ink?: string;
402
+ edge?: string;
403
+ selfEdge?: string;
404
+ /** Corner radius, CSS px (default 10). */
405
+ radius?: number;
406
+ }
407
+
408
+ /** Draw placed bubbles on a 2D canvas (CSS px, the context's transform already set for the device pixel ratio). */
409
+ export function paintBubbles(ctx: CanvasRenderingContext2D, list: BubbleOut[], style: BubbleStyle = {}): void {
410
+ const font = style.font ?? BUBBLE_FONT;
411
+ const paper = style.paper ?? '#ffffff';
412
+ const ink = style.ink ?? '#15171f';
413
+ const edge = style.edge ?? 'rgba(0,0,0,0.18)';
414
+ const r = style.radius ?? 10;
415
+ for (const b of list) {
416
+ if (b.alpha <= 0) continue;
417
+ ctx.save();
418
+ ctx.globalAlpha = b.alpha;
419
+ const cx = b.cx;
420
+ const cy = b.bottom;
421
+ ctx.translate(cx, cy);
422
+ ctx.scale(b.scale, b.scale);
423
+ ctx.translate(-cx, -cy);
424
+ ctx.fillStyle = paper;
425
+ ctx.strokeStyle = edge;
426
+ ctx.lineWidth = 1;
427
+ ctx.beginPath();
428
+ ctx.roundRect(b.left, b.top, b.right - b.left, b.bottom - b.top, Math.min(r, (b.bottom - b.top) / 2));
429
+ ctx.fill();
430
+ ctx.stroke();
431
+ // The tail: a small triangle from the bubble's bottom to where it points.
432
+ const tx = b.tip.x;
433
+ ctx.beginPath();
434
+ ctx.moveTo(tx - 6, b.bottom - 1);
435
+ ctx.lineTo(tx, Math.min(b.tip.y, b.bottom + 7));
436
+ ctx.lineTo(tx + 6, b.bottom - 1);
437
+ ctx.closePath();
438
+ ctx.fill();
439
+ ctx.fillStyle = ink;
440
+ ctx.textAlign = 'center';
441
+ ctx.textBaseline = 'middle';
442
+ if (b.emoji) {
443
+ ctx.font = font.replace(/\d+(?:\.\d+)?px/, `${Math.round(b.lineHeight * 1.35)}px`);
444
+ ctx.fillText(b.lines[0] ?? '', b.cx, b.cy + 1);
445
+ } else {
446
+ ctx.font = font;
447
+ b.lines.forEach((line, i) => ctx.fillText(line, b.cx, b.top + b.pad.y + b.lineHeight * (i + 0.5) + 0.5));
448
+ }
449
+ ctx.restore();
450
+ }
451
+ }
@@ -0,0 +1,83 @@
1
+ # Selling things in your games: what you are taking on
2
+
3
+ Plain words for a studio's owner, from the Homie studio kit. **This is not legal or tax advice.** If you will
4
+ sell for real, ask an accountant where you must register for tax, and a lawyer anything this page leaves you
5
+ unsure about.
6
+
7
+ ## The short version
8
+
9
+ You sell with **your own Stripe account**. Money goes straight from your players to you. Homie never sees it,
10
+ never holds it, and takes no cut. That also means **you are the shop**: you set the prices, you answer refund
11
+ requests, you deal with card disputes, and the tax on your sales is yours to handle.
12
+
13
+ ## Who does what
14
+
15
+ | | You (the studio) | Stripe | Homie |
16
+ |---|---|---|---|
17
+ | Prices, what you sell | yes | | |
18
+ | Taking the card, the receipt email | | yes | |
19
+ | Refunds | yes: one tap in your office | carries them out | |
20
+ | Card disputes (chargebacks) | yes: you answer them in Stripe and carry the loss if you lose | runs the process | |
21
+ | Sales tax and VAT | yes, with Stripe Tax's help (or Managed Payments, below) | calculates and collects | |
22
+ | Your players' data | yes: you are responsible for it | keeps the card and email | stores nothing |
23
+ | The kids rules | built into the kit, and you can be stricter | | wrote the kit |
24
+
25
+ ## Refunds
26
+
27
+ - A player can refund an **unused** item themselves within your refund window (at least 14 days) from their
28
+ account page. Fewer people turn to card disputes when a refund is easy.
29
+ - Anything else, you refund with **one tap** on your office's shop page. Your AI can only *ask* you to refund; it
30
+ can never do it by itself.
31
+ - Stripe does not give back its processing fee on a refund. That is the cost of a happy player.
32
+
33
+ ## Disputes (chargebacks)
34
+
35
+ - A buyer's bank can reverse a payment. Stripe charges a dispute fee (about US$15) whether you win or lose, and
36
+ you lose the sale's money if the bank decides for the buyer. On a US$5 item one dispute costs more than the
37
+ sale, which is why the kit makes refunds easy and suggests items of US$3 or more.
38
+ - **A dispute never deletes or locks the player's account.** While it is open nothing changes. If you lose it,
39
+ that one item is taken back, as a refund would.
40
+
41
+ ## Tax
42
+
43
+ You choose who the seller is when you connect your Stripe (`homie-studio shop connect` shows both):
44
+
45
+ - **You are the seller** (`"till": "stripe"`): Stripe Tax is on. It works out and collects tax **only where you
46
+ have told Stripe you are registered**, and it does not file your returns. Some places expect a foreign seller
47
+ of digital goods to register from the first sale (the EU and the UK do). Cheapest; the most work.
48
+ - **Stripe is the seller** (`"till": "stripe-managed"`, Stripe Managed Payments): **3.5% more** a sale. Stripe's
49
+ Link, LLC becomes the seller of record and registers, collects, files and pays sales tax and VAT in 80+
50
+ countries, runs fraud checks and answers disputes for you. You still carry the money of a lost dispute, and
51
+ Stripe may refund a buyer within 60 days. You turn it on in Stripe first (after Stripe's eligibility review).
52
+
53
+ ## Kids
54
+
55
+ The kit keeps you out of the worst trouble, and you cannot switch these off in it (you can be stricter):
56
+
57
+ - nothing is sold on a kids server, or anywhere in a studio made for children (`"audience": "kids"`);
58
+ - spending is off on every account until one neutral question (the year you were born) says the player is an
59
+ adult; the answer is kept only as "adult", "teen" or "child";
60
+ - nothing is ever sold to an account that said it is under 13;
61
+ - a 13 to 17 year old can only pass a link to a parent, who pays in their own name on Stripe's page;
62
+ - no loot boxes or anything random for money, nothing that changes the game on beginner servers, no countdowns;
63
+ - every price is in real money, every purchase goes through Stripe's own page, one at a time, with a monthly cap;
64
+ - the television never sells: it shows a code to buy on a phone.
65
+
66
+ Even so, if children play your games you are the one responsible to the law for them (for example COPPA in the
67
+ US, the Children's Code in the UK, GDPR in the EU). Keep what you collect small, and keep chat with strangers off
68
+ on kids servers.
69
+
70
+ ## Referrals
71
+
72
+ If another studio (or homie.rocks, or a creator) sends you a new player who buys something, you owe them the
73
+ referral share you published in `shop.json` (10% if you kept the suggestion; 0 if you turn it off). Your office
74
+ shows what you owe and to whom, and signs a monthly statement for each. **They invoice you, and you pay them
75
+ yourself.** Nothing moves through Homie.
76
+
77
+ ## Before the first real sale
78
+
79
+ 1. Make (or pick) your Stripe account, verify it, and add a bank account.
80
+ 2. Test everything in **test mode** first: buy your own item with a Stripe test card, refund it from the office.
81
+ 3. Fill in your public business details in Stripe (name, support email, the refund page `/shop/refunds/`).
82
+ 4. Talk to an accountant about where you must register for tax, or choose Managed Payments.
83
+ 5. Then, and only then, connect a live key (`homie-studio shop connect --live`).
package/shop/SHOP.md ADDED
@@ -0,0 +1,154 @@
1
+ # The shop: selling in a studio's games with the studio's own Stripe
2
+
3
+ `@homie-rocks/studio` 0.24.0. A studio sells items for its games through **its own Stripe account**. The studio
4
+ is the seller; homie.rocks never holds, routes or settles its money, is not a Stripe Connect platform, and takes
5
+ no cut. There is no shared currency between studios. `SELLING.md` beside this file is the owner's plain-words
6
+ note on what selling means for them (refunds, disputes, tax, kids; not legal advice).
7
+
8
+ ## shop.json
9
+
10
+ At the studio's root, reviewed in git. Nothing secret is ever in it.
11
+
12
+ ```json
13
+ {
14
+ "till": "stripe",
15
+ "currency": "usd",
16
+ "refundDays": 14,
17
+ "capPerPlayerMonth": 5000,
18
+ "items": [
19
+ { "id": "supporter", "kind": "supporter", "name": "Supporter", "price": 500, "days": 365,
20
+ "gives": ["badge:supporter", "credits-name"], "badge": "Supporter" },
21
+ { "id": "ember-skin", "kind": "cosmetic", "game": "orbit", "name": "Ember hull", "price": 300, "gives": ["skin:ember"] },
22
+ { "id": "season-1", "kind": "pass", "name": "Season 1", "price": 800, "ends": "2027-01-31", "gives": ["track:season-1"] },
23
+ { "id": "tip", "kind": "tip", "name": "Buy the studio a coffee", "price": "choose", "min": 200, "max": 5000 }
24
+ ],
25
+ "referrals": { "rate": 0.10, "windowDays": 30, "capPerPlayer": 1000, "holdDays": 30, "minimumInvoice": 2500 }
26
+ }
27
+ ```
28
+
29
+ | Field | Meaning |
30
+ |---|---|
31
+ | `till` | `stripe`: the studio is the seller, Stripe Tax on (`automatic_tax`). `stripe-managed`: Stripe Managed Payments, Stripe's Link, LLC is the seller of record and files the tax (3.5% more; turn it on in Stripe first). `off`. |
32
+ | `currency` | one currency with cents (`usd`, `cad`, `eur`, `gbp`, `aud`…). |
33
+ | `refundDays` | 14 to 60: a player refunds an unused item themselves within it. |
34
+ | `capPerPlayerMonth` | cents one player may spend here in a month: 100 to 5000. Lower it, never remove it. |
35
+ | `items[].kind` | `cosmetic` (a look), `supporter` (a badge, thanks), `pass` (a season), `unlock` (a mode, a chapter, the full game), `tip` (pay what you want, `min` to `max`). |
36
+ | `items[].price` | whole cents, real money. Under 300 the check warns (fees eat it). |
37
+ | `items[].gives` | the entitlement keys the game reads: `skin:ember`, `badge:supporter`. A `badge:` key shows beside the player's name in rooms and on their account page. |
38
+ | `items[].days` / `starts` / `ends` | how long it lasts; a season's dates (shown as dates, never a countdown). |
39
+ | `items[].game` | only in that game's shop (left out: every game's). |
40
+ | `items[].advantage` | `true` when it changes how the game plays: never offered, and never counted as owned, on a beginner or kids server. |
41
+ | `items[].taxCode` | a Stripe product tax code. Managed Payments needs one: by default `txcd_10201000` (video games, downloaded, permanent) or `txcd_10201001` (limited time). |
42
+ | `referrals` | what this studio pays a referrer for a new player's purchase (left out: nothing). |
43
+
44
+ `homie-studio shop check`, and every `homie-studio build`, refuse: anything that names chance, odds, random, a
45
+ crate, a box, a mystery, loot, a gacha, a spin or a roll; fields for odds or drop pools; a countdown, timer or
46
+ "hurry" field; gems, coins, points or any currency of the studio's own; subscriptions and paid services (later
47
+ versions); a cap over 5000 or a refund window under 14 days. A studio whose studio.json says
48
+ `"audience": "kids"` sells nothing at all.
49
+
50
+ ## Who may buy
51
+
52
+ The Worker decides, for every item and every player (`worker/shop-rules.mjs` `wayFor`):
53
+
54
+ | The player | What happens |
55
+ |---|---|
56
+ | on a kids server, or in a kids studio | no shop at all: nothing listed, `shop.open()` says nothing is sold here |
57
+ | a guest (no passkey) | "make an account first" (a purchase would be lost with a cookie) |
58
+ | an account that has not answered | the one neutral age question: the year they were born, no default, nothing hinting which answer opens anything; kept only as a band |
59
+ | an account that said under 13 | nothing, ever; the account is treated as a kids account from then on |
60
+ | 13 to 17 | "ask a grown-up": a one-time link (a week) the teen passes on; the parent opens it on their own device, sees what and for whom, ticks that they are the parent or guardian, and pays in their own name on Stripe's page; the item lands on the teen's account |
61
+ | an adult | Stripe's own hosted Checkout, one item at a time, with the real-money price and its own confirm |
62
+ | over the monthly cap | not until next month |
63
+
64
+ ## In a game
65
+
66
+ ```ts
67
+ import { createShop } from '@homie-rocks/studio/shop';
68
+ const shop = createShop();
69
+ await shop.ready; // { open, kids, screen }
70
+ if (shop.has('skin:ember')) useEmber(); // synchronous: fine in a render loop
71
+ shop.on('change', (owns) => redraw()); // a purchase landed, or a refund took one back
72
+ buyButton.onclick = () => shop.open('ember-skin'); // the store sheet (a code to scan on a TV)
73
+ shop.used('skin:ember'); // equipped: it leaves the player's own refund window
74
+ ```
75
+
76
+ Rules for the game: open the shop only from a button the player pressed, never from the play, start or wake
77
+ button, never on a timer; show prices in real money; never draw a countdown; never sell randomness; do nothing
78
+ differently for a supporter on a beginner server.
79
+
80
+ The play shell around the game answers these calls (the game never sees a card, an email, an order or a
81
+ session). The room button's sheet has a **Shop** button too. A purchase opens Stripe's page in a new tab, the game
82
+ keeps running, and the shell looks again when the tab comes back: `change` fires once it is paid.
83
+
84
+ **Badges in rooms.** A player whose account owns a `badge:` key carries it on their seat: `net.players()` gives
85
+ `peer.badge` ("Supporter"), set by the studio's Worker from what the account owns when the player connects (a
86
+ hello can never claim one). None on a kids server, none for an AI.
87
+
88
+ ## The money path
89
+
90
+ ```
91
+ phone (signed in) ─ POST /api/shop/buy ─► studio Worker ─ Checkout Session (the studio's restricted key) ─► Stripe
92
+ ◄──────────────── Stripe's hosted page (new tab): the player pays ─────────────────────────────────┘
93
+ Stripe ─ signed webhook ─► POST /api/shop/hook: order paid → entitlements → referral line (held)
94
+ homie.rocks: not involved at any step.
95
+ ```
96
+
97
+ - The order row is written before Stripe is called; the Checkout Session carries the order, the player and the
98
+ item in its metadata; the hook checks the session matches the order (player, price) before it grants anything.
99
+ - The webhook's `Stripe-Signature` is checked (HMAC-SHA256 of `<t>.<payload>` with the endpoint's `whsec_`
100
+ secret, within five minutes); each event id is handled once; a test event never touches a live shop.
101
+ - Paid: the entitlements are granted. Refunded: that one order's entitlements are revoked. A dispute: nothing
102
+ changes until it is decided; lost: that one item goes, as a refund would; won: it stays. **The account is never
103
+ locked or deleted over a dispute.**
104
+ - Receipts come from Stripe by email (the buyer types it on Stripe's page, never on the studio's). The account
105
+ page lists the player's orders and badges; `/api/player/export` includes them; deleting an account asks first
106
+ when it owns things, and keeps the order rows without the player.
107
+ - Each Checkout Session says beside the pay button that the item is delivered at once (the EU and UK withdrawal
108
+ acknowledgement) and that an unused item can still be refunded.
109
+
110
+ ## Setting it up (Claude does the commands; the owner only uses pages)
111
+
112
+ 1. `npx --no-install homie-studio shop init --supporter` (shop.json and SELLING.md), then `shop check`, commit,
113
+ `npm run deploy` (it applies migration `0008_studio_shop.sql`; the shop stays closed).
114
+ 2. In Stripe (the owner): a restricted key with **Checkout Sessions: Write, Charges: Write (refunds),
115
+ PaymentIntents: Read, Disputes: Read**, and a webhook endpoint at `https://<site>/api/shop/hook` for
116
+ `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`,
117
+ `checkout.session.expired`, `charge.refunded`, `refund.created`, `refund.updated`, `charge.dispute.created`,
118
+ `charge.dispute.closed`.
119
+ 3. `npx --no-install homie-studio shop connect`: a page on the owner's computer (127.0.0.1, one use, ten minutes)
120
+ with those steps, both fields and the seller choice (the studio, or Stripe Managed Payments, with what each
121
+ costs and does). The key and the secret go straight to the Worker secrets `STRIPE_KEY` and
122
+ `STRIPE_WEBHOOK_SECRET` on Wrangler's standard input. Test keys only, unless `--live`.
123
+ 4. `homie-studio shop` says whether anything is still missing. Buy your own item with a Stripe test card, and
124
+ refund it from `/_studio/office/shop`.
125
+
126
+ Never ask for a key in a chat, never put one in a file, an argument, a log or a commit.
127
+
128
+ ## The office
129
+
130
+ `/_studio/office/shop` (the owner's browser) and `/_studio/api/shop` (an office key): open or what is missing,
131
+ the last 30 days, every order with its Stripe page, Refund (one tap), disputes, payouts, balance and tax as links
132
+ to the studio's own Stripe Dashboard, a CSV for the accountant, and the referral books. From an office key a
133
+ refund (`POST /_studio/api/shop/refund`, `homie-studio shop refund <order>`) is always an ASK the owner confirms
134
+ with one tap; so is marking a referrer paid.
135
+
136
+ ## Referrals
137
+
138
+ - A link with `?via=<host>` (another studio, homie.rocks, anywhere) opened by a person whose browser has never
139
+ been to this studio leaves a signed cookie with the host and the time (only on a studio that pays referrals;
140
+ never a prefetch, a crawler, this site, or a server's page or room).
141
+ - A paid order by an account made after that arrival, within `windowDays`, writes a referral line in THIS
142
+ studio's D1, if the referrer's own `/.well-known/homie-studio.json` says it takes statements. Held for
143
+ `holdDays`; a refund or a lost dispute inside the hold voids it, after it a clawback nets off the next statement.
144
+ - `homie-studio shop statements [--send]` signs each referrer's statement with the studio's Ed25519 key (the
145
+ public half is in the manifest's `referrals.key`) and POSTs it to the referrer's `/api/referrals/statement`,
146
+ which checks the signature against the seller's own manifest. No player, name or email is in a statement.
147
+ - Paying stays outside Homie: the referrer invoices the seller from its own Stripe; the seller pays and marks it
148
+ paid in the office.
149
+
150
+ ## Testing without money
151
+
152
+ The kit's tests run the Worker against `stripe-mock` (Stripe's open-source API mock, which checks every parameter
153
+ against Stripe's own API description) and sign webhooks with a test secret. `STRIPE_API_BASE` (a loopback address
154
+ only) points a Worker at such a mock; it is never honoured for anything but 127.0.0.1 or localhost.
package/shop/shop.ts ADDED
@@ -0,0 +1,130 @@
1
+ /*
2
+ * shop.ts — what a player owns in a game on a Homie studio (@homie-rocks/studio/shop). SHOP.md beside it is the guide.
3
+ * =============================================================================
4
+ *
5
+ * import { createShop } from '@homie-rocks/studio/shop';
6
+ * const shop = createShop();
7
+ * await shop.ready;
8
+ * if (shop.has('skin:ember')) hull.material = ember; // what the player bought, by the key shop.json gives
9
+ * shop.on('change', () => redrawSkins()); // a purchase landed (or a refund took one back)
10
+ * button.onclick = () => shop.open('ember-skin'); // the store sheet; on a TV, a code to buy on a phone
11
+ * shop.used('skin:ember'); // equipped: it is no longer refundable by the player
12
+ *
13
+ * WHERE IT COMES FROM. The game runs in a sandboxed frame that can see no cookie and send no request as the site. The
14
+ * play shell around it (on the studio's own origin) asks the studio's Worker what the signed-in player owns and
15
+ * answers this module by postMessage, only for THIS game. The Worker decides: an item bought with the studio's own
16
+ * Stripe, paid (the signed webhook said so), not refunded, not ended. A game never sees a card, an email or an order.
17
+ *
18
+ * THE KIDS RULES (the kit's, not the game's to change): on a kids server there is no shop (`open` is false, `kids` is
19
+ * true, `open()` shows "nothing is sold here"); on a beginner or kids server anything with a play advantage counts as
20
+ * NOT owned; on a television `open()` shows only a code to scan and buy on a phone. A game never shows a countdown, a
21
+ * random reward for money, or a buy button on its play button.
22
+ *
23
+ * Without a shell (a game opened as a file, a dev server, a studio with no shop) everything is empty and `open()` does
24
+ * nothing, so the same code runs everywhere. It has no imports and uses only erasable TypeScript.
25
+ * =============================================================================
26
+ */
27
+
28
+ export const SHOP_VERSION = 1;
29
+
30
+ export interface ShopState {
31
+ /** The studio sells something in this game, and this room may show it. */
32
+ open: boolean;
33
+ /** A kids server: nothing is sold or shown. */
34
+ kids: boolean;
35
+ /** This is the big screen: `open()` shows a code to buy on a phone. */
36
+ screen: boolean;
37
+ }
38
+ export type ShopEvent = 'change' | 'closed';
39
+
40
+ export interface ShopOptions {
41
+ /** Tests and custom shells: where messages go (default: the parent frame). */
42
+ target?: { postMessage: (m: unknown, origin: string) => void } | null;
43
+ /** How long to wait for the shell's first answer before treating the game as having no shop (ms). */
44
+ timeoutMs?: number;
45
+ }
46
+
47
+ export interface Shop {
48
+ /** Resolves once the shell answered (or after `timeoutMs` with no shell: an empty, closed shop). */
49
+ readonly ready: Promise<ShopState>;
50
+ readonly state: ShopState;
51
+ /** Whether the player owns this entitlement key now ("skin:ember", "badge:supporter"). Synchronous, for a render loop. */
52
+ has(key: string): boolean;
53
+ /** Every key the player owns now. */
54
+ entitlements(): string[];
55
+ /** Ask the shell for a fresh answer (the shell also looks again by itself after a purchase). */
56
+ refresh(): Promise<string[]>;
57
+ /** Show the store sheet, at one item if given. Call it from a button the player pressed, never on a timer. */
58
+ open(item?: string): void;
59
+ close(): void;
60
+ /** The player used something (equipped it, played with it): it leaves the player's own refund window. */
61
+ used(key: string): Promise<boolean>;
62
+ on(ev: 'change', fn: (owns: string[]) => void): () => void;
63
+ on(ev: 'closed', fn: () => void): () => void;
64
+ }
65
+
66
+ type Msg = Record<string, unknown> & { t?: string; q?: number; ev?: string; ok?: boolean };
67
+ const KEY = /^[a-z0-9][a-z0-9_.:-]{0,63}$/;
68
+
69
+ export function createShop(opts: ShopOptions = {}): Shop {
70
+ const g = globalThis as unknown as { HOMIE_NET?: { shop?: boolean }; parent?: unknown; addEventListener?: typeof addEventListener };
71
+ const inShell = Boolean(g.HOMIE_NET?.shop);
72
+ const target = opts.target !== undefined ? opts.target : (inShell && g.parent && g.parent !== g ? (g.parent as { postMessage: (m: unknown, o: string) => void }) : null);
73
+ const listeners = new Map<string, Set<(x: unknown) => void>>();
74
+ const emit = (ev: string, x: unknown): void => { for (const fn of listeners.get(ev) ?? []) { try { fn(x); } catch { /* the game's */ } } };
75
+ let owns: string[] = [];
76
+ const state: ShopState = { open: false, kids: false, screen: false };
77
+ let seq = 0;
78
+ const waiting = new Map<number, (m: Msg) => void>();
79
+ const send = (m: Record<string, unknown>): Promise<Msg> => {
80
+ if (!target) return Promise.resolve({ ok: false });
81
+ const q = ++seq;
82
+ return new Promise((resolve) => {
83
+ waiting.set(q, resolve);
84
+ try { target.postMessage({ t: 'homie-shop', q, ...m }, '*'); } catch { waiting.delete(q); resolve({ ok: false }); }
85
+ setTimeout(() => { if (waiting.has(q)) { waiting.delete(q); resolve({ ok: false, error: 'timeout' }); } }, 8000);
86
+ });
87
+ };
88
+ const setOwns = (next: unknown): boolean => {
89
+ const list = Array.isArray(next) ? next.map(String).filter((k) => KEY.test(k)) : [];
90
+ const before = owns.slice().sort().join(',');
91
+ owns = list;
92
+ return list.slice().sort().join(',') !== before;
93
+ };
94
+ if (target && g.addEventListener) {
95
+ g.addEventListener('message', (e: MessageEvent) => {
96
+ const m = e.data as Msg;
97
+ if (!m || typeof m !== 'object' || m.t !== 'homie-shop') return;
98
+ if (typeof m.q === 'number' && waiting.has(m.q)) { const r = waiting.get(m.q)!; waiting.delete(m.q); r(m); return; }
99
+ if (m.ev === 'owns') { if (setOwns(m.owns)) emit('change', owns.slice()); }
100
+ if (m.ev === 'closed') emit('closed', undefined);
101
+ });
102
+ }
103
+ const ready: Promise<ShopState> = target
104
+ ? Promise.race([
105
+ send({ op: 'hello', v: SHOP_VERSION }).then((m) => {
106
+ state.open = Boolean(m.ok && m.open); state.kids = Boolean(m.kids); state.screen = Boolean(m.screen);
107
+ if (setOwns(m.owns)) emit('change', owns.slice());
108
+ return { ...state };
109
+ }),
110
+ new Promise<ShopState>((r) => setTimeout(() => r({ ...state }), opts.timeoutMs ?? 4000)),
111
+ ])
112
+ : Promise.resolve({ ...state });
113
+
114
+ const shop: Shop = {
115
+ ready,
116
+ get state() { return { ...state }; },
117
+ has: (key) => owns.includes(String(key)),
118
+ entitlements: () => owns.slice(),
119
+ refresh: async () => { const m = await send({ op: 'owns' }); if (m.ok && setOwns(m.owns)) emit('change', owns.slice()); return owns.slice(); },
120
+ open: (item) => { void send({ op: 'open', ...(typeof item === 'string' ? { item } : {}) }); },
121
+ close: () => { void send({ op: 'close' }); },
122
+ used: async (key) => (KEY.test(String(key)) && owns.includes(String(key)) ? Boolean((await send({ op: 'used', key })).ok) : false),
123
+ on: ((ev: ShopEvent, fn: (x: unknown) => void) => {
124
+ if (!listeners.has(ev)) listeners.set(ev, new Set());
125
+ listeners.get(ev)!.add(fn);
126
+ return () => { listeners.get(ev)?.delete(fn); };
127
+ }) as Shop['on'],
128
+ };
129
+ return shop;
130
+ }
package/site/SITE.md CHANGED
@@ -30,7 +30,17 @@ songs (each with a `cover`), videos (each with a `poster`), the latest posts, an
30
30
  (`games[].played`, `{ "days": 7, "plays", "rounds" }`). To keep the studio's rooms off the hub, studio.json takes
31
31
  `"rooms": { "share": false }`: the manifest then names no `rooms` (the studio's own pages still list them).
32
32
  Each room in `/api/rooms` carries `play` and, unless its game says `"watch": false`, `watch` (its watch door), so
33
- the hub offers Watch beside Join.
33
+ the hub offers Watch beside Join. From 0.23.0 a room whose chat is on and whose rules let homie.rocks show it
34
+ (`hub`, on by default) also says `chat: true`: the hub's room card then has Chat, which opens the room's own watch
35
+ socket from the visitor's browser and shows its lines and reactions live (NETPLAY.md section 19). A visitor there
36
+ sends what a watcher may send (reactions and quick lines when the room's `react` is `anyone`), never types.
37
+
38
+ ## Room chat on the play, watch and TV pages
39
+
40
+ Every play page, big screen and watch page carries room chat (worker/chat-page.mjs; chat/CHAT.md): the Chat pill
41
+ and its sheet, the float, the ticker, the big screen's corner. Reports go to `POST /<id>/api/chat/report` (this
42
+ site's pages only); the owner's tools are `/_studio/api/chat`, `/_studio/api/chat/rules`, `/_studio/api/chat/remove`,
43
+ `/_studio/api/chat/report` (dismiss) and `/_studio/api/chat/budget`, and `mute`/`kick` take a chat line.
34
44
 
35
45
  ## A game's landing: `/<id>/`
36
46
 
@@ -256,6 +266,17 @@ everything. The play page of such a game carries the saves bridge and a "who is
256
266
  and shows a passkey sheet over the game when the game asks (`saves.signIn()`); its landing links the account page.
257
267
  `/account/` belongs to the site: `site/pages/account/` and `site/public/account/` are refused.
258
268
 
269
+ ## The shop: `/shop/` (0.24.0)
270
+
271
+ A studio with a `shop.json` ([../shop/SHOP.md](../shop/SHOP.md)) has `/shop/` (its items in real money, sign in, the
272
+ one neutral age question, Buy on Stripe's own page, or a link for a parent), `/shop/thanks`, `/shop/refunds/` (the
273
+ refund policy every seller publishes) and `/shop/parent/<link>` (what a parent opens). The account page lists the
274
+ player's purchases and badges, with the self-serve refund of an unused item. A play page whose game the studio
275
+ sells something for carries the store sheet (the game's `shop.open()`, and Shop in the room sheet); on a kids server
276
+ there is none, and the big screen's sheet is a code to buy on a phone. With a `shop.json`, `/shop/` belongs to the
277
+ shop (a `site/pages/shop/` is not served); without one it is the studio's own. The directory manifest says only
278
+ whether the shop is open and its till.
279
+
259
280
  ## The arcade knock
260
281
 
261
282
  A game made with Homie's arcade controls asks its page's origin for a Homie box (`GET /__homie/call`), and a score