@homie-rocks/studio 0.32.1 → 0.33.1

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 (66) hide show
  1. package/CHANGELOG.md +67 -1
  2. package/README.md +24 -6
  3. package/agents/agents.ts +57 -24
  4. package/bin/homie-studio.mjs +3 -3
  5. package/lib/build.mjs +64 -9
  6. package/lib/cf-usage.mjs +76 -0
  7. package/lib/cloudflare.mjs +25 -2
  8. package/lib/dev.mjs +45 -4
  9. package/lib/lab-check.mjs +3 -2
  10. package/lib/mcp-tools.mjs +1 -1
  11. package/lib/mcp.mjs +1 -1
  12. package/lib/office.mjs +7 -0
  13. package/lib/perf.mjs +8 -3
  14. package/lib/player-image.mjs +40 -0
  15. package/lib/rules-build.mjs +184 -0
  16. package/lib/rules-guard.mjs +770 -0
  17. package/lib/scaffold.mjs +43 -16
  18. package/lib/shop-catalog.mjs +10 -9
  19. package/lib/shop.mjs +32 -15
  20. package/lib/studio.mjs +22 -1
  21. package/lib/template-history.json +90 -0
  22. package/lib/upgrade.mjs +19 -15
  23. package/lib/version.mjs +2 -7
  24. package/netplay/NETPLAY.md +249 -4
  25. package/netplay/netplay.ts +55 -13
  26. package/package.json +23 -1
  27. package/rules/core.ts +1250 -0
  28. package/rules/guard.ts +419 -0
  29. package/rules/host.ts +707 -0
  30. package/rules/math.ts +266 -0
  31. package/rules/pack.ts +292 -0
  32. package/rules/rules.ts +544 -0
  33. package/rules/view.ts +447 -0
  34. package/shop/SELLING.md +14 -16
  35. package/shop/SHOP.md +119 -30
  36. package/shop/shop.ts +6 -8
  37. package/site/SITE.md +119 -2
  38. package/starters/coin-dash/game.json +11 -0
  39. package/starters/coin-dash/index.html +17 -0
  40. package/starters/coin-dash/map/main.json +16 -0
  41. package/starters/coin-dash/src/move.ts +27 -0
  42. package/starters/coin-dash/src/rules.ts +86 -0
  43. package/starters/coin-dash/src/view.ts +106 -0
  44. package/starters/coin-dash/tunables.json +5 -0
  45. package/starters/ember-vale/index.html +11 -0
  46. package/worker/brain.mjs +8 -7
  47. package/worker/chat-page.mjs +1 -1
  48. package/worker/embed.mjs +135 -0
  49. package/worker/hosted.mjs +56 -0
  50. package/worker/index.mjs +413 -22
  51. package/worker/office-page.mjs +1 -0
  52. package/worker/office.mjs +24 -7
  53. package/worker/pages.mjs +189 -43
  54. package/worker/players.mjs +10 -6
  55. package/worker/referrals.mjs +192 -57
  56. package/worker/room-store.mjs +54 -0
  57. package/worker/room.mjs +294 -119
  58. package/worker/saves-shell.mjs +7 -1
  59. package/worker/schema.mjs +4 -3
  60. package/worker/shop-page.mjs +103 -37
  61. package/worker/shop-rules.mjs +156 -117
  62. package/worker/shop-store.mjs +70 -9
  63. package/worker/shop.mjs +275 -143
  64. package/worker/site.mjs +26 -5
  65. package/worker/stripe.mjs +9 -6
  66. package/worker/version.mjs +1 -1
@@ -1,54 +1,61 @@
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
-
1
+ /** Shop validation and the studio's editable policies. Pure: shared by the build and Worker. */
22
2
  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. */
3
+ /** Implemented payment adapters; item kinds do not select a billing integration. */
24
4
  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',
5
+ export const POLICY_PRESETS = Object.freeze({
6
+ protective: Object.freeze({ withdrawalAcknowledgement: true, childAge: 13, adultAge: 18, requireAccount: true, ageQuestion: true, children: 'deny', teens: 'parent',
7
+ kidsStudio: false, kidsServer: false, beginnerAdvantages: false, kidsAdvantages: false,
8
+ paidRandomRewards: false, countdownOffers: false, virtualCurrency: false, supporterAdvantages: false,
9
+ repeatPurchases: false, refundUsedItems: false, televisionCheckout: false }),
10
+ 'adults-only': Object.freeze({ withdrawalAcknowledgement: true, childAge: 13, adultAge: 18, requireAccount: true, ageQuestion: true, children: 'deny', teens: 'deny',
11
+ kidsStudio: false, kidsServer: false, beginnerAdvantages: false, kidsAdvantages: false,
12
+ paidRandomRewards: false, countdownOffers: false, virtualCurrency: false, supporterAdvantages: false,
13
+ repeatPurchases: false, refundUsedItems: false, televisionCheckout: false }),
14
+ custom: Object.freeze({ withdrawalAcknowledgement: true, childAge: 13, adultAge: 18, requireAccount: false, ageQuestion: false, children: 'allow', teens: 'allow',
15
+ kidsStudio: true, kidsServer: true, beginnerAdvantages: true, kidsAdvantages: true,
16
+ paidRandomRewards: true, countdownOffers: true, virtualCurrency: true, supporterAdvantages: true,
17
+ repeatPurchases: true, refundUsedItems: true, televisionCheckout: true }),
33
18
  });
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}$/;
19
+ export const ITEM_ID = /^[^\u0000-\u001f\u007f]+$/;
20
+ export const ENTITLEMENT_KEY = /^[^\u0000-\u001f\u007f]+$/;
45
21
  const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
46
22
  const TAX_CODE = /^txcd_\d{8}$/;
47
23
  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']);
24
+ // Stripe currency units, special cases and settlement-currency minimums, fetched 2026-10-08:
25
+ // https://docs.stripe.com/currencies#zero-decimal
26
+ // https://docs.stripe.com/currencies#special-cases
27
+ // https://docs.stripe.com/currencies#minimum-and-maximum-charge-amounts
28
+ // Free Checkout orders: https://docs.stripe.com/payments/checkout/no-cost-orders (fetched 2026-10-08).
29
+ // Actual minimums after conversion and payment-method maximums are enforced by Stripe at checkout.
30
+ export const ZERO_DECIMAL = new Set(['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'vnd', 'vuv', 'xaf', 'xof', 'xpf']);
31
+ export const THREE_DECIMAL = new Set(['bhd', 'jod', 'kwd', 'omr', 'tnd']);
32
+ export const currencyScale = (currency) => ZERO_DECIMAL.has(String(currency).toLowerCase()) ? 1 : THREE_DECIMAL.has(String(currency).toLowerCase()) ? 1000 : 100;
33
+ // No three-decimal divisibility refusal: https://docs.stripe.com/currencies does not document one (2026-10-08).
34
+ export const amountStep = (currency) => ['isk', 'ugx'].includes(String(currency).toLowerCase()) ? 100 : 1;
35
+ const MINIMUMS = { usd: 50, aed: 200, ars: 50, aud: 50, brl: 50, cad: 50, chf: 50, cop: 50, czk: 1500, dkk: 250, eur: 50, gbp: 30, hkd: 400, huf: 17500, idr: 50, ils: 50, inr: 50, jpy: 50, krw: 50, mxn: 1000, myr: 200, nok: 300, nzd: 50, php: 50, pln: 200, ron: 200, rub: 50, sek: 300, sgd: 50, thb: 1000, zar: 50 };
36
+ export const minimumCharge = (currency) => MINIMUMS[currency] ?? amountStep(currency);
37
+ /** Parse the buyer's decimal text without rounding a fractional currency unit. */
38
+ export function parseAmount(value, scale = 100) {
39
+ const digits = Math.log10(scale);
40
+ if (!new RegExp(`^\\d+(?:\\.\\d{0,${digits}})?$`).test(String(value))) return NaN;
41
+ const parts = String(value).split('.');
42
+ if (scale === 1 && parts[1] && Number(parts[1]) !== 0) return NaN;
43
+ const n = Number(BigInt(parts[0]) * BigInt(scale) + (scale === 1 ? 0n : BigInt((parts[1] || '').padEnd(digits, '0'))));
44
+ return Number.isSafeInteger(n) ? n : NaN;
45
+ }
46
+ export function amountError(amount, currency) {
47
+ if (!Number.isSafeInteger(amount) || amount < 0) return 'Stripe amounts must be nonnegative whole numbers of its currency unit, within JavaScript safe integer arithmetic.';
48
+ if (amount % amountStep(currency)) return `Stripe requires ${currency.toUpperCase()} amounts divisible by ${amountStep(currency)} in its API minor units.`;
50
49
 
51
- /** Paid randomness, in any wording a shop file might use. Whole words, so "Jukebox" is fine and "Mystery Box" is not. */
50
+ return null;
51
+ }
52
+
53
+ /** Checkout wording is shared by validation and the request path. */
54
+ export function checkoutMessage(shop, studio = 'this studio', parent = false) {
55
+ return `${parent ? "This is for your child's account. " : ''}Delivered at once to the player's account on ${studio}. ${shop.policy.withdrawalAcknowledgement ? 'By paying you ask for it now, which ends the 14-day withdrawal right;' : 'Your statutory rights still apply.'} ${shop.refundDays === null ? (shop.policy.withdrawalAcknowledgement ? 'ask the studio about refunds.' : 'Ask the studio about refunds.') : `${shop.policy.refundUsedItems ? 'an item' : 'an unused item'} can still be refunded within ${shop.refundDays} days.`}`;
56
+ }
57
+
58
+ /** Content patterns used only when the studio's paidRandomRewards policy refuses them. */
52
59
  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
60
  /** Fields that only exist to make an item random. */
54
61
  const RANDOM_FIELDS = new Set(['odds', 'chance', 'chances', 'random', 'pool', 'drops', 'droptable', 'loottable', 'weights', 'rarityweights', 'probability', 'probabilities']);
@@ -56,8 +63,10 @@ const RANDOM_FIELDS = new Set(['odds', 'chance', 'chances', 'random', 'pool', 'd
56
63
  const PRESSURE_FIELDS = new Set(['countdown', 'timer', 'hurry', 'limitedtime', 'flash', 'flashsale', 'expiresin', 'urgency', 'scarcity', 'onlyleft']);
57
64
  const CURRENCY_KEYS = /^(?:gems?|coins?|currency|credits?|points|tokens|bucks|diamonds|crystals|vbucks|robux):/i;
58
65
 
66
+ const numeric = (value) => typeof value === 'number' ? value : typeof value === 'string' && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value) ? Number(value) : NaN;
67
+
59
68
  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);
69
+ const oneLine = (v, max = Infinity) => String(v ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
61
70
 
62
71
  /** Every string in a value, with the key path it sits at (to name where a forbidden word is). */
63
72
  function strings(v, at = '', out = []) {
@@ -82,84 +91,114 @@ function dateOf(v) {
82
91
 
83
92
  /** A badge's words from an entitlement key: badge:supporter → Supporter. */
84
93
  export function badgeWord(key, item = null) {
85
- const given = oneLine(item?.badge, 16);
94
+ const given = oneLine(item?.badge);
86
95
  if (given) return given;
87
96
  const word = String(key ?? '').replace(/^badge:/, '').replace(/[-_.:]+/g, ' ').trim();
88
- return word ? word.charAt(0).toUpperCase() + word.slice(1, 16) : '';
97
+ return word ? word.charAt(0).toUpperCase() + word.slice(1) : '';
89
98
  }
90
99
 
91
100
  /**
92
101
  * Check a shop.json. `{ ok, shop, errors, warnings }`: `shop` is the public, normalised shop the Worker sells from
93
102
  * (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).
103
+ * `audience`: studio.json "audience" (the studio policy decides whether a kids studio sells).
95
104
  */
96
- export function checkShop(raw, { games = null, audience = 'general' } = {}) {
105
+ export function checkShop(raw, { games = null, audience = 'general', studioName = 'this studio' } = {}) {
97
106
  const errors = [];
98
107
  const warnings = [];
99
108
  const err = (at, message) => errors.push({ at, message });
100
109
  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.`);
110
+ const preset = raw.policy?.preset ?? 'protective';
111
+ if (!Object.hasOwn(POLICY_PRESETS, preset)) err('policy.preset', 'choose protective, adults-only or custom');
112
+ if (raw.policy !== undefined && !isObj(raw.policy)) err('policy', 'policy is an object with a named preset and editable rules');
113
+ const policy = { preset, ...(Object.hasOwn(POLICY_PRESETS, preset) ? POLICY_PRESETS[preset] : POLICY_PRESETS.protective) };
114
+ for (const key of Object.keys(POLICY_PRESETS.protective)) {
115
+ if (raw.policy?.[key] === undefined) continue;
116
+ const value = raw.policy[key];
117
+ if (['childAge', 'adultAge'].includes(key)) {
118
+ if (!Number.isSafeInteger(value) || value < 1) err(`policy.${key}`, 'use a positive whole age in years');
119
+ else policy[key] = value;
120
+ continue;
121
+ }
122
+ if (['children', 'teens'].includes(key) ? !['deny', 'parent', 'allow'].includes(value) : typeof value !== 'boolean') err(`policy.${key}`, 'use a boolean, or deny / parent / allow for children and teens');
123
+ else policy[key] = value;
104
124
  }
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)');
125
+ if (policy.childAge >= policy.adultAge) err('policy.adultAge', 'adultAge must be greater than childAge');
126
+ for (const [at, text] of strings(raw.items)) {
127
+ if (!policy.paidRandomRewards && RANDOM_WORDS.test(text)) err(`items.${at}`, `The studio's policy.paidRandomRewards setting refuses paid randomness: "${oneLine(text, 40)}".`);
128
+ }
129
+ for (const [at, k] of keys(raw.items)) {
130
+ if (!policy.paidRandomRewards && RANDOM_FIELDS.has(k)) err(`items.${at}`, "The studio's policy.paidRandomRewards setting refuses odds, drops or a random pool.");
131
+ if (!policy.countdownOffers && PRESSURE_FIELDS.has(k)) err(`items.${at}`, "The studio's policy.countdownOffers setting refuses countdown fields.");
108
132
  }
109
133
  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"');
134
+ 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
135
  const currency = raw.currency === undefined ? 'usd' : String(raw.currency).toLowerCase();
113
136
  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);
137
+ const optionalNumber = (value, at, { integer = true } = {}) => {
138
+ if (value === undefined || value === null) return null;
139
+ value = numeric(value); // Decimal numeric strings remain accepted; booleans and containers do not.
140
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || (integer && !at.endsWith('Days') && !Number.isSafeInteger(value))) err(at, `write a nonnegative ${integer && !at.endsWith('Days') ? 'whole number of minor units (for example 2500, not 2500.7)' : 'finite number'}, or omit the setting.${integer && !at.endsWith('Days') ? ' Amounts are never rounded.' : ''}`);
141
+ if (at.endsWith('Days') && value !== null && (!Number.isSafeInteger(Math.round(value * 86400000)) || !Number.isSafeInteger(Math.round(value * 86400000) + Date.now()) || !Number.isFinite(new Date(Math.round(value * 86400000) + Date.now()).getTime()))) err(at, 'duration rounded to milliseconds must remain within safe arithmetic and the JavaScript timestamp range');
142
+ return value;
143
+ };
144
+ const refundDays = optionalNumber(raw.refundDays, 'refundDays');
145
+ const cap = optionalNumber(raw.capPerPlayerMonth, 'capPerPlayerMonth');
146
+ // 600 new buyers per hour allows a school or venue to arrive together without disabling flood protection.
147
+ const guestBuyersPerAddressPerHour = numeric(raw.guestBuyersPerAddressPerHour ?? 600);
148
+ if (!Number.isSafeInteger(guestBuyersPerAddressPerHour) || guestBuyersPerAddressPerHour < 1) err('guestBuyersPerAddressPerHour', 'use a positive whole number for new guest flood protection');
149
+ const purchaseAttemptsPerMinute = numeric(raw.purchaseAttemptsPerMinute ?? 6);
150
+ // A school or venue can share an address; its aggregate bucket must allow many independent accounts.
151
+ const purchaseAttemptsPerAddressPerMinute = numeric(raw.purchaseAttemptsPerAddressPerMinute ?? 600);
152
+ if (!Number.isSafeInteger(purchaseAttemptsPerAddressPerMinute) || purchaseAttemptsPerAddressPerMinute < 1) err('purchaseAttemptsPerAddressPerMinute', 'use a positive whole number for address flood protection');
153
+ if (!Number.isSafeInteger(purchaseAttemptsPerMinute) || purchaseAttemptsPerMinute < 1) err('purchaseAttemptsPerMinute', 'use a positive whole number for the studio account rate limit');
154
+ if (raw.supportHomie !== undefined && Number(raw.supportHomie) !== 0) err('supportHomie', 'Homie takes no cut; this adapter does not transfer a share to Homie');
121
155
 
122
156
  const items = [];
123
157
  const list = Array.isArray(raw.items) ? raw.items : [];
124
158
  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
159
  const seen = new Set();
127
- list.slice(0, ITEMS_MAX).forEach((it, i) => {
160
+ list.forEach((it, i) => {
128
161
  const at = `items[${i}]`;
129
162
  if (!isObj(it)) { err(at, 'an item is an object'); return; }
130
163
  const id = String(it.id ?? '');
131
- if (!ITEM_ID.test(id)) err(`${at}.id`, 'id is 1 to 40 lowercase letters, digits or -');
164
+ if (!id.trim() || !ITEM_ID.test(id)) err(`${at}.id`, 'id is nonempty text without control characters');
132
165
  else if (seen.has(id)) err(`${at}.id`, `two items are called "${id}"`);
133
166
  seen.add(id);
134
167
  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);
168
+ if (!kind.trim()) err(`${at}.kind`, 'an item needs a kind chosen by the studio');
169
+ if (!policy.virtualCurrency && ['gems', 'coins', 'currency'].includes(kind)) err(`${at}.kind`, "The studio's policy.virtualCurrency setting refuses currency items.");
170
+ const name = oneLine(it.name);
138
171
  if (!name) err(`${at}.name`, 'an item needs a name the buyer reads');
139
- const blurb = oneLine(it.blurb ?? it.description, 200);
172
+ const blurb = oneLine(it.blurb ?? it.description);
173
+ // Stripe Product text constraints: https://docs.stripe.com/api/products/create
174
+ if ([...name].length > 5000) err(`${at}.name`, 'Stripe product names allow at most 5000 characters. Shorten the name.');
175
+ if ([...blurb].length > 40000) err(`${at}.blurb`, 'Stripe product descriptions allow at most 40000 characters. Shorten the description.');
140
176
  let price = null; let min = null; let max = null;
141
177
  if (kind === 'tip') {
142
178
  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)');
179
+ min = numeric(it.min ?? 0); max = optionalNumber(it.max, `${at}.max`);
180
+ const why = amountError(min, currency);
181
+ if (why) err(`${at}.min`, why);
182
+ if (max !== null && (max < min || amountError(max, currency))) err(`${at}.max`, 'the studio tip maximum must be at least its minimum and use valid Stripe currency units');
145
183
  } 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.` }); }
184
+ const why = amountError(numeric(it.price), currency);
185
+ if (why) err(`${at}.price`, why);
186
+ else price = numeric(it.price);
149
187
  }
150
- if (price !== null && price > cap) err(`${at}.price`, `costs more than the monthly cap (${cap} cents): nobody could buy it`);
188
+ const charge = kind === 'tip' ? min : price;
189
+ if (currencyScale(currency) === 1000 && charge % 10) warnings.push({ at: `${at}.price`, message: 'This three-decimal amount uses the smallest minor unit. Confirm support with your Stripe payment method; Stripe decides acceptance (https://docs.stripe.com/currencies).' });
190
+ if (charge > 0 && charge < minimumCharge(currency)) warnings.push({ at: `${at}.price`, message: `Stripe's minimum for settlement in ${currency.toUpperCase()} is ${money(minimumCharge(currency), currency)}. Your settlement currency and payment method determine the actual minimum at checkout.` });
191
+ if (cap !== null && price !== null && price > cap) warnings.push({ at: `${at}.price`, message: 'This price exceeds the studio capPerPlayerMonth setting; raise or remove that setting to allow a purchase.' });
151
192
  const gives = Array.isArray(it.gives) ? it.gives.map(String) : [];
152
193
  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
194
  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`);
195
+ if (!g.trim() || !ENTITLEMENT_KEY.test(g)) err(`${at}.gives`, `"${oneLine(g, 40)}" is not a key: use nonempty text without control characters`);
196
+ else if (!policy.virtualCurrency && CURRENCY_KEYS.test(g)) err(`${at}.gives`, `"${g}" is a currency refused by the studio policy.virtualCurrency setting`);
158
197
  }
159
198
  let days = null;
160
199
  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; }
200
+ days = numeric(it.days);
201
+ if (!(days > 0 && Number.isFinite(days) && Number.isSafeInteger(Math.round(days * 86400000)) && Number.isSafeInteger(Math.round(days * 86400000) + Date.now()) && Number.isFinite(new Date(Math.round(days * 86400000) + Date.now()).getTime()))) { err(`${at}.days`, 'days is a positive duration with whole milliseconds within safe arithmetic and the JavaScript timestamp range (omit for forever)'); days = null; }
163
202
  }
164
203
  const starts = dateOf(it.starts);
165
204
  const ends = dateOf(it.ends);
@@ -172,15 +211,18 @@ export function checkShop(raw, { games = null, audience = 'general' } = {}) {
172
211
  }
173
212
  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
213
  const advantage = it.advantage === true;
175
- if (advantage && kind === 'supporter') err(`${at}.advantage`, 'a supporter pack never changes how the game plays');
214
+ if (advantage && kind === 'supporter' && !policy.supporterAdvantages) err(`${at}.advantage`, 'the studio policy.supporterAdvantages setting refuses an advantage in a supporter pack');
176
215
  const badge = gives.find((g) => g.startsWith('badge:'));
216
+ for (const key of Object.keys(it)) {
217
+ if (!['id', 'kind', 'name', 'blurb', 'description', 'price', 'min', 'max', 'gives', 'badge', 'days', 'starts', 'ends', 'game', 'taxCode', 'advantage'].includes(key)) warnings.push({ at: `${at}.${key}`, message: 'This field is not published by the shop. Describe any buyer-facing meaning in blurb so buyers can read it.' });
218
+ }
177
219
  items.push({
178
220
  id, kind, name, ...(blurb ? { blurb } : {}),
179
221
  ...(kind === 'tip' ? { price: 'choose', min, max } : { price }),
180
222
  gives, ...(badge ? { badge: badgeWord(badge, it) } : {}),
181
223
  ...(days ? { days } : {}), ...(Number.isFinite(starts) ? { starts: new Date(starts).toISOString() } : {}), ...(Number.isFinite(ends) ? { ends: new Date(ends).toISOString() } : {}),
182
224
  ...(it.game ? { game: String(it.game) } : {}), ...(it.taxCode ? { taxCode: String(it.taxCode) } : {}),
183
- ...(advantage ? { advantage: true } : {}),
225
+ advantage,
184
226
  });
185
227
  });
186
228
 
@@ -189,20 +231,15 @@ export function checkShop(raw, { games = null, audience = 'general' } = {}) {
189
231
  const r = raw.referrals;
190
232
  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
233
  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'];
234
+ const rate = optionalNumber(r.rate ?? 0.10, 'referrals.rate', { integer: false });
235
+ const windowDays = optionalNumber(r.windowDays, 'referrals.windowDays');
236
+ const capPerPlayer = optionalNumber(r.capPerPlayer, 'referrals.capPerPlayer');
237
+ const holdDays = optionalNumber(r.holdDays, 'referrals.holdDays');
238
+ const minimumInvoice = optionalNumber(r.minimumInvoice, 'referrals.minimumInvoice');
239
+ const accept = Array.isArray(r.accept) ? r.accept.map(String) : ['stripe-invoice'];
203
240
  const billing = r.billingEmail === undefined ? null : String(r.billingEmail);
204
241
  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 } : {}) };
242
+ referrals = { rate, windowDays, capPerPlayer, holdDays, minimumInvoice, basis: 'pre-tax', accept, ...(billing ? { billingEmail: billing } : {}) };
206
243
  }
207
244
  }
208
245
 
@@ -213,13 +250,15 @@ export function checkShop(raw, { games = null, audience = 'general' } = {}) {
213
250
  else catalog = [...new Set(raw.catalog)].sort();
214
251
  }
215
252
 
216
- const kids = audience === 'kids';
253
+ const kids = audience === 'kids' && !policy.kidsStudio;
217
254
  const finalTill = kids ? 'off' : TILLS.includes(till) ? till : 'off';
218
- 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).' });
255
+ 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 under policy.kidsStudio).' });
219
256
  const shop = {
220
- v: 1, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items, referrals,
257
+ v: 1, policy, guestBuyersPerAddressPerHour, purchaseAttemptsPerMinute, purchaseAttemptsPerAddressPerMinute, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items, referrals,
221
258
  open: finalTill !== 'off' && items.length > 0, ...(kids ? { audience: 'kids' } : {}), ...(catalog.length ? { catalog } : {}),
222
259
  };
260
+ // https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-custom_text-submit-message
261
+ if ([...checkoutMessage(shop, studioName, true)].length > 1200) err('studio.name / refundDays', 'Stripe submit messages allow at most 1200 characters. Shorten the studio name or refund wording.');
223
262
  return { ok: errors.length === 0, shop, errors, warnings };
224
263
  }
225
264
 
@@ -241,53 +280,53 @@ export function audienceOf(studio) {
241
280
  /* ------------------------------------------------------------------ who may buy */
242
281
 
243
282
  /** The neutral age question's answer as a band (never stored as a date): child (under 13), teen (13-17), adult. */
244
- export function bandOf(year, now = new Date()) {
283
+ export function bandOf(year, now = new Date(), policy = POLICY_PRESETS.protective) {
245
284
  const y = Math.floor(Number(year));
246
285
  const thisYear = now.getUTCFullYear();
247
- if (!(y >= 1900 && y <= thisYear)) return null;
286
+ if (!(Number.isSafeInteger(y) && y >= thisYear - 120 && y <= thisYear)) return null;
248
287
  // The youngest a person born that year can be: a year of birth alone never makes a child look older.
249
288
  const age = thisYear - y - 1;
250
- return age < 13 ? 'child' : age < 18 ? 'teen' : 'adult';
289
+ return age < policy.childAge ? 'child' : age < policy.adultAge ? 'teen' : 'adult';
251
290
  }
252
291
 
253
292
  /**
254
- * How this player may get an item, the one place the kids rules decide it:
293
+ * How this player may get an item under the studio policy (descriptions below use protective):
255
294
  * 'closed' the shop is not open (no till, no key, a kids studio)
256
295
  * 'kids' a kids server: nothing is shown at all
257
296
  * 'beginner' an item with a play advantage, on a beginner server: not offered there
258
297
  * 'owned' they have it (and it lasts)
259
298
  * 'make-an-account' a guest (a purchase would be lost with a cookie)
260
- * 'age-question' the account has not answered the neutral age question (spending is off until it says adult)
261
- * 'no' under 13: never
299
+ * 'age-question' the studio requires an age band before checkout
300
+ * 'no' the studio denies this age band
262
301
  * 'ask-a-parent' 13-17: a link a parent opens on their own device and pays in their own name
263
302
  * 'checkout' an adult: Stripe's own page, one purchase at a time
264
303
  * 'not-yet' / 'over' a season pass before it starts or after it ends
265
304
  * 'cap' this month's cap is reached
266
305
  */
267
- export function wayFor(item, { open = true, kids = false, beginner = false, player = null, band = null, owned = false, spent = 0, cap = CAP_CEILING, now = Date.now() } = {}) {
306
+ export function wayFor(item, { open = true, kids = false, beginner = false, player = null, band = null, owned = false, spent = 0, cap = null, policy = POLICY_PRESETS.protective, now = Date.now() } = {}) {
268
307
  if (!open) return 'closed';
269
- if (kids) return 'kids';
270
- if (item.advantage && beginner) return 'beginner';
271
- if (owned && item.kind !== 'tip') return 'owned';
308
+ if (kids && !policy.kidsServer) return 'kids';
309
+ if (item.advantage && ((beginner && !policy.beginnerAdvantages) || (kids && !policy.kidsAdvantages))) return 'beginner';
310
+ if (owned && item.kind !== 'tip' && !policy.repeatPurchases) return 'owned';
272
311
  if (item.starts && Date.parse(item.starts) > now) return 'not-yet';
273
312
  if (item.ends && Date.parse(item.ends) <= now) return 'over';
274
- if (!player || player.guest) return 'make-an-account';
275
- if (!band) return 'age-question';
276
- if (band === 'child') return 'no';
313
+ if (policy.requireAccount && (!player || player.guest)) return 'make-an-account';
314
+ if (policy.ageQuestion && !band) return 'age-question';
315
+ if ((band === 'child' && policy.children === 'deny') || (band === 'teen' && policy.teens === 'deny')) return 'no';
277
316
  const price = item.kind === 'tip' ? item.min : item.price;
278
- if (spent + price > cap) return 'cap';
279
- if (band === 'teen') return 'ask-a-parent';
317
+ if (price > 0 && cap !== null && spent + price > cap) return 'cap';
318
+ if ((band === 'teen' && policy.teens === 'parent') || (band === 'child' && policy.children === 'parent')) return 'ask-a-parent';
280
319
  return 'checkout';
281
320
  }
282
321
 
283
322
  /** The public face of a shop (for the directory manifest and the shop page): never a secret, never a person. */
284
323
  export function publicShop(shop) {
285
324
  if (!shop) return null;
286
- 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 } : {}) };
325
+ return { policy: shop.policy, 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 } : {}) };
287
326
  }
288
327
 
289
328
  /** A price for people: 500 usd → "$5.00", 300 eur → "€3.00", 1250 cad → "CA$12.50". */
290
329
  export function money(cents, currency = 'usd') {
291
330
  const c = String(currency).toUpperCase();
292
- 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}`; }
331
+ try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: c, currencyDisplay: 'symbol', minimumFractionDigits: currencyScale(currency) === 1 || amountStep(currency) === 100 ? 0 : Math.log10(currencyScale(currency)), maximumFractionDigits: currencyScale(currency) === 1 || amountStep(currency) === 100 ? 0 : Math.log10(currencyScale(currency)) }).format(Number(cents) / currencyScale(currency)); } catch { return `${(Number(cents) / currencyScale(currency)).toFixed(2)} ${c}`; }
293
332
  }
@@ -97,6 +97,33 @@ CREATE TABLE IF NOT EXISTS referral_statements (
97
97
  ) WITHOUT ROWID;
98
98
  `;
99
99
 
100
+ export const SHOP_RESERVATIONS_FILE = '0010_shop_reservations.sql';
101
+ export const SHOP_RESERVATIONS = `-- Checkout expiry in milliseconds; only sessionless rows age out after a one-minute margin.
102
+ ALTER TABLE shop_orders ADD COLUMN expires_at INTEGER;
103
+ -- Previously opened sessions used the same explicit 31-minute lifetime.
104
+ UPDATE shop_orders SET expires_at = (CAST(created_at / 1000 AS INTEGER) + 31 * 60) * 1000 WHERE status IN ('started', 'processing');
105
+ CREATE INDEX IF NOT EXISTS referral_lines_book ON referral_lines (via, currency, order_id);
106
+ `;
107
+
108
+ export const SHOP_STATEMENTS_FILE = '0011_shop_statements.sql';
109
+ export const SHOP_STATEMENTS = `-- Statements retain independent currencies and an immutable edition while sending.
110
+ ALTER TABLE referral_statements RENAME TO referral_statements_old;
111
+ CREATE TABLE referral_statements (
112
+ seller TEXT NOT NULL, period TEXT NOT NULL, owed INTEGER NOT NULL, pending INTEGER NOT NULL,
113
+ currency TEXT NOT NULL, body TEXT NOT NULL, received_at INTEGER NOT NULL,
114
+ PRIMARY KEY (seller, period, currency)
115
+ ) WITHOUT ROWID;
116
+ INSERT INTO referral_statements SELECT * FROM referral_statements_old;
117
+ DROP TABLE referral_statements_old;
118
+ CREATE TABLE referral_editions (id TEXT PRIMARY KEY, period TEXT NOT NULL, issued INTEGER NOT NULL) WITHOUT ROWID;
119
+ CREATE TABLE referral_edition_lines (
120
+ edition TEXT NOT NULL, order_id TEXT NOT NULL, via TEXT NOT NULL, currency TEXT NOT NULL,
121
+ net INTEGER NOT NULL, rate REAL NOT NULL, share INTEGER NOT NULL, state TEXT NOT NULL,
122
+ period TEXT NOT NULL, hold_until INTEGER NOT NULL, created_at INTEGER NOT NULL, item TEXT, paid_at INTEGER,
123
+ PRIMARY KEY (edition, via, currency, order_id)
124
+ ) WITHOUT ROWID;
125
+ `;
126
+
100
127
  const DAY = 86_400_000;
101
128
  export const ORDER_ID = /^ord_[A-Za-z0-9]{20}$/;
102
129
  const PLAYER_ID = /^pl_[A-Za-z0-9_-]{22}$/;
@@ -107,8 +134,26 @@ export function newOrderId() {
107
134
  return `ord_${btoa(String.fromCharCode(...raw)).replace(/[^A-Za-z0-9]/g, '').padEnd(20, '0').slice(0, 20)}`;
108
135
  }
109
136
 
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) {
137
+ const completeSchemas = new WeakSet();
138
+
139
+ /** Name the first missing schema migration, including a present table with an older shape. */
140
+ export async function migrationNeeded(env) {
141
+ if (!env?.DB) return SHOP_MIGRATION_FILE;
142
+ if (completeSchemas.has(env.DB)) return null;
143
+ for (const [file, query] of [
144
+ [SHOP_MIGRATION_FILE, 'SELECT 1 FROM shop_orders LIMIT 1'],
145
+ [SHOP_RESERVATIONS_FILE, 'SELECT expires_at FROM shop_orders LIMIT 1'],
146
+ [SHOP_STATEMENTS_FILE, 'SELECT issued FROM referral_editions LIMIT 1'],
147
+ [SHOP_STATEMENTS_FILE, 'SELECT edition FROM referral_edition_lines LIMIT 1'],
148
+ ]) {
149
+ try { await env.DB.prepare(query).first(); } catch { return file; }
150
+ }
151
+ completeSchemas.add(env.DB);
152
+ return null;
153
+ }
154
+
155
+ export async function migrated(env, { complete = true } = {}) {
156
+ if (complete) return !(await migrationNeeded(env));
112
157
  if (!env?.DB) return false;
113
158
  try { await env.DB.prepare('SELECT 1 FROM shop_orders LIMIT 1').first(); return true; } catch { return false; }
114
159
  }
@@ -143,12 +188,11 @@ export async function ownsOf(env, player, now = Date.now()) {
143
188
 
144
189
  /** What a player spent here this calendar month (paid, and checkouts still open), in minor units. */
145
190
  export async function spentThisMonth(env, player, now = Date.now()) {
191
+ // A Stripe session remains reserved until Stripe confirms its outcome. Test rows never consume a live cap.
146
192
  const d = new Date(now);
147
193
  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; }
194
+ const r = await env.DB.prepare("SELECT COALESCE(SUM(amount), 0) AS n FROM shop_orders WHERE player = ?1 AND mode = ?4 AND ((status IN ('paid', 'disputed') AND paid_at >= ?2) OR (status IN ('started', 'processing') AND (session IS NOT NULL OR COALESCE(expires_at, created_at + 1860000) + 60000 > ?3)))").bind(player, start, now, String(env.STRIPE_KEY).includes('_live_') ? 'live' : 'test').first();
195
+ return Number(r?.n) || 0;
152
196
  }
153
197
 
154
198
  export async function orderById(env, id) {
@@ -168,10 +212,15 @@ export function orderView(r) {
168
212
  };
169
213
  }
170
214
 
215
+ /** Internal reservation decisions leave the player's unfinished order as it was. */
216
+ export function playerOrderStatus(row) {
217
+ return ['missing', 'released'].includes(row.status) ? (/; previous status: processing$/.test(row.note ?? '') ? 'processing' : 'started') : row.status;
218
+ }
219
+
171
220
  /** Everything of a player's in the shop, for their own export (worker/players.mjs). */
172
221
  export async function shopDataOf(env, player) {
173
222
  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);
223
+ const orders = ((await env.DB.prepare("SELECT * FROM shop_orders WHERE player = ?1 AND status != 'started' ORDER BY created_at").bind(player).all()).results ?? []).filter((r) => playerOrderStatus(r) !== 'started').map((r) => orderView({ ...r, status: playerOrderStatus(r) }));
175
224
  const owns = await ownsOf(env, player);
176
225
  const band = await bandOfPlayer(env, player);
177
226
  return { orders, owns, ageBand: band };
@@ -199,7 +248,7 @@ export function forgetPlayerShop(env, player) {
199
248
  /** Grant an order's entitlements (idempotent on player, key and order). */
200
249
  export function grantStatements(env, order, item, now = Date.now()) {
201
250
  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;
251
+ const ends = item.ends ? Date.parse(item.ends) : item.days ? starts + Math.round(item.days * DAY) : null;
203
252
  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
253
  .bind(order.player, key, item.id, order.id, starts, ends));
205
254
  }
@@ -213,5 +262,17 @@ export async function badgeOf(env, player, shop) {
213
262
  const key = owns.find((o) => o.key.startsWith('badge:'));
214
263
  if (!key) return null;
215
264
  const item = (shop?.items ?? []).find((i) => i.id === key.item);
216
- return item?.badge ?? key.key.slice(6, 22);
265
+ return item?.badge ?? key.key.slice(6);
266
+ }
267
+
268
+ /** Guest purchases move with their player when they sign into an existing account. */
269
+ export async function adoptShopStatements(env, guest, player) {
270
+ if (!(await migrated(env, { complete: false }))) return [];
271
+ return [
272
+ env.DB.prepare('UPDATE shop_orders SET player = ?2 WHERE player = ?1').bind(guest, player),
273
+ env.DB.prepare('UPDATE entitlements SET player = ?2 WHERE player = ?1').bind(guest, player),
274
+ env.DB.prepare('UPDATE shop_parent_links SET player = ?2 WHERE player = ?1').bind(guest, player),
275
+ env.DB.prepare('INSERT INTO player_age (player, band, asked_at) SELECT ?2, band, asked_at FROM player_age WHERE player = ?1 ON CONFLICT(player) DO NOTHING').bind(guest, player),
276
+ env.DB.prepare('DELETE FROM player_age WHERE player = ?1').bind(guest),
277
+ ];
217
278
  }