@mpgd/platform 0.27.0 → 0.27.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.
@@ -112,8 +112,13 @@ export declare function isCreditLotEligible(lot: CreditLot, criteria: CreditLotE
112
112
  export declare function selectEligibleCreditLots(lots: readonly CreditLot[], criteria: CreditLotEligibility): readonly CreditLot[];
113
113
  export declare function sumCreditLotRemaining(lots: readonly CreditLot[]): number;
114
114
  export declare function sumCreditDebits(debits: readonly CreditDebit[]): number;
115
+ /**
116
+ * True when two credit amounts differ only by representational float noise.
117
+ * The allowance does not grow with the amount, so a one-credit gap is never
118
+ * equal, and a zero and a positive price are not equal.
119
+ */
115
120
  export declare function sameCreditAmount(left: number, right: number): boolean;
116
- /** The whole number of `unit` prices in `amount`, or `undefined` when `amount` is not a positive multiple. */
121
+ /** The whole number of `unit` prices in `amount`, or `undefined` when `amount` does not cover a positive multiple. */
117
122
  export declare function creditPriceMultiple(amount: number, unit: number): number | undefined;
118
123
  export type CreditDebitPlan = {
119
124
  readonly status: 'debited';
@@ -125,7 +130,9 @@ export type CreditDebitPlan = {
125
130
  /**
126
131
  * Plans the debits of `amount` across eligible lots in spend order without
127
132
  * mutating them. A spend the lots cannot cover is rejected with
128
- * `INSUFFICIENT_BALANCE` and plans nothing.
133
+ * `INSUFFICIENT_BALANCE` and plans nothing. A shortfall is never a debit,
134
+ * including one credit of a large price, and an empty debit list is not a
135
+ * debit of a positive amount.
129
136
  */
130
137
  export declare function planCreditDebits(eligible: readonly CreditLot[], amount: number): CreditDebitPlan;
131
138
  /** Prefix of every scheduled membership debit key; a client spend cannot use it. */
@@ -328,28 +328,156 @@ export function selectEligibleCreditLots(lots, criteria) {
328
328
  return lots.filter((lot) => isCreditLotEligible(lot, criteria)).sort(compareCreditLotSpendOrder);
329
329
  }
330
330
  export function sumCreditLotRemaining(lots) {
331
- return lots.reduce((sum, lot) => sum + lot.remaining, 0);
331
+ return sumCreditAmounts(lots.map((lot) => lot.remaining));
332
332
  }
333
333
  export function sumCreditDebits(debits) {
334
- return debits.reduce((sum, debit) => sum + debit.amount, 0);
334
+ return sumCreditAmounts(debits.map((debit) => debit.amount));
335
335
  }
336
- /** Tolerance for comparing credit amounts, which the contract allows to be fractional. */
337
- const creditAmountEpsilon = 1e-9;
336
+ /**
337
+ * Compensated sum. A long run of fractional lots (`100 × 0.1`) is the price
338
+ * it adds up to, not a shortfall left by naive addition. A non-finite term
339
+ * makes the sum non-finite.
340
+ */
341
+ function sumCreditAmounts(amounts) {
342
+ let sum = 0;
343
+ let compensation = 0;
344
+ for (const amount of amounts) {
345
+ if (!Number.isFinite(amount)) {
346
+ return Number.NaN;
347
+ }
348
+ const corrected = amount - compensation;
349
+ const next = sum + corrected;
350
+ compensation = (next - sum) - corrected;
351
+ sum = next;
352
+ }
353
+ return sum;
354
+ }
355
+ /**
356
+ * Representational noise around a credit amount, in units in the last place.
357
+ * A sum of binary fractions can land one ULP off (`0.7 + 0.1` vs `0.8`).
358
+ * A few ULPs cover that. The count does not scale with the price.
359
+ */
360
+ const creditAmountUlpSlop = 4;
361
+ /**
362
+ * Absolute ceiling on that noise. It does not grow with the price, so a
363
+ * one-credit shortfall is never float residue. Below this magnitude the ULP
364
+ * bound is tighter, and a zero never matches a positive amount
365
+ * (`sameCreditAmount(0, 1e-9)`).
366
+ */
367
+ const creditAmountAbsoluteCap = 1e-9;
368
+ /** Spacing to the next representable value at `value`'s magnitude. */
369
+ function creditAmountUlp(value) {
370
+ const magnitude = Math.abs(value);
371
+ if (!Number.isFinite(magnitude) || magnitude === 0) {
372
+ return Number.MIN_VALUE;
373
+ }
374
+ // Math.log2 can round a value just under 2^n up to n. Step back when the
375
+ // reconstructed boundary is above the magnitude.
376
+ const exponent = Math.floor(Math.log2(magnitude));
377
+ const boundary = 2 ** exponent;
378
+ const binade = magnitude < boundary ? exponent - 1 : exponent;
379
+ const ulp = 2 ** (binade - 52);
380
+ return Number.isFinite(ulp) && ulp > 0 ? ulp : Number.MIN_VALUE;
381
+ }
382
+ /** Noise two amounts may differ by. Capped so it cannot grow into a credit. */
383
+ function creditAmountNoise(left, right) {
384
+ const scale = Math.max(Math.abs(left), Math.abs(right));
385
+ // A few ULPs of a subnormal price are most of that price. The relative
386
+ // bound underflows to zero there, so only an exact payment covers it.
387
+ const relative = scale * Number.EPSILON;
388
+ if (!Number.isFinite(relative) || relative === 0) {
389
+ return 0;
390
+ }
391
+ return Math.min(creditAmountUlpSlop * creditAmountUlp(scale), creditAmountAbsoluteCap, relative);
392
+ }
393
+ /**
394
+ * True when two credit amounts differ only by representational float noise.
395
+ * The allowance does not grow with the amount, so a one-credit gap is never
396
+ * equal, and a zero and a positive price are not equal.
397
+ */
338
398
  export function sameCreditAmount(left, right) {
339
- return Math.abs(left - right) <= creditAmountEpsilon;
399
+ if (!Number.isFinite(left) || !Number.isFinite(right)) {
400
+ return false;
401
+ }
402
+ const difference = Math.abs(left - right);
403
+ if (difference === 0) {
404
+ return true;
405
+ }
406
+ if (left === 0 || right === 0) {
407
+ return false;
408
+ }
409
+ return difference <= creditAmountNoise(left, right);
410
+ }
411
+ /**
412
+ * True when `paid` meets `price`. A shortfall is not a payment: zero does not
413
+ * meet a positive price, and a gap that is not float residue does not either.
414
+ * Residue may leave `paid` a few ULPs under `price`; that allowance is capped
415
+ * and does not grow with the price, so a large price cannot be underpaid by a
416
+ * whole credit.
417
+ */
418
+ function creditAmountCovers(paid, price) {
419
+ if (!Number.isFinite(paid) || !Number.isFinite(price) || !(paid > 0) || !(price > 0)) {
420
+ return false;
421
+ }
422
+ if (paid >= price) {
423
+ return true;
424
+ }
425
+ const shortfall = price - paid;
426
+ return shortfall < price && shortfall <= creditAmountNoise(paid, price);
340
427
  }
341
- /** The whole number of `unit` prices in `amount`, or `undefined` when `amount` is not a positive multiple. */
428
+ /** The whole number of `unit` prices in `amount`, or `undefined` when `amount` does not cover a positive multiple. */
342
429
  export function creditPriceMultiple(amount, unit) {
343
430
  if (!(unit > 0) || !(amount > 0) || !Number.isFinite(amount) || !Number.isFinite(unit)) {
344
431
  return undefined;
345
432
  }
346
433
  const multiple = Math.round(amount / unit);
347
- return multiple >= 1 && sameCreditAmount(multiple * unit, amount) ? multiple : undefined;
434
+ if (!Number.isFinite(multiple) || multiple < 1) {
435
+ return undefined;
436
+ }
437
+ const product = multiple * unit;
438
+ // `amount` must cover the units. Float residue may put `product` a few ULPs
439
+ // above `amount`; a one-credit gap is not a whole number of units.
440
+ if (!creditAmountCovers(amount, product) || !sameCreditAmount(product, amount)) {
441
+ return undefined;
442
+ }
443
+ return multiple;
444
+ }
445
+ /**
446
+ * True when the lots meet `amount` without adding a lot that would overflow
447
+ * the running total. A lot that meets the unpaid gap covers the price by
448
+ * itself; summing it with an already huge total is not required.
449
+ */
450
+ function creditLotsCover(lots, amount) {
451
+ let sum = 0;
452
+ let compensation = 0;
453
+ for (const lot of lots) {
454
+ const remaining = lot.remaining;
455
+ if (!Number.isFinite(remaining) || !(remaining > 0)) {
456
+ continue;
457
+ }
458
+ const unpaid = amount - sum;
459
+ if (!(unpaid > 0) || remaining >= unpaid) {
460
+ return true;
461
+ }
462
+ const corrected = remaining - compensation;
463
+ const next = sum + corrected;
464
+ if (!Number.isFinite(next)) {
465
+ return true;
466
+ }
467
+ compensation = (next - sum) - corrected;
468
+ sum = next;
469
+ if (creditAmountCovers(sum, amount)) {
470
+ return true;
471
+ }
472
+ }
473
+ return creditAmountCovers(sum, amount);
348
474
  }
349
475
  /**
350
476
  * Plans the debits of `amount` across eligible lots in spend order without
351
477
  * mutating them. A spend the lots cannot cover is rejected with
352
- * `INSUFFICIENT_BALANCE` and plans nothing.
478
+ * `INSUFFICIENT_BALANCE` and plans nothing. A shortfall is never a debit,
479
+ * including one credit of a large price, and an empty debit list is not a
480
+ * debit of a positive amount.
353
481
  */
354
482
  export function planCreditDebits(eligible, amount) {
355
483
  if (!Number.isFinite(amount) || amount <= 0) {
@@ -362,31 +490,40 @@ export function planCreditDebits(eligible, amount) {
362
490
  },
363
491
  };
364
492
  }
493
+ const insufficient = (available) => ({
494
+ status: 'rejected',
495
+ error: {
496
+ code: 'INSUFFICIENT_BALANCE',
497
+ retryable: false,
498
+ message: `Eligible balance ${available} cannot cover ${amount}.`,
499
+ },
500
+ });
365
501
  const ordered = [...eligible].sort(compareCreditLotSpendOrder);
366
- const available = sumCreditLotRemaining(ordered);
367
- if (available < amount && !sameCreditAmount(available, amount)) {
368
- return {
369
- status: 'rejected',
370
- error: {
371
- code: 'INSUFFICIENT_BALANCE',
372
- retryable: false,
373
- message: `Eligible balance ${available} cannot cover ${amount}.`,
374
- },
375
- };
502
+ // Stop once a lot meets the unpaid gap. Summing every lot first overflows
503
+ // to Infinity when two finite lots are `Number.MAX_VALUE`, and then a
504
+ // price the first lot covers is rejected.
505
+ if (!creditLotsCover(ordered, amount)) {
506
+ return insufficient(sumCreditLotRemaining(ordered));
376
507
  }
377
508
  const debits = [];
378
509
  let remaining = amount;
379
510
  for (const lot of ordered) {
380
- if (remaining <= creditAmountEpsilon) {
511
+ if (!(remaining > 0)) {
381
512
  break;
382
513
  }
383
514
  const debit = Math.min(lot.remaining, remaining);
384
- if (debit <= 0) {
515
+ if (!(debit > 0)) {
385
516
  continue;
386
517
  }
387
518
  debits.push({ lotId: lot.lotId, amount: debit });
388
519
  remaining -= debit;
389
520
  }
521
+ const paid = sumCreditDebits(debits);
522
+ // The loop must not report a debit it did not take. A positive price with
523
+ // an empty list, or a sum that does not cover, is still insufficient.
524
+ if (debits.length === 0 || !creditAmountCovers(paid, amount)) {
525
+ return insufficient(paid);
526
+ }
390
527
  return { status: 'debited', debits };
391
528
  }
392
529
  // Membership keys and subscription events.
@@ -1459,10 +1596,14 @@ export function createCreditCommerceProvider(client, options) {
1459
1596
  if (spend.status === 'rejected') {
1460
1597
  return failed(spend.error);
1461
1598
  }
1462
- // A debited answer that does not add up to the price is not a purchase.
1463
- if (!sameCreditAmount(sumCreditDebits(spend.debits), spendInput.amount)) {
1464
- return failed(invalidResponse('spend', new RangeError(`Debits for ${offer.logicalId} sum to ${sumCreditDebits(spend.debits)}, `
1465
- + `not ${spendInput.amount}.`)).toCommerceErrorInfo());
1599
+ // A debited answer that does not cover the price is not a purchase.
1600
+ // An empty debit list never covers a positive price, and a one-credit
1601
+ // shortfall is still a shortfall. The allowance does not grow with price.
1602
+ const paid = sumCreditDebits(spend.debits);
1603
+ if (spend.debits.length === 0
1604
+ || !creditAmountCovers(paid, spendInput.amount)
1605
+ || !sameCreditAmount(paid, spendInput.amount)) {
1606
+ return failed(invalidResponse('spend', new RangeError(`Debits for ${offer.logicalId} sum to ${paid}, not ${spendInput.amount}.`)).toCommerceErrorInfo());
1466
1607
  }
1467
1608
  return {
1468
1609
  outcome: 'purchased',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/platform",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
4
4
  "description": "Shared platform gateway, monetization, ads, leaderboard, identity, lifecycle, and storage contracts for mpgd games.",
5
5
  "license": "MIT",
6
6
  "repository": {