@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
@@ -0,0 +1,277 @@
1
+ /**
2
+ * THE SHOP'S RULES (@homie-rocks/studio 0.24.0): what a studio's shop.json may sell, checked the same way by the
3
+ * build, `homie-studio shop check` and the Worker (shop/SHOP.md is the guide). Pure: no Node, no Worker APIs.
4
+ *
5
+ * The kit's kids and fairness rules live HERE, not in docs, so a studio cannot switch them off by editing a file:
6
+ *
7
+ * - real-money prices only, in one currency; no gems, coins or any currency of the studio's own (version 1);
8
+ * - nothing random: an item that names chance, odds, random, a crate, a box, a mystery, loot, a gacha, a spin or a
9
+ * roll (in its kind, id, name, words or fields) is refused, and the kit has no loot-box primitive;
10
+ * - no countdown offers: a countdown, timer or "hurry" field is refused (a season pass may END on a date; the kit
11
+ * shows the date, never a clock);
12
+ * - an item that gives a play advantage (`"advantage": true`) is never offered, nor counted as owned, on a beginner
13
+ * or kids server;
14
+ * - a monthly cap per player that a studio may lower, never raise past the ceiling or remove;
15
+ * - a refund window for unused items of at least 14 days;
16
+ * - a studio made for children (`studio.json "audience": "kids"`) sells nothing: its till is off.
17
+ *
18
+ * Who may buy (an adult account; 13-17 through a parent's own checkout; under 13 never) is enforced by the Worker
19
+ * (worker/shop.mjs); a kids server shows no shop; a television never sells (it shows a code to buy on a phone).
20
+ */
21
+
22
+ export const SHOP_FILE = 'shop.json';
23
+ /** The tills this version ships. `link` (another merchant of record) and gems come later, one at a time. */
24
+ export const TILLS = Object.freeze(['stripe', 'stripe-managed', 'off']);
25
+ /** What an item can be. All directly priced, all deterministic. */
26
+ export const KINDS = Object.freeze(['cosmetic', 'supporter', 'pass', 'unlock', 'tip']);
27
+ const LATER = Object.freeze({
28
+ gems: 'gems are not in version 1: price items in real money (a studio\'s own currency brings the EU consumer authorities\' currency rules)',
29
+ coins: 'a currency of the studio\'s own is not in version 1: price items in real money',
30
+ currency: 'a currency of the studio\'s own is not in version 1: price items in real money',
31
+ subscription: 'subscriptions come in a later version (terms before billing, express consent, and one-click cancel)',
32
+ service: 'paid services (an AI filling a spot) come in a later version',
33
+ });
34
+ /** The template's ceiling on what one player may spend here in a month, in the currency's minor units (US$50). */
35
+ export const CAP_CEILING = 5000;
36
+ export const REFUND_DAYS_MIN = 14;
37
+ export const REFUND_DAYS_MAX = 60;
38
+ /** Below this an item mostly pays fees: the fixed 30 cents and a never-returned dispute fee. A warning, not a refusal. */
39
+ export const PRICE_FLOOR_WARN = 300;
40
+ /** The most a single item may cost (US$500): a shop in a game, not a till for anything. */
41
+ export const PRICE_MAX = 50_000;
42
+ export const ITEMS_MAX = 60;
43
+ export const ITEM_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
44
+ export const ENTITLEMENT_KEY = /^[a-z0-9][a-z0-9_.:-]{0,63}$/;
45
+ const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
46
+ const TAX_CODE = /^txcd_\d{8}$/;
47
+ const CURRENCY = /^[a-z]{3}$/;
48
+ /** Currencies without minor units: the cap and the floor would read 100x too high, so they are refused in v1. */
49
+ const ZERO_DECIMAL = new Set(['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf']);
50
+
51
+ /** Paid randomness, in any wording a shop file might use. Whole words, so "Jukebox" is fine and "Mystery Box" is not. */
52
+ const RANDOM_WORDS = /\b(?:chance|chances|odds|random|randomi[sz]ed|randomly|crates?|box|boxes|lootbox(?:es)?|loot|mystery|gacha|spins?|roll|rolls|lottery|raffle|sweepstakes?|jackpot|surprise|blind ?bag|drop ?rate|pity)\b/i;
53
+ /** Fields that only exist to make an item random. */
54
+ const RANDOM_FIELDS = new Set(['odds', 'chance', 'chances', 'random', 'pool', 'drops', 'droptable', 'loottable', 'weights', 'rarityweights', 'probability', 'probabilities']);
55
+ /** Fields that only exist to pressure: a clock on an offer. */
56
+ const PRESSURE_FIELDS = new Set(['countdown', 'timer', 'hurry', 'limitedtime', 'flash', 'flashsale', 'expiresin', 'urgency', 'scarcity', 'onlyleft']);
57
+ const CURRENCY_KEYS = /^(?:gems?|coins?|currency|credits?|points|tokens|bucks|diamonds|crystals|vbucks|robux):/i;
58
+
59
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
60
+ const oneLine = (v, max) => String(v ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
61
+
62
+ /** Every string in a value, with the key path it sits at (to name where a forbidden word is). */
63
+ function strings(v, at = '', out = []) {
64
+ if (typeof v === 'string') out.push([at, v]);
65
+ else if (Array.isArray(v)) v.forEach((x, i) => strings(x, `${at}[${i}]`, out));
66
+ else if (isObj(v)) for (const [k, x] of Object.entries(v)) { out.push([`${at}${at ? '.' : ''}${k}`, k]); strings(x, `${at}${at ? '.' : ''}${k}`, out); }
67
+ return out;
68
+ }
69
+ /** Every key in a value, lower-cased, with its path. */
70
+ function keys(v, at = '', out = []) {
71
+ if (Array.isArray(v)) v.forEach((x, i) => keys(x, `${at}[${i}]`, out));
72
+ else if (isObj(v)) for (const [k, x] of Object.entries(v)) { const p = `${at}${at ? '.' : ''}${k}`; out.push([p, k.toLowerCase().replace(/[^a-z]/g, '')]); keys(x, p, out); }
73
+ return out;
74
+ }
75
+
76
+ /** "2026-11-01" or an ISO time: its ms, or null. */
77
+ function dateOf(v) {
78
+ if (v === undefined || v === null || v === '') return null;
79
+ const t = Date.parse(String(v));
80
+ return Number.isFinite(t) ? t : NaN;
81
+ }
82
+
83
+ /** A badge's words from an entitlement key: badge:supporter → Supporter. */
84
+ export function badgeWord(key, item = null) {
85
+ const given = oneLine(item?.badge, 16);
86
+ if (given) return given;
87
+ const word = String(key ?? '').replace(/^badge:/, '').replace(/[-_.:]+/g, ' ').trim();
88
+ return word ? word.charAt(0).toUpperCase() + word.slice(1, 16) : '';
89
+ }
90
+
91
+ /**
92
+ * Check a shop.json. `{ ok, shop, errors, warnings }`: `shop` is the public, normalised shop the Worker sells from
93
+ * (nothing secret is ever in shop.json). `games`: the studio's game ids, when known (the build), to check `game`.
94
+ * `audience`: studio.json "audience" ("kids" closes the till, whatever shop.json says).
95
+ */
96
+ export function checkShop(raw, { games = null, audience = 'general' } = {}) {
97
+ const errors = [];
98
+ const warnings = [];
99
+ const err = (at, message) => errors.push({ at, message });
100
+ if (!isObj(raw)) return { ok: false, shop: null, errors: [{ at: '', message: 'shop.json is a JSON object: { "till": "stripe", "currency": "usd", "items": [...] }' }], warnings };
101
+ // Randomness and pressure anywhere in the file, before anything else: the kit has no way to sell either.
102
+ for (const [at, text] of strings(raw)) {
103
+ if (RANDOM_WORDS.test(text)) err(at, `"${oneLine(text, 40)}" names paid randomness (chance, odds, random, a crate, a box, a mystery, loot…). The kit sells only items the buyer can see, at a fixed price.`);
104
+ }
105
+ for (const [at, k] of keys(raw)) {
106
+ if (RANDOM_FIELDS.has(k)) err(at, 'a field for odds, drops or a random pool: the kit never sells paid randomness');
107
+ if (PRESSURE_FIELDS.has(k)) err(at, 'a countdown, timer or "hurry" field: the kit never shows a clock on an offer (a pass may end on a date; the date is shown, not a countdown)');
108
+ }
109
+ const till = raw.till === undefined ? 'stripe' : String(raw.till);
110
+ if (till === 'link') err('till', 'the "link" till (another merchant of record) comes in a later version: use "stripe" or "stripe-managed"');
111
+ else if (!TILLS.includes(till)) err('till', 'till is "stripe" (your own Stripe: you are the seller), "stripe-managed" (Stripe Managed Payments: Stripe is the seller of record and files the tax, for 3.5% more) or "off"');
112
+ const currency = raw.currency === undefined ? 'usd' : String(raw.currency).toLowerCase();
113
+ if (!CURRENCY.test(currency)) err('currency', 'currency is a three-letter code such as "usd", "cad", "eur" or "gbp"');
114
+ else if (ZERO_DECIMAL.has(currency)) err('currency', `${currency.toUpperCase()} has no minor units; version 1 sells in a currency with cents (usd, cad, eur, gbp, aud…)`);
115
+ let refundDays = raw.refundDays === undefined ? REFUND_DAYS_MIN : Math.floor(Number(raw.refundDays));
116
+ if (!(refundDays >= REFUND_DAYS_MIN && refundDays <= REFUND_DAYS_MAX)) { err('refundDays', `refundDays is ${REFUND_DAYS_MIN} to ${REFUND_DAYS_MAX}: a player can always get an unused item refunded for at least 14 days`); refundDays = REFUND_DAYS_MIN; }
117
+ let cap = raw.capPerPlayerMonth === undefined ? CAP_CEILING : Math.floor(Number(raw.capPerPlayerMonth));
118
+ if (!(cap >= 100 && cap <= CAP_CEILING)) { err('capPerPlayerMonth', `capPerPlayerMonth is what one player may spend here in a month, in cents: 100 to ${CAP_CEILING} (US$50). A studio may lower it, never remove it.`); cap = CAP_CEILING; }
119
+ if (raw.supportHomie !== undefined && Number(raw.supportHomie) !== 0) err('supportHomie', 'a voluntary "support Homie" share comes in a later version; leave it out (0 is the default, and Homie takes no cut)');
120
+ if (raw.gems !== undefined || raw.coins !== undefined) err('gems', LATER.gems);
121
+
122
+ const items = [];
123
+ const list = Array.isArray(raw.items) ? raw.items : [];
124
+ if (raw.items !== undefined && !Array.isArray(raw.items)) err('items', 'items is a list');
125
+ if (list.length > ITEMS_MAX) err('items', `at most ${ITEMS_MAX} items`);
126
+ const seen = new Set();
127
+ list.slice(0, ITEMS_MAX).forEach((it, i) => {
128
+ const at = `items[${i}]`;
129
+ if (!isObj(it)) { err(at, 'an item is an object'); return; }
130
+ const id = String(it.id ?? '');
131
+ if (!ITEM_ID.test(id)) err(`${at}.id`, 'id is 1 to 40 lowercase letters, digits or -');
132
+ else if (seen.has(id)) err(`${at}.id`, `two items are called "${id}"`);
133
+ seen.add(id);
134
+ const kind = String(it.kind ?? '');
135
+ if (LATER[kind]) { err(`${at}.kind`, LATER[kind]); return; }
136
+ if (!KINDS.includes(kind)) { err(`${at}.kind`, `kind is one of ${KINDS.join(', ')}`); return; }
137
+ const name = oneLine(it.name, 60);
138
+ if (!name) err(`${at}.name`, 'an item needs a name the buyer reads');
139
+ const blurb = oneLine(it.blurb ?? it.description, 200);
140
+ let price = null; let min = null; let max = null;
141
+ if (kind === 'tip') {
142
+ if (it.price !== 'choose' && it.price !== undefined) err(`${at}.price`, 'a tip\'s price is "choose" (pay what you want between min and max)');
143
+ min = Math.floor(Number(it.min ?? 200)); max = Math.floor(Number(it.max ?? 5000));
144
+ if (!(min >= 100 && max >= min && max <= PRICE_MAX)) err(`${at}.min`, 'a tip has min and max in cents (min at least 100, max at most 50000)');
145
+ } else {
146
+ if (typeof it.price !== 'number' || !Number.isInteger(it.price)) err(`${at}.price`, 'price is a whole number of cents in the shop\'s currency (500 is $5.00): real money only, never gems or points');
147
+ else if (!(it.price >= 50 && it.price <= PRICE_MAX)) err(`${at}.price`, 'price is 50 to 50000 cents');
148
+ else { price = it.price; if (price < PRICE_FLOOR_WARN) warnings.push({ at: `${at}.price`, message: `${name || id} costs under ${PRICE_FLOOR_WARN / 100} ${currency.toUpperCase()}: fees (about 30 cents a sale, and a dispute fee that is never returned) eat most of it. Bundle it with something.` }); }
149
+ }
150
+ if (price !== null && price > cap) err(`${at}.price`, `costs more than the monthly cap (${cap} cents): nobody could buy it`);
151
+ const gives = Array.isArray(it.gives) ? it.gives.map(String) : [];
152
+ if (it.gives !== undefined && !Array.isArray(it.gives)) err(`${at}.gives`, 'gives is a list of entitlement keys the game reads: ["skin:ember"]');
153
+ if (kind !== 'tip' && !gives.length) err(`${at}.gives`, 'an item gives something: the keys the game reads with shop.has("skin:ember")');
154
+ if (gives.length > 8) err(`${at}.gives`, 'at most 8 keys an item');
155
+ for (const g of gives) {
156
+ if (!ENTITLEMENT_KEY.test(g)) err(`${at}.gives`, `"${oneLine(g, 40)}" is not a key: lowercase letters, digits and _ . : - (skin:ember, badge:supporter)`);
157
+ else if (CURRENCY_KEYS.test(g)) err(`${at}.gives`, `"${g}" is a currency: version 1 sells items, never gems, coins or points`);
158
+ }
159
+ let days = null;
160
+ if (it.days !== undefined && it.days !== null) {
161
+ days = Math.floor(Number(it.days));
162
+ if (!(days >= 1 && days <= 3650)) { err(`${at}.days`, 'days is how long it lasts, 1 to 3650 (leave it out for ever)'); days = null; }
163
+ }
164
+ const starts = dateOf(it.starts);
165
+ const ends = dateOf(it.ends);
166
+ if (Number.isNaN(starts)) err(`${at}.starts`, 'starts is a date: "2026-11-01"');
167
+ if (Number.isNaN(ends)) err(`${at}.ends`, 'ends is a date: "2027-01-31"');
168
+ if (Number.isFinite(starts) && Number.isFinite(ends) && ends <= starts) err(`${at}.ends`, 'ends after it starts');
169
+ if (it.game !== undefined && it.game !== null) {
170
+ if (!GAME_ID.test(String(it.game))) err(`${at}.game`, 'game is one of the studio\'s game ids (leave it out for the whole studio)');
171
+ else if (Array.isArray(games) && !games.includes(String(it.game))) err(`${at}.game`, `there is no game "${it.game}" in games/`);
172
+ }
173
+ if (it.taxCode !== undefined && !TAX_CODE.test(String(it.taxCode))) err(`${at}.taxCode`, 'taxCode is a Stripe product tax code such as "txcd_10000000"');
174
+ const advantage = it.advantage === true;
175
+ if (advantage && kind === 'supporter') err(`${at}.advantage`, 'a supporter pack never changes how the game plays');
176
+ const badge = gives.find((g) => g.startsWith('badge:'));
177
+ items.push({
178
+ id, kind, name, ...(blurb ? { blurb } : {}),
179
+ ...(kind === 'tip' ? { price: 'choose', min, max } : { price }),
180
+ gives, ...(badge ? { badge: badgeWord(badge, it) } : {}),
181
+ ...(days ? { days } : {}), ...(Number.isFinite(starts) ? { starts: new Date(starts).toISOString() } : {}), ...(Number.isFinite(ends) ? { ends: new Date(ends).toISOString() } : {}),
182
+ ...(it.game ? { game: String(it.game) } : {}), ...(it.taxCode ? { taxCode: String(it.taxCode) } : {}),
183
+ ...(advantage ? { advantage: true } : {}),
184
+ });
185
+ });
186
+
187
+ let referrals = null;
188
+ if (raw.referrals !== undefined && raw.referrals !== null && raw.referrals !== 0 && raw.referrals !== false) {
189
+ const r = raw.referrals;
190
+ if (!isObj(r)) err('referrals', 'referrals is { "rate": 0.10, "windowDays": 30, "capPerPlayer": 1000, "holdDays": 30, "minimumInvoice": 2500 } (or leave it out: nobody is paid for sending players)');
191
+ else {
192
+ const rate = Number(r.rate ?? 0.10);
193
+ const windowDays = Math.floor(Number(r.windowDays ?? 30));
194
+ const capPerPlayer = Math.floor(Number(r.capPerPlayer ?? 1000));
195
+ const holdDays = Math.floor(Number(r.holdDays ?? 30));
196
+ const minimumInvoice = Math.floor(Number(r.minimumInvoice ?? 2500));
197
+ if (!(rate >= 0 && rate <= 0.5)) err('referrals.rate', 'rate is a share of the pre-tax price, 0 to 0.5 (0.10 is 10%)');
198
+ if (!(windowDays >= 1 && windowDays <= 90)) err('referrals.windowDays', 'windowDays is 1 to 90');
199
+ if (!(capPerPlayer >= 0 && capPerPlayer <= 10_000)) err('referrals.capPerPlayer', 'capPerPlayer is 0 to 10000 cents');
200
+ if (!(holdDays >= refundDays && holdDays <= 120)) err('referrals.holdDays', `holdDays is ${refundDays} (the refund window) to 120, so a refund never follows a payout`);
201
+ if (!(minimumInvoice >= 0 && minimumInvoice <= 100_000)) err('referrals.minimumInvoice', 'minimumInvoice is 0 to 100000 cents');
202
+ const accept = Array.isArray(r.accept) ? r.accept.map(String).filter((x) => ['stripe-invoice', 'paypal', 'wise', 'gift-codes'].includes(x)) : ['stripe-invoice'];
203
+ const billing = r.billingEmail === undefined ? null : String(r.billingEmail);
204
+ if (billing !== null && !/^[^\s@<>()",;:]{1,64}@[A-Za-z0-9.-]{1,190}\.[A-Za-z]{2,24}$/.test(billing)) err('referrals.billingEmail', 'billingEmail is where referrers send their invoices (an address the studio reads)');
205
+ referrals = { rate: Math.round(rate * 1000) / 1000, windowDays, capPerPlayer, holdDays, minimumInvoice, basis: 'pre-tax', accept, ...(billing ? { billingEmail: billing } : {}) };
206
+ }
207
+ }
208
+
209
+ const kids = audience === 'kids';
210
+ const finalTill = kids ? 'off' : TILLS.includes(till) ? till : 'off';
211
+ if (kids && till !== 'off') warnings.push({ at: 'till', message: 'studio.json says "audience": "kids": the studio sells nothing in its games (the till is off whatever shop.json says).' });
212
+ const shop = {
213
+ v: 1, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items, referrals,
214
+ open: finalTill !== 'off' && items.length > 0, ...(kids ? { audience: 'kids' } : {}),
215
+ };
216
+ return { ok: errors.length === 0, shop, errors, warnings };
217
+ }
218
+
219
+ /** studio.json "audience": "general" (the default), "teens" or "kids". */
220
+ export function audienceOf(studio) {
221
+ const a = studio?.audience;
222
+ return a === 'kids' || a === 'teens' ? a : 'general';
223
+ }
224
+
225
+ /* ------------------------------------------------------------------ who may buy */
226
+
227
+ /** The neutral age question's answer as a band (never stored as a date): child (under 13), teen (13-17), adult. */
228
+ export function bandOf(year, now = new Date()) {
229
+ const y = Math.floor(Number(year));
230
+ const thisYear = now.getUTCFullYear();
231
+ if (!(y >= 1900 && y <= thisYear)) return null;
232
+ // The youngest a person born that year can be: a year of birth alone never makes a child look older.
233
+ const age = thisYear - y - 1;
234
+ return age < 13 ? 'child' : age < 18 ? 'teen' : 'adult';
235
+ }
236
+
237
+ /**
238
+ * How this player may get an item, the one place the kids rules decide it:
239
+ * 'closed' the shop is not open (no till, no key, a kids studio)
240
+ * 'kids' a kids server: nothing is shown at all
241
+ * 'beginner' an item with a play advantage, on a beginner server: not offered there
242
+ * 'owned' they have it (and it lasts)
243
+ * 'make-an-account' a guest (a purchase would be lost with a cookie)
244
+ * 'age-question' the account has not answered the neutral age question (spending is off until it says adult)
245
+ * 'no' under 13: never
246
+ * 'ask-a-parent' 13-17: a link a parent opens on their own device and pays in their own name
247
+ * 'checkout' an adult: Stripe's own page, one purchase at a time
248
+ * 'not-yet' / 'over' a season pass before it starts or after it ends
249
+ * 'cap' this month's cap is reached
250
+ */
251
+ export function wayFor(item, { open = true, kids = false, beginner = false, player = null, band = null, owned = false, spent = 0, cap = CAP_CEILING, now = Date.now() } = {}) {
252
+ if (!open) return 'closed';
253
+ if (kids) return 'kids';
254
+ if (item.advantage && beginner) return 'beginner';
255
+ if (owned && item.kind !== 'tip') return 'owned';
256
+ if (item.starts && Date.parse(item.starts) > now) return 'not-yet';
257
+ if (item.ends && Date.parse(item.ends) <= now) return 'over';
258
+ if (!player || player.guest) return 'make-an-account';
259
+ if (!band) return 'age-question';
260
+ if (band === 'child') return 'no';
261
+ const price = item.kind === 'tip' ? item.min : item.price;
262
+ if (spent + price > cap) return 'cap';
263
+ if (band === 'teen') return 'ask-a-parent';
264
+ return 'checkout';
265
+ }
266
+
267
+ /** The public face of a shop (for the directory manifest and the shop page): never a secret, never a person. */
268
+ export function publicShop(shop) {
269
+ if (!shop) return null;
270
+ return { till: shop.till, currency: shop.currency, refundDays: shop.refundDays, capPerPlayerMonth: shop.capPerPlayerMonth, open: Boolean(shop.open), items: shop.items.length, ...(shop.referrals ? { referrals: shop.referrals } : {}) };
271
+ }
272
+
273
+ /** A price for people: 500 usd → "$5.00", 300 eur → "€3.00", 1250 cad → "CA$12.50". */
274
+ export function money(cents, currency = 'usd') {
275
+ const c = String(currency).toUpperCase();
276
+ try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: c, currencyDisplay: 'symbol' }).format(Number(cents) / 100); } catch { return `${(Number(cents) / 100).toFixed(2)} ${c}`; }
277
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The shop's rows in the studio's own D1 (@homie-rocks/studio 0.24.0, migration 0008_studio_shop.sql). Kept apart
3
+ * from worker/shop.mjs so player accounts (worker/players.mjs) can export and forget a player's purchases without
4
+ * importing the routes.
5
+ *
6
+ * WHAT IS STORED: orders (the item, the price, the state, Stripe's ids, the referrer's host), entitlements (what a
7
+ * player owns, by the keys the game reads), the age question's answer as a BAND only (adult, teen or child; never a
8
+ * date), one-time links a teenager hands a parent, the webhook events already handled (ids only), referral lines
9
+ * (the seller's books) and the statements other studios sent this one. NO card, NO address, NO email, NO name:
10
+ * Stripe keeps the buyer's details and sends the receipt.
11
+ */
12
+
13
+ export const SHOP_MIGRATION_FILE = '0008_studio_shop.sql';
14
+ export const SHOP_MIGRATION = `-- The shop (@homie-rocks/studio 0.24.0): what players bought here with the studio's own Stripe (shop/SHOP.md).
15
+ -- No card, no address, no email: Stripe keeps the buyer's details and sends the receipt. An age is kept only as a
16
+ -- band (adult, teen, child). An order outlives a deleted account without the player (the law keeps sales records).
17
+ CREATE TABLE IF NOT EXISTS shop_orders (
18
+ id TEXT PRIMARY KEY,
19
+ player TEXT,
20
+ item TEXT NOT NULL,
21
+ game TEXT,
22
+ amount INTEGER NOT NULL,
23
+ currency TEXT NOT NULL,
24
+ tax INTEGER,
25
+ total INTEGER,
26
+ till TEXT NOT NULL,
27
+ mode TEXT NOT NULL,
28
+ status TEXT NOT NULL,
29
+ session TEXT UNIQUE,
30
+ payment TEXT,
31
+ refund TEXT,
32
+ dispute TEXT,
33
+ parent INTEGER NOT NULL DEFAULT 0,
34
+ via TEXT,
35
+ note TEXT,
36
+ created_at INTEGER NOT NULL,
37
+ paid_at INTEGER,
38
+ refunded_at INTEGER,
39
+ updated_at INTEGER NOT NULL
40
+ );
41
+ CREATE INDEX IF NOT EXISTS shop_orders_player ON shop_orders (player, created_at);
42
+ CREATE INDEX IF NOT EXISTS shop_orders_status ON shop_orders (status, created_at);
43
+ CREATE INDEX IF NOT EXISTS shop_orders_payment ON shop_orders (payment);
44
+ CREATE TABLE IF NOT EXISTS entitlements (
45
+ player TEXT NOT NULL,
46
+ key TEXT NOT NULL,
47
+ item TEXT NOT NULL,
48
+ order_id TEXT NOT NULL,
49
+ starts_at INTEGER NOT NULL,
50
+ ends_at INTEGER,
51
+ state TEXT NOT NULL,
52
+ used_at INTEGER,
53
+ PRIMARY KEY (player, key, order_id)
54
+ ) WITHOUT ROWID;
55
+ CREATE INDEX IF NOT EXISTS entitlements_order ON entitlements (order_id);
56
+ CREATE TABLE IF NOT EXISTS shop_events (
57
+ id TEXT PRIMARY KEY,
58
+ type TEXT NOT NULL,
59
+ at INTEGER NOT NULL
60
+ ) WITHOUT ROWID;
61
+ CREATE TABLE IF NOT EXISTS player_age (
62
+ player TEXT PRIMARY KEY,
63
+ band TEXT NOT NULL,
64
+ asked_at INTEGER NOT NULL
65
+ ) WITHOUT ROWID;
66
+ CREATE TABLE IF NOT EXISTS shop_parent_links (
67
+ hash TEXT PRIMARY KEY,
68
+ player TEXT NOT NULL,
69
+ item TEXT NOT NULL,
70
+ game TEXT,
71
+ order_id TEXT,
72
+ expires_at INTEGER NOT NULL
73
+ ) WITHOUT ROWID;
74
+ CREATE TABLE IF NOT EXISTS referral_lines (
75
+ order_id TEXT PRIMARY KEY,
76
+ via TEXT NOT NULL,
77
+ net INTEGER NOT NULL,
78
+ rate REAL NOT NULL,
79
+ share INTEGER NOT NULL,
80
+ currency TEXT NOT NULL,
81
+ state TEXT NOT NULL,
82
+ period TEXT NOT NULL,
83
+ hold_until INTEGER NOT NULL,
84
+ settled_ref TEXT,
85
+ created_at INTEGER NOT NULL
86
+ ) WITHOUT ROWID;
87
+ CREATE INDEX IF NOT EXISTS referral_lines_via ON referral_lines (via, period);
88
+ CREATE TABLE IF NOT EXISTS referral_statements (
89
+ seller TEXT NOT NULL,
90
+ period TEXT NOT NULL,
91
+ owed INTEGER NOT NULL,
92
+ pending INTEGER NOT NULL,
93
+ currency TEXT NOT NULL,
94
+ body TEXT NOT NULL,
95
+ received_at INTEGER NOT NULL,
96
+ PRIMARY KEY (seller, period)
97
+ ) WITHOUT ROWID;
98
+ `;
99
+
100
+ const DAY = 86_400_000;
101
+ export const ORDER_ID = /^ord_[A-Za-z0-9]{20}$/;
102
+ const PLAYER_ID = /^pl_[A-Za-z0-9_-]{22}$/;
103
+
104
+ export function newOrderId() {
105
+ const raw = new Uint8Array(15);
106
+ crypto.getRandomValues(raw);
107
+ return `ord_${btoa(String.fromCharCode(...raw)).replace(/[^A-Za-z0-9]/g, '').padEnd(20, '0').slice(0, 20)}`;
108
+ }
109
+
110
+ /** Whether the shop's tables are in this D1 (a studio deployed before 0.24.0 has none until `npm run deploy`). */
111
+ export async function migrated(env) {
112
+ if (!env?.DB) return false;
113
+ try { await env.DB.prepare('SELECT 1 FROM shop_orders LIMIT 1').first(); return true; } catch { return false; }
114
+ }
115
+
116
+ /** The age band this account gave (adult, teen, child), or null when it has not been asked. */
117
+ export async function bandOfPlayer(env, player) {
118
+ if (!PLAYER_ID.test(String(player ?? ''))) return null;
119
+ try { return (await env.DB.prepare('SELECT band FROM player_age WHERE player = ?1').bind(player).first())?.band ?? null; } catch { return null; }
120
+ }
121
+
122
+ /** The answer is kept once: a player cannot answer again to get a different band. */
123
+ export async function setBand(env, player, band) {
124
+ await env.DB.prepare('INSERT INTO player_age (player, band, asked_at) VALUES (?1, ?2, ?3) ON CONFLICT(player) DO NOTHING').bind(player, band, Date.now()).run();
125
+ return bandOfPlayer(env, player);
126
+ }
127
+
128
+ /** What a player owns now: [{ key, item, until, used }] (live entitlements only). */
129
+ export async function ownsOf(env, player, now = Date.now()) {
130
+ if (!PLAYER_ID.test(String(player ?? ''))) return [];
131
+ try {
132
+ const { results } = await env.DB.prepare("SELECT key, item, ends_at, used_at FROM entitlements WHERE player = ?1 AND state = 'active' AND starts_at <= ?2 AND (ends_at IS NULL OR ends_at > ?2) ORDER BY starts_at").bind(player, now).all();
133
+ const out = new Map();
134
+ for (const r of results ?? []) {
135
+ const until = r.ends_at === null ? null : Number(r.ends_at);
136
+ const prev = out.get(r.key);
137
+ // Two orders of the same key: the one that lasts longest wins.
138
+ if (!prev || (prev.until !== null && (until === null || until > prev.until))) out.set(r.key, { key: r.key, item: r.item, until, used: r.used_at !== null && r.used_at !== undefined });
139
+ }
140
+ return [...out.values()];
141
+ } catch { return []; }
142
+ }
143
+
144
+ /** What a player spent here this calendar month (paid, and checkouts still open), in minor units. */
145
+ export async function spentThisMonth(env, player, now = Date.now()) {
146
+ const d = new Date(now);
147
+ const start = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
148
+ try {
149
+ const r = await env.DB.prepare("SELECT COALESCE(SUM(amount), 0) AS n FROM shop_orders WHERE player = ?1 AND ((status IN ('paid', 'disputed') AND paid_at >= ?2) OR (status = 'started' AND created_at >= ?3))").bind(player, start, now - 31 * 60_000).first();
150
+ return Number(r?.n) || 0;
151
+ } catch { return 0; }
152
+ }
153
+
154
+ export async function orderById(env, id) {
155
+ if (!ORDER_ID.test(String(id ?? ''))) return null;
156
+ try { return await env.DB.prepare('SELECT * FROM shop_orders WHERE id = ?1').bind(id).first(); } catch { return null; }
157
+ }
158
+
159
+ export const orderBySession = (env, session) => env.DB.prepare('SELECT * FROM shop_orders WHERE session = ?1').bind(String(session ?? '')).first();
160
+ export const orderByPayment = (env, payment) => env.DB.prepare('SELECT * FROM shop_orders WHERE payment = ?1').bind(String(payment ?? '')).first();
161
+
162
+ /** An order as the player, the office and the export see it (never a card, never an email). */
163
+ export function orderView(r) {
164
+ return {
165
+ id: r.id, item: r.item, game: r.game ?? null, amount: Number(r.amount), currency: r.currency, tax: r.tax === null ? null : Number(r.tax), total: r.total === null ? null : Number(r.total),
166
+ status: r.status, till: r.till, mode: r.mode, parent: Number(r.parent) === 1, via: r.via ?? null,
167
+ createdAt: Number(r.created_at), paidAt: r.paid_at === null ? null : Number(r.paid_at), refundedAt: r.refunded_at === null ? null : Number(r.refunded_at),
168
+ };
169
+ }
170
+
171
+ /** Everything of a player's in the shop, for their own export (worker/players.mjs). */
172
+ export async function shopDataOf(env, player) {
173
+ try {
174
+ const orders = ((await env.DB.prepare("SELECT * FROM shop_orders WHERE player = ?1 AND status != 'started' ORDER BY created_at").bind(player).all()).results ?? []).map(orderView);
175
+ const owns = await ownsOf(env, player);
176
+ const band = await bandOfPlayer(env, player);
177
+ return { orders, owns, ageBand: band };
178
+ } catch { return null; }
179
+ }
180
+
181
+ /** Whether a player owns something that would be lost with their account (the delete asks first). */
182
+ export async function livePurchases(env, player) {
183
+ return (await ownsOf(env, player)).length;
184
+ }
185
+
186
+ /**
187
+ * A deleted account: what it owned goes, its age band goes, and its orders stay WITHOUT the player (a seller keeps
188
+ * sales records for its accountant and the tax office; nothing left in them names anyone). Statements for D1's batch.
189
+ */
190
+ export function forgetPlayerShop(env, player) {
191
+ return [
192
+ env.DB.prepare('DELETE FROM entitlements WHERE player = ?1').bind(player),
193
+ env.DB.prepare('DELETE FROM player_age WHERE player = ?1').bind(player),
194
+ env.DB.prepare('DELETE FROM shop_parent_links WHERE player = ?1').bind(player),
195
+ env.DB.prepare('UPDATE shop_orders SET player = NULL WHERE player = ?1').bind(player),
196
+ ];
197
+ }
198
+
199
+ /** Grant an order's entitlements (idempotent on player, key and order). */
200
+ export function grantStatements(env, order, item, now = Date.now()) {
201
+ const starts = item.starts ? Math.max(now, Date.parse(item.starts)) : now;
202
+ const ends = item.ends ? Date.parse(item.ends) : item.days ? starts + item.days * DAY : null;
203
+ return (item.gives ?? []).map((key) => env.DB.prepare("INSERT INTO entitlements (player, key, item, order_id, starts_at, ends_at, state, used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active', NULL) ON CONFLICT(player, key, order_id) DO UPDATE SET state = 'active'")
204
+ .bind(order.player, key, item.id, order.id, starts, ends));
205
+ }
206
+
207
+ export const revokeStatement = (env, orderId) => env.DB.prepare("UPDATE entitlements SET state = 'revoked' WHERE order_id = ?1").bind(orderId);
208
+ export const restoreStatement = (env, orderId) => env.DB.prepare("UPDATE entitlements SET state = 'active' WHERE order_id = ?1").bind(orderId);
209
+
210
+ /** The badge a player shows in rooms (a supporter's), from what they own: the first live badge key, or null. */
211
+ export async function badgeOf(env, player, shop) {
212
+ const owns = await ownsOf(env, player);
213
+ const key = owns.find((o) => o.key.startsWith('badge:'));
214
+ if (!key) return null;
215
+ const item = (shop?.items ?? []).find((i) => i.id === key.item);
216
+ return item?.badge ?? key.key.slice(6, 22);
217
+ }