@homie-rocks/studio 0.32.1 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -1
- package/README.md +24 -6
- package/agents/agents.ts +57 -24
- package/bin/homie-studio.mjs +3 -3
- package/lib/build.mjs +64 -9
- package/lib/cf-usage.mjs +76 -0
- package/lib/cloudflare.mjs +25 -2
- package/lib/dev.mjs +45 -4
- package/lib/lab-check.mjs +3 -2
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +1 -1
- package/lib/office.mjs +7 -0
- package/lib/perf.mjs +8 -3
- package/lib/player-image.mjs +40 -0
- package/lib/rules-build.mjs +184 -0
- package/lib/rules-guard.mjs +770 -0
- package/lib/scaffold.mjs +43 -16
- package/lib/shop-catalog.mjs +10 -9
- package/lib/shop.mjs +32 -15
- package/lib/studio.mjs +21 -1
- package/lib/template-history.json +50 -0
- package/lib/upgrade.mjs +19 -15
- package/lib/version.mjs +2 -7
- package/netplay/NETPLAY.md +249 -4
- package/netplay/netplay.ts +55 -13
- package/package.json +23 -1
- package/rules/core.ts +1250 -0
- package/rules/guard.ts +419 -0
- package/rules/host.ts +707 -0
- package/rules/math.ts +266 -0
- package/rules/pack.ts +292 -0
- package/rules/rules.ts +544 -0
- package/rules/view.ts +447 -0
- package/shop/SELLING.md +14 -16
- package/shop/SHOP.md +119 -30
- package/shop/shop.ts +6 -8
- package/site/SITE.md +119 -2
- package/starters/coin-dash/game.json +11 -0
- package/starters/coin-dash/index.html +17 -0
- package/starters/coin-dash/map/main.json +16 -0
- package/starters/coin-dash/src/move.ts +27 -0
- package/starters/coin-dash/src/rules.ts +86 -0
- package/starters/coin-dash/src/view.ts +106 -0
- package/starters/coin-dash/tunables.json +5 -0
- package/starters/ember-vale/index.html +11 -0
- package/worker/brain.mjs +8 -7
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +135 -0
- package/worker/hosted.mjs +56 -0
- package/worker/index.mjs +413 -22
- package/worker/office-page.mjs +1 -0
- package/worker/office.mjs +24 -7
- package/worker/pages.mjs +189 -43
- package/worker/players.mjs +10 -6
- package/worker/referrals.mjs +192 -57
- package/worker/room-store.mjs +54 -0
- package/worker/room.mjs +294 -119
- package/worker/saves-shell.mjs +7 -1
- package/worker/schema.mjs +4 -3
- package/worker/shop-page.mjs +103 -37
- package/worker/shop-rules.mjs +156 -117
- package/worker/shop-store.mjs +70 -9
- package/worker/shop.mjs +275 -143
- package/worker/site.mjs +26 -5
- package/worker/stripe.mjs +9 -6
- package/worker/version.mjs +1 -1
package/worker/shop-rules.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
3
|
+
/** Implemented payment adapters; item kinds do not select a billing integration. */
|
|
24
4
|
export const TILLS = Object.freeze(['stripe', 'stripe-managed', 'off']);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
export const
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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" (
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
if (
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
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
|
|
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 (
|
|
136
|
-
if (!
|
|
137
|
-
const name = oneLine(it.name
|
|
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
|
|
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 =
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
else
|
|
184
|
+
const why = amountError(numeric(it.price), currency);
|
|
185
|
+
if (why) err(`${at}.price`, why);
|
|
186
|
+
else price = numeric(it.price);
|
|
149
187
|
}
|
|
150
|
-
|
|
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:
|
|
157
|
-
else if (CURRENCY_KEYS.test(g)) err(`${at}.gives`, `"${g}" is a currency
|
|
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 =
|
|
162
|
-
if (!(days
|
|
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`, '
|
|
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
|
-
|
|
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 =
|
|
193
|
-
const windowDays =
|
|
194
|
-
const capPerPlayer =
|
|
195
|
-
const holdDays =
|
|
196
|
-
const minimumInvoice =
|
|
197
|
-
|
|
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
|
|
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
|
|
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 >=
|
|
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 <
|
|
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
|
|
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
|
|
261
|
-
* 'no'
|
|
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 =
|
|
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) /
|
|
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
|
}
|
package/worker/shop-store.mjs
CHANGED
|
@@ -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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|
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
|
}
|