@flayerlabs/gamemode-client 0.4.2 → 0.5.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/dist/embed.js +2 -2
- package/dist/embed.js.map +1 -1
- package/dist/host.js +3 -3
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +39 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/live.d.ts +1 -1
- package/dist/live.d.ts.map +1 -1
- package/dist/live.js +121 -49
- package/dist/live.js.map +1 -1
- package/dist/mock.d.ts +3 -3
- package/dist/mock.d.ts.map +1 -1
- package/dist/mock.js +56 -41
- package/dist/mock.js.map +1 -1
- package/dist/replay-market.d.ts +1 -1
- package/dist/replay-market.d.ts.map +1 -1
- package/dist/replay-market.js +6 -6
- package/dist/replay-market.js.map +1 -1
- package/package.json +2 -2
- package/src/embed.ts +4 -4
- package/src/host.ts +3 -3
- package/src/index.ts +45 -9
- package/src/live.ts +142 -54
- package/src/mock.ts +56 -41
- package/src/replay-market.ts +7 -7
package/src/live.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type PresenceState,
|
|
27
27
|
type Reaction,
|
|
28
28
|
type RoundTerms,
|
|
29
|
+
type SpendToken,
|
|
29
30
|
type WireEconomyBalance,
|
|
30
31
|
type WireMarketState,
|
|
31
32
|
type WireRoundTerms,
|
|
@@ -64,7 +65,7 @@ export interface Host {
|
|
|
64
65
|
authorisation: Authorisation,
|
|
65
66
|
progress?: (event: HostBuyProgress) => void,
|
|
66
67
|
signal?: AbortSignal,
|
|
67
|
-
): Promise<{
|
|
68
|
+
): Promise<{ spent: bigint } | { failed: BuyResult }>;
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
/** The host reports only what it uniquely knows; LiveRoom owns the terminal result. */
|
|
@@ -377,7 +378,7 @@ class LiveRoom<PublicView, PlayerView, Action> implements Room<PublicView, Playe
|
|
|
377
378
|
readonly economy: Economy = {
|
|
378
379
|
current: () => this.economySignal.current(),
|
|
379
380
|
subscribe: (listener) => this.economySignal.subscribe(listener),
|
|
380
|
-
available: () => this.economySignal.current().
|
|
381
|
+
available: () => this.economySignal.current().available,
|
|
381
382
|
buy: (maxSpendWei) => this.buy(maxSpendWei),
|
|
382
383
|
};
|
|
383
384
|
|
|
@@ -405,13 +406,15 @@ class LiveRoom<PublicView, PlayerView, Action> implements Room<PublicView, Playe
|
|
|
405
406
|
requestId,
|
|
406
407
|
});
|
|
407
408
|
} catch (error) {
|
|
408
|
-
if (error instanceof GateRefused)
|
|
409
|
+
if (error instanceof GateRefused) {
|
|
410
|
+
return this.failBuy(attempt, claimFailure(error.refuse), { refuse: error.refuse });
|
|
411
|
+
}
|
|
409
412
|
return this.failBuy(attempt, 'try-again');
|
|
410
413
|
}
|
|
411
414
|
|
|
412
|
-
let
|
|
415
|
+
let spend: bigint;
|
|
413
416
|
try {
|
|
414
|
-
|
|
417
|
+
spend = BigInt(authorisation.maxSpendWei);
|
|
415
418
|
} catch {
|
|
416
419
|
return this.failBuy(attempt, 'try-again');
|
|
417
420
|
}
|
|
@@ -422,11 +425,11 @@ class LiveRoom<PublicView, PlayerView, Action> implements Room<PublicView, Playe
|
|
|
422
425
|
...before,
|
|
423
426
|
...(this.balanceRevision === balanceRevision
|
|
424
427
|
? {
|
|
425
|
-
|
|
426
|
-
|
|
428
|
+
held: before.held + spend,
|
|
429
|
+
available: before.available > spend ? before.available - spend : 0n,
|
|
427
430
|
}
|
|
428
431
|
: {}),
|
|
429
|
-
buy: { state: 'signing',
|
|
432
|
+
buy: { state: 'signing', spend },
|
|
430
433
|
});
|
|
431
434
|
|
|
432
435
|
let transactionHash: string | undefined;
|
|
@@ -441,8 +444,8 @@ class LiveRoom<PublicView, PlayerView, Action> implements Room<PublicView, Playe
|
|
|
441
444
|
this.economySignal.set({
|
|
442
445
|
...current,
|
|
443
446
|
buy: transactionHash === undefined
|
|
444
|
-
? { state: 'pending',
|
|
445
|
-
: { state: 'pending',
|
|
447
|
+
? { state: 'pending', spend }
|
|
448
|
+
: { state: 'pending', spend, transactionHash },
|
|
446
449
|
});
|
|
447
450
|
});
|
|
448
451
|
hostSettled = true;
|
|
@@ -452,18 +455,23 @@ class LiveRoom<PublicView, PlayerView, Action> implements Room<PublicView, Playe
|
|
|
452
455
|
}
|
|
453
456
|
|
|
454
457
|
if ('failed' in result) {
|
|
455
|
-
if (result.failed.bought)
|
|
456
|
-
|
|
458
|
+
if (result.failed.bought) {
|
|
459
|
+
return this.failBuy(attempt, 'try-again', { detail: 'page-reported-success-as-failure' });
|
|
460
|
+
}
|
|
461
|
+
return this.failBuy(attempt, result.failed.reason, {
|
|
462
|
+
detail: result.failed.detail,
|
|
463
|
+
transactionHash: result.failed.transactionHash ?? transactionHash,
|
|
464
|
+
});
|
|
457
465
|
}
|
|
458
466
|
if (this.activeBuy !== attempt) return { bought: false, reason: 'try-again' };
|
|
459
467
|
|
|
460
468
|
const current = this.economySignal.current();
|
|
461
469
|
const confirmed: BuyProgress = transactionHash === undefined
|
|
462
|
-
? { state: 'confirmed',
|
|
463
|
-
: { state: 'confirmed',
|
|
470
|
+
? { state: 'confirmed', spent: result.spent }
|
|
471
|
+
: { state: 'confirmed', spent: result.spent, transactionHash };
|
|
464
472
|
// The signed allowance remains held until the gate observes chain settlement or it expires.
|
|
465
473
|
this.economySignal.set({ ...current, buy: confirmed });
|
|
466
|
-
return { bought: true,
|
|
474
|
+
return { bought: true, spent: result.spent };
|
|
467
475
|
}
|
|
468
476
|
|
|
469
477
|
readonly social: Social = {
|
|
@@ -498,12 +506,32 @@ class LiveRoom<PublicView, PlayerView, Action> implements Room<PublicView, Playe
|
|
|
498
506
|
this.economySignal.set({ ...balance, buy: this.economySignal.current().buy });
|
|
499
507
|
}
|
|
500
508
|
|
|
501
|
-
|
|
509
|
+
/**
|
|
510
|
+
* Fail a buy, keeping whatever diagnostic the refusing party supplied.
|
|
511
|
+
*
|
|
512
|
+
* `reason` is the player's answer; the rest is for the developer reading a log afterwards — a
|
|
513
|
+
* gate's `refuse` code, or an embedding page's `detail` and the transaction it had submitted.
|
|
514
|
+
* Carried rather than dropped because `try-again` alone has cost more than one debugging day.
|
|
515
|
+
*/
|
|
516
|
+
private failBuy(
|
|
517
|
+
attempt: symbol,
|
|
518
|
+
reason: BuyFailure,
|
|
519
|
+
// `undefined` is admitted explicitly (the package runs exactOptionalPropertyTypes) so a
|
|
520
|
+
// caller can forward a field it may not have without checking first; they are filtered below.
|
|
521
|
+
diagnostic: {
|
|
522
|
+
refuse?: string | undefined;
|
|
523
|
+
detail?: string | undefined;
|
|
524
|
+
transactionHash?: string | undefined;
|
|
525
|
+
} = {},
|
|
526
|
+
): { bought: false; reason: BuyFailure; refuse?: string; detail?: string; transactionHash?: string } {
|
|
527
|
+
const named = Object.fromEntries(
|
|
528
|
+
Object.entries(diagnostic).filter(([, value]) => value !== undefined),
|
|
529
|
+
);
|
|
502
530
|
if (this.activeBuy === attempt) {
|
|
503
531
|
const current = this.economySignal.current();
|
|
504
|
-
this.economySignal.set({ ...current, buy: { state: 'failed', reason } });
|
|
532
|
+
this.economySignal.set({ ...current, buy: { state: 'failed', reason, ...named } });
|
|
505
533
|
}
|
|
506
|
-
return { bought: false, reason };
|
|
534
|
+
return { bought: false, reason, ...named };
|
|
507
535
|
}
|
|
508
536
|
|
|
509
537
|
private nextRequestId(): string {
|
|
@@ -540,11 +568,11 @@ class LiveRoom<PublicView, PlayerView, Action> implements Room<PublicView, Playe
|
|
|
540
568
|
|
|
541
569
|
function emptyEconomy(): EconomyState {
|
|
542
570
|
return {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
571
|
+
unitsPerPoint: 0n,
|
|
572
|
+
earned: 0n,
|
|
573
|
+
held: 0n,
|
|
574
|
+
spent: 0n,
|
|
575
|
+
available: 0n,
|
|
548
576
|
holdExpiresAt: null,
|
|
549
577
|
buy: null,
|
|
550
578
|
};
|
|
@@ -555,13 +583,13 @@ function economyFrom(input: unknown): Omit<EconomyState, 'buy'> | null {
|
|
|
555
583
|
const value = input as Partial<WireEconomyBalance>;
|
|
556
584
|
try {
|
|
557
585
|
const amounts = {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
586
|
+
unitsPerPoint: BigInt(value.unitsPerPoint ?? '0'),
|
|
587
|
+
earned: BigInt(value.earned ?? '-1'),
|
|
588
|
+
held: BigInt(value.held ?? '-1'),
|
|
589
|
+
spent: BigInt(value.spent ?? '-1'),
|
|
590
|
+
available: BigInt(value.available ?? '-1'),
|
|
563
591
|
};
|
|
564
|
-
if (amounts.
|
|
592
|
+
if (amounts.unitsPerPoint <= 0n || Object.values(amounts).some((amount) => amount < 0n)) return null;
|
|
565
593
|
|
|
566
594
|
// A deadline is either a whole millisecond timestamp or absent. Anything else — a string, a
|
|
567
595
|
// fraction, Infinity — is a malformed frame, and a countdown drawn from one would run to a
|
|
@@ -587,38 +615,77 @@ export function termsFrom(input: unknown): RoundTerms | null {
|
|
|
587
615
|
const value = input as Partial<WireRoundTerms>;
|
|
588
616
|
const decimal = /^(0|[1-9][0-9]*)$/;
|
|
589
617
|
if (
|
|
590
|
-
typeof value.
|
|
591
|
-
!decimal.test(value.
|
|
592
|
-
typeof value.
|
|
593
|
-
!decimal.test(value.
|
|
618
|
+
typeof value.walletCap !== 'string' ||
|
|
619
|
+
!decimal.test(value.walletCap) ||
|
|
620
|
+
typeof value.unitsPerPoint !== 'string' ||
|
|
621
|
+
!decimal.test(value.unitsPerPoint)
|
|
594
622
|
) return null;
|
|
595
623
|
try {
|
|
596
|
-
const
|
|
597
|
-
const
|
|
598
|
-
const { maxPointsPerPlayer, pointsPerDollar,
|
|
624
|
+
const walletCap = BigInt(value.walletCap);
|
|
625
|
+
const unitsPerPoint = BigInt(value.unitsPerPoint);
|
|
626
|
+
const { maxPointsPerPlayer, pointsPerDollar, usdPerSpendToken } = value;
|
|
599
627
|
if (
|
|
600
|
-
|
|
601
|
-
|
|
628
|
+
walletCap <= 0n ||
|
|
629
|
+
unitsPerPoint <= 0n ||
|
|
602
630
|
typeof maxPointsPerPlayer !== 'number' ||
|
|
603
631
|
!Number.isSafeInteger(maxPointsPerPlayer) ||
|
|
604
632
|
maxPointsPerPlayer <= 0 ||
|
|
605
633
|
// Prices, not counts, so these are finite rather than integral. Both are still positive: a
|
|
606
|
-
// round priced at zero dollars
|
|
634
|
+
// round priced at zero dollars a token is a round whose rate cannot mean anything.
|
|
607
635
|
typeof pointsPerDollar !== 'number' ||
|
|
608
636
|
!Number.isFinite(pointsPerDollar) ||
|
|
609
637
|
pointsPerDollar <= 0 ||
|
|
610
|
-
typeof
|
|
611
|
-
!Number.isFinite(
|
|
612
|
-
|
|
638
|
+
typeof usdPerSpendToken !== 'number' ||
|
|
639
|
+
!Number.isFinite(usdPerSpendToken) ||
|
|
640
|
+
usdPerSpendToken <= 0
|
|
613
641
|
) {
|
|
614
642
|
return null;
|
|
615
643
|
}
|
|
616
|
-
|
|
644
|
+
// The token is required: every amount in these terms is in its base units, so terms without it
|
|
645
|
+
// cannot be displayed or spent against. A gate that does not send one is not one this client
|
|
646
|
+
// can play against, and refusing here says so at the snapshot rather than in the arithmetic.
|
|
647
|
+
const spendToken = spendTokenFrom((value as { spendToken?: unknown }).spendToken);
|
|
648
|
+
if (!spendToken) return null;
|
|
649
|
+
return Object.freeze({
|
|
650
|
+
maxPointsPerPlayer,
|
|
651
|
+
walletCap,
|
|
652
|
+
unitsPerPoint,
|
|
653
|
+
pointsPerDollar,
|
|
654
|
+
usdPerSpendToken,
|
|
655
|
+
spendToken,
|
|
656
|
+
});
|
|
617
657
|
} catch {
|
|
618
658
|
return null;
|
|
619
659
|
}
|
|
620
660
|
}
|
|
621
661
|
|
|
662
|
+
const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
|
|
663
|
+
|
|
664
|
+
function spendTokenFrom(input: unknown): SpendToken | null {
|
|
665
|
+
if (typeof input !== 'object' || input === null) return null;
|
|
666
|
+
const value = input as Partial<SpendToken>;
|
|
667
|
+
if (
|
|
668
|
+
typeof value.address !== 'string' ||
|
|
669
|
+
!ADDRESS.test(value.address) ||
|
|
670
|
+
typeof value.symbol !== 'string' ||
|
|
671
|
+
value.symbol.length === 0 ||
|
|
672
|
+
value.symbol.length > 64 ||
|
|
673
|
+
typeof value.decimals !== 'number' ||
|
|
674
|
+
!Number.isSafeInteger(value.decimals) ||
|
|
675
|
+
value.decimals < 0 ||
|
|
676
|
+
value.decimals > 36 ||
|
|
677
|
+
typeof value.isNative !== 'boolean'
|
|
678
|
+
) {
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
return Object.freeze({
|
|
682
|
+
address: value.address,
|
|
683
|
+
symbol: value.symbol,
|
|
684
|
+
decimals: value.decimals,
|
|
685
|
+
isNative: value.isNative,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
622
689
|
function presenceFrom(input: unknown): PresenceState | null {
|
|
623
690
|
if (typeof input !== 'object' || input === null) return null;
|
|
624
691
|
const value = input as Partial<PresenceState>;
|
|
@@ -649,14 +716,27 @@ export function marketFrom(input: unknown): MarketState | null {
|
|
|
649
716
|
value.trades.length > 500
|
|
650
717
|
) return null;
|
|
651
718
|
|
|
652
|
-
const
|
|
653
|
-
(
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
719
|
+
const usable = (figure: unknown, floor: number): boolean =>
|
|
720
|
+
typeof figure === 'number' && Number.isFinite(figure) && figure >= floor;
|
|
721
|
+
|
|
722
|
+
const prices = value.prices
|
|
723
|
+
.filter(
|
|
724
|
+
(price) =>
|
|
725
|
+
typeof price?.at === 'number' &&
|
|
726
|
+
Number.isFinite(price.at) &&
|
|
727
|
+
typeof price.priceEth === 'number' &&
|
|
728
|
+
Number.isFinite(price.priceEth) &&
|
|
729
|
+
price.priceEth > 0,
|
|
730
|
+
)
|
|
731
|
+
// The USD pair is dropped per point rather than per series: a platform that starts sending
|
|
732
|
+
// them mid-round leaves a series with both kinds of point in it, and a chart that can plot
|
|
733
|
+
// market cap should plot the part that has one.
|
|
734
|
+
.map((price) => ({
|
|
735
|
+
at: price.at,
|
|
736
|
+
priceEth: price.priceEth,
|
|
737
|
+
...(usable(price.priceUsd, Number.MIN_VALUE) ? { priceUsd: price.priceUsd } : {}),
|
|
738
|
+
...(usable(price.marketCapUsd, 0) ? { marketCapUsd: price.marketCapUsd } : {}),
|
|
739
|
+
}));
|
|
660
740
|
const trades = value.trades.flatMap((trade) => {
|
|
661
741
|
try {
|
|
662
742
|
if (
|
|
@@ -667,14 +747,14 @@ export function marketFrom(input: unknown): MarketState | null {
|
|
|
667
747
|
(trade.side !== 'buy' && trade.side !== 'sell') ||
|
|
668
748
|
(trade.priceEth !== null && (typeof trade.priceEth !== 'number' || !Number.isFinite(trade.priceEth)))
|
|
669
749
|
) return [];
|
|
670
|
-
const
|
|
671
|
-
if (
|
|
750
|
+
const spend = BigInt(trade.spend);
|
|
751
|
+
if (spend < 0n) return [];
|
|
672
752
|
return [{
|
|
673
753
|
id: trade.id,
|
|
674
754
|
at: trade.at,
|
|
675
755
|
player: trade.player,
|
|
676
756
|
side: trade.side,
|
|
677
|
-
|
|
757
|
+
spend,
|
|
678
758
|
priceEth: trade.priceEth,
|
|
679
759
|
...(typeof trade.transactionHash === 'string' ? { transactionHash: trade.transactionHash } : {}),
|
|
680
760
|
}];
|
|
@@ -692,6 +772,14 @@ export function immutableLaunch(launch: LaunchContext): LaunchContext {
|
|
|
692
772
|
return Object.freeze({ ...launch });
|
|
693
773
|
}
|
|
694
774
|
|
|
775
|
+
/**
|
|
776
|
+
* A gate refusal in the game's own vocabulary.
|
|
777
|
+
*
|
|
778
|
+
* The gate has far more ways to say no than {@link BuyFailure} has words, and that is on purpose:
|
|
779
|
+
* this list is what a game renders at a player. Everything unnamed becomes `try-again`, which is
|
|
780
|
+
* true for a player (the round is live, another attempt may work) and unhelpful to the developer
|
|
781
|
+
* running the gate — so the raw code travels alongside as `BuyResult.refuse`.
|
|
782
|
+
*/
|
|
695
783
|
function claimFailure(refuse: string): BuyFailure {
|
|
696
784
|
if (refuse === 'claim.nothing_earned' || refuse === 'claim.nothing_available') return 'nothing-to-spend';
|
|
697
785
|
if (refuse === 'claim.not_open_yet' || refuse === 'claim.window_closed') return 'window-closed';
|
package/src/mock.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Round } from '@flayerlabs/gamemode-spec/round';
|
|
|
2
2
|
import { isRefusal, type GameModule, type PlayerId } from '@flayerlabs/gamemode-spec';
|
|
3
3
|
import type { BuyProgress, BuyResult, Economy, EconomyState, MockOptions, Room, SendResult, Snapshot, Social } from './index.js';
|
|
4
4
|
import {
|
|
5
|
+
ETH_SPEND_TOKEN,
|
|
5
6
|
isReactionId,
|
|
6
7
|
type ConnectionState,
|
|
7
8
|
type LaunchContext,
|
|
@@ -16,8 +17,6 @@ import { CachedIdentityResolver } from './identity.js';
|
|
|
16
17
|
/** How often the mock looks for a deadline that has fallen due. */
|
|
17
18
|
const TICK_MS = 100;
|
|
18
19
|
|
|
19
|
-
const WEI_PER_ETH = 1_000_000_000_000_000_000n;
|
|
20
|
-
|
|
21
20
|
/** Long enough to see a spinner, short enough not to be annoying while building. */
|
|
22
21
|
const FAKE_SIGN_MS = 150;
|
|
23
22
|
const FAKE_PENDING_MS = 250;
|
|
@@ -29,13 +28,13 @@ export class MockRoom<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
29
28
|
{
|
|
30
29
|
private readonly round: Round<Config, State, Event, Action, PublicView, PlayerView>;
|
|
31
30
|
private readonly player: PlayerId;
|
|
32
|
-
private readonly
|
|
31
|
+
private readonly unitsPerPoint: bigint;
|
|
33
32
|
private readonly allowedReactions: ReadonlySet<string>;
|
|
34
33
|
private readonly listeners = new Set<(s: Snapshot<PublicView, PlayerView>) => void>();
|
|
35
34
|
private readonly reactionListeners = new Set<(r: Reaction) => void>();
|
|
36
35
|
private readonly timer: ReturnType<typeof setInterval>;
|
|
37
|
-
private
|
|
38
|
-
private
|
|
36
|
+
private spent = 0n;
|
|
37
|
+
private held = 0n;
|
|
39
38
|
/** Mirrors the gate's five-minute hold, so a game can build the countdown against the mock. */
|
|
40
39
|
private holdExpiresAt: number | null = null;
|
|
41
40
|
private buyProgress: BuyProgress | null = null;
|
|
@@ -53,7 +52,7 @@ export class MockRoom<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
53
52
|
) {
|
|
54
53
|
const opensAt = Date.now() + (options.lobbyMs ?? 3_000);
|
|
55
54
|
this.player = options.player ?? '0xyou';
|
|
56
|
-
this.
|
|
55
|
+
this.unitsPerPoint = options.unitsPerPoint ?? 10_000_000_000_000n;
|
|
57
56
|
this.allowedReactions = new Set(options.reactionIds ?? []);
|
|
58
57
|
if ([...this.allowedReactions].some((id) => !isReactionId(id))) {
|
|
59
58
|
throw new RangeError('reactionIds must contain only valid reaction identifiers');
|
|
@@ -79,10 +78,10 @@ export class MockRoom<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
79
78
|
// may spend. Defaulting the cap to exactly that payout, and refusing anything under it, keeps
|
|
80
79
|
// the mock from letting a game pass locally on numbers the gate would reject.
|
|
81
80
|
const maxPointsPerPlayer = game.rewardBounds(options.config).maxPointsPerPlayer;
|
|
82
|
-
const flawlessRoundWei = BigInt(maxPointsPerPlayer) * this.
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
throw new RangeError('
|
|
81
|
+
const flawlessRoundWei = BigInt(maxPointsPerPlayer) * this.unitsPerPoint;
|
|
82
|
+
const walletCap = options.walletCap ?? flawlessRoundWei;
|
|
83
|
+
if (walletCap < flawlessRoundWei) {
|
|
84
|
+
throw new RangeError('walletCap must cover a flawless round, or the gate would refuse this launch');
|
|
86
85
|
}
|
|
87
86
|
// The gate derives its rate from these two, so the mock runs the identity backwards from
|
|
88
87
|
// whatever rate it was given. Reporting three numbers that do not agree would let a menu built
|
|
@@ -91,17 +90,33 @@ export class MockRoom<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
91
90
|
if (!Number.isFinite(pointsPerDollar) || pointsPerDollar <= 0) {
|
|
92
91
|
throw new RangeError('pointsPerDollar must be a positive finite number');
|
|
93
92
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
// In the spend token's own base units — wei unless a token was given. The gate's identity is
|
|
94
|
+
// `unitsPerPoint = units / round(usdPerSpendToken × pointsPerDollar)`, so the mock runs it
|
|
95
|
+
// backwards and reports exactly the three numbers a live round would agree on.
|
|
96
|
+
const spendToken = options.spendToken ?? ETH_SPEND_TOKEN;
|
|
97
|
+
const decimals = spendToken.decimals;
|
|
98
|
+
if (!Number.isSafeInteger(decimals) || decimals < 0 || decimals > 36) {
|
|
99
|
+
throw new RangeError('spendToken.decimals must be an integer between 0 and 36');
|
|
100
|
+
}
|
|
101
|
+
const units = 10n ** BigInt(decimals);
|
|
102
|
+
const pointsPerToken = units / this.unitsPerPoint;
|
|
103
|
+
if (pointsPerToken <= 0n || pointsPerToken > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
104
|
+
throw new RangeError('unitsPerPoint does not produce representable round terms');
|
|
97
105
|
}
|
|
98
|
-
const
|
|
99
|
-
const roundTrip =
|
|
100
|
-
if (roundTrip !== this.
|
|
101
|
-
throw new RangeError('
|
|
106
|
+
const usdPerSpendToken = Number(pointsPerToken) / pointsPerDollar;
|
|
107
|
+
const roundTrip = units / BigInt(Math.round(usdPerSpendToken * pointsPerDollar));
|
|
108
|
+
if (roundTrip !== this.unitsPerPoint) {
|
|
109
|
+
throw new RangeError('unitsPerPoint must round-trip through gate pricing terms');
|
|
102
110
|
}
|
|
103
111
|
this.termsSignal = new Signal<RoundTerms>(
|
|
104
|
-
Object.freeze({
|
|
112
|
+
Object.freeze({
|
|
113
|
+
maxPointsPerPlayer,
|
|
114
|
+
walletCap,
|
|
115
|
+
unitsPerPoint: this.unitsPerPoint,
|
|
116
|
+
pointsPerDollar,
|
|
117
|
+
usdPerSpendToken,
|
|
118
|
+
spendToken: Object.freeze({ ...spendToken }),
|
|
119
|
+
}),
|
|
105
120
|
);
|
|
106
121
|
this.marketSignal = new Signal(
|
|
107
122
|
options.platform?.market?.current() ?? { status: 'unavailable', prices: [], trades: [], marketCapUsd: null },
|
|
@@ -180,25 +195,25 @@ export class MockRoom<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
180
195
|
};
|
|
181
196
|
|
|
182
197
|
private economyState(): EconomyState {
|
|
183
|
-
const
|
|
184
|
-
const
|
|
198
|
+
const earned = BigInt(this.round.pointsFor(this.player)) * this.unitsPerPoint;
|
|
199
|
+
const available = earned - this.spent - this.held;
|
|
185
200
|
return {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
holdExpiresAt: this.
|
|
201
|
+
unitsPerPoint: this.unitsPerPoint,
|
|
202
|
+
earned,
|
|
203
|
+
held: this.held,
|
|
204
|
+
spent: this.spent,
|
|
205
|
+
available: available > 0n ? available : 0n,
|
|
206
|
+
holdExpiresAt: this.held > 0n ? this.holdExpiresAt : null,
|
|
192
207
|
buy: this.buyProgress,
|
|
193
208
|
};
|
|
194
209
|
}
|
|
195
210
|
|
|
196
211
|
private readonly economySignal = new Signal<EconomyState>({
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
212
|
+
unitsPerPoint: 0n,
|
|
213
|
+
earned: 0n,
|
|
214
|
+
held: 0n,
|
|
215
|
+
spent: 0n,
|
|
216
|
+
available: 0n,
|
|
202
217
|
holdExpiresAt: null,
|
|
203
218
|
buy: null,
|
|
204
219
|
});
|
|
@@ -210,7 +225,7 @@ export class MockRoom<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
210
225
|
readonly economy: Economy = {
|
|
211
226
|
current: () => this.economyState(),
|
|
212
227
|
subscribe: (listener) => this.economySignal.subscribe(listener),
|
|
213
|
-
available: () => this.economyState().
|
|
228
|
+
available: () => this.economyState().available,
|
|
214
229
|
buy: async (maxSpendWei: bigint): Promise<BuyResult> => {
|
|
215
230
|
if (maxSpendWei <= 0n) {
|
|
216
231
|
this.buyProgress = { state: 'failed', reason: 'nothing-to-spend' };
|
|
@@ -228,34 +243,34 @@ export class MockRoom<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
228
243
|
}
|
|
229
244
|
|
|
230
245
|
const spend = maxSpendWei < available ? maxSpendWei : available;
|
|
231
|
-
this.
|
|
246
|
+
this.held += spend;
|
|
232
247
|
this.holdExpiresAt = Date.now() + MOCK_HOLD_MS;
|
|
233
|
-
this.buyProgress = { state: 'signing',
|
|
248
|
+
this.buyProgress = { state: 'signing', spend: spend };
|
|
234
249
|
this.publishEconomy();
|
|
235
250
|
|
|
236
251
|
await new Promise((resolve) => setTimeout(resolve, FAKE_SIGN_MS));
|
|
237
252
|
if (this.disposed) {
|
|
238
|
-
this.
|
|
253
|
+
this.held -= spend;
|
|
239
254
|
this.buyProgress = { state: 'failed', reason: 'try-again' };
|
|
240
255
|
this.publishEconomy();
|
|
241
256
|
return { bought: false, reason: 'try-again' };
|
|
242
257
|
}
|
|
243
|
-
this.buyProgress = { state: 'pending',
|
|
258
|
+
this.buyProgress = { state: 'pending', spend: spend };
|
|
244
259
|
this.publishEconomy();
|
|
245
260
|
|
|
246
261
|
await new Promise((resolve) => setTimeout(resolve, FAKE_PENDING_MS));
|
|
247
262
|
if (this.disposed) {
|
|
248
|
-
this.
|
|
263
|
+
this.held -= spend;
|
|
249
264
|
this.buyProgress = { state: 'failed', reason: 'try-again' };
|
|
250
265
|
this.publishEconomy();
|
|
251
266
|
return { bought: false, reason: 'try-again' };
|
|
252
267
|
}
|
|
253
268
|
|
|
254
|
-
this.
|
|
255
|
-
this.
|
|
256
|
-
this.buyProgress = { state: 'confirmed',
|
|
269
|
+
this.held -= spend;
|
|
270
|
+
this.spent += spend;
|
|
271
|
+
this.buyProgress = { state: 'confirmed', spent: spend };
|
|
257
272
|
this.emit();
|
|
258
|
-
return { bought: true,
|
|
273
|
+
return { bought: true, spent: spend };
|
|
259
274
|
},
|
|
260
275
|
};
|
|
261
276
|
|
package/src/replay-market.ts
CHANGED
|
@@ -27,7 +27,7 @@ export interface MarketFixture {
|
|
|
27
27
|
afterMs: number;
|
|
28
28
|
player: string | null;
|
|
29
29
|
side: 'buy' | 'sell';
|
|
30
|
-
|
|
30
|
+
spend: bigint;
|
|
31
31
|
priceEth: number | null;
|
|
32
32
|
}[];
|
|
33
33
|
marketCapUsd?: number | null;
|
|
@@ -91,7 +91,7 @@ export function replayMarket(fixture: MarketFixture, now: () => number = Date.no
|
|
|
91
91
|
at: startedAt + trade.afterMs,
|
|
92
92
|
player: trade.player,
|
|
93
93
|
side: trade.side,
|
|
94
|
-
|
|
94
|
+
spend: trade.spend,
|
|
95
95
|
priceEth: trade.priceEth,
|
|
96
96
|
}));
|
|
97
97
|
|
|
@@ -141,10 +141,10 @@ export const BUSY_LAUNCH: MarketFixture = {
|
|
|
141
141
|
{ afterMs: 18_000, priceEth: 0.000_049 },
|
|
142
142
|
],
|
|
143
143
|
trades: [
|
|
144
|
-
{ afterMs: 1_500, player: '0x1111111111111111111111111111111111111111', side: 'buy',
|
|
145
|
-
{ afterMs: 5_200, player: '0x2222222222222222222222222222222222222222', side: 'buy',
|
|
146
|
-
{ afterMs: 6_100, player: null, side: 'buy',
|
|
147
|
-
{ afterMs: 9_400, player: '0x3333333333333333333333333333333333333333', side: 'sell',
|
|
148
|
-
{ afterMs: 12_800, player: '0x1111111111111111111111111111111111111111', side: 'buy',
|
|
144
|
+
{ afterMs: 1_500, player: '0x1111111111111111111111111111111111111111', side: 'buy', spend: 12_000_000_000_000_000n, priceEth: 0.000_013 },
|
|
145
|
+
{ afterMs: 5_200, player: '0x2222222222222222222222222222222222222222', side: 'buy', spend: 40_000_000_000_000_000n, priceEth: 0.000_021 },
|
|
146
|
+
{ afterMs: 6_100, player: null, side: 'buy', spend: 8_000_000_000_000_000n, priceEth: 0.000_041 },
|
|
147
|
+
{ afterMs: 9_400, player: '0x3333333333333333333333333333333333333333', side: 'sell', spend: 5_000_000_000_000_000n, priceEth: 0.000_039 },
|
|
148
|
+
{ afterMs: 12_800, player: '0x1111111111111111111111111111111111111111', side: 'buy', spend: 25_000_000_000_000_000n, priceEth: 0.000_050 },
|
|
149
149
|
],
|
|
150
150
|
};
|