@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/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<{ spentWei: bigint } | { failed: BuyResult }>;
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().availableWei,
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) return this.failBuy(attempt, claimFailure(error.refuse));
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 spendWei: bigint;
415
+ let spend: bigint;
413
416
  try {
414
- spendWei = BigInt(authorisation.maxSpendWei);
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
- heldWei: before.heldWei + spendWei,
426
- availableWei: before.availableWei > spendWei ? before.availableWei - spendWei : 0n,
428
+ held: before.held + spend,
429
+ available: before.available > spend ? before.available - spend : 0n,
427
430
  }
428
431
  : {}),
429
- buy: { state: 'signing', spendWei },
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', spendWei }
445
- : { state: 'pending', spendWei, transactionHash },
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) return this.failBuy(attempt, 'try-again');
456
- return this.failBuy(attempt, result.failed.reason);
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', spentWei: result.spentWei }
463
- : { state: 'confirmed', spentWei: result.spentWei, transactionHash };
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, spentWei: result.spentWei };
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
- private failBuy(attempt: symbol, reason: BuyFailure): { bought: false; reason: BuyFailure } {
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
- weiPerPoint: 0n,
544
- earnedWei: 0n,
545
- heldWei: 0n,
546
- spentWei: 0n,
547
- availableWei: 0n,
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
- weiPerPoint: BigInt(value.weiPerPoint ?? '0'),
559
- earnedWei: BigInt(value.earnedWei ?? '-1'),
560
- heldWei: BigInt(value.heldWei ?? '-1'),
561
- spentWei: BigInt(value.spentWei ?? '-1'),
562
- availableWei: BigInt(value.availableWei ?? '-1'),
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.weiPerPoint <= 0n || Object.values(amounts).some((amount) => amount < 0n)) return null;
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.walletCapWei !== 'string' ||
591
- !decimal.test(value.walletCapWei) ||
592
- typeof value.weiPerPoint !== 'string' ||
593
- !decimal.test(value.weiPerPoint)
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 walletCapWei = BigInt(value.walletCapWei);
597
- const weiPerPoint = BigInt(value.weiPerPoint);
598
- const { maxPointsPerPlayer, pointsPerDollar, usdPerEth } = value;
624
+ const walletCap = BigInt(value.walletCap);
625
+ const unitsPerPoint = BigInt(value.unitsPerPoint);
626
+ const { maxPointsPerPlayer, pointsPerDollar, usdPerSpendToken } = value;
599
627
  if (
600
- walletCapWei <= 0n ||
601
- weiPerPoint <= 0n ||
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 an ETH is a round whose rate cannot mean anything.
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 usdPerEth !== 'number' ||
611
- !Number.isFinite(usdPerEth) ||
612
- usdPerEth <= 0
638
+ typeof usdPerSpendToken !== 'number' ||
639
+ !Number.isFinite(usdPerSpendToken) ||
640
+ usdPerSpendToken <= 0
613
641
  ) {
614
642
  return null;
615
643
  }
616
- return Object.freeze({ maxPointsPerPlayer, walletCapWei, weiPerPoint, pointsPerDollar, usdPerEth });
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 prices = value.prices.filter(
653
- (price) =>
654
- typeof price?.at === 'number' &&
655
- Number.isFinite(price.at) &&
656
- typeof price.priceEth === 'number' &&
657
- Number.isFinite(price.priceEth) &&
658
- price.priceEth > 0,
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 spendWei = BigInt(trade.spendWei);
671
- if (spendWei < 0n) return [];
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
- spendWei,
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 weiPerPoint: bigint;
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 spentWei = 0n;
38
- private heldWei = 0n;
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.weiPerPoint = options.weiPerPoint ?? 10_000_000_000_000n;
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.weiPerPoint;
83
- const walletCapWei = options.walletCapWei ?? flawlessRoundWei;
84
- if (walletCapWei < flawlessRoundWei) {
85
- throw new RangeError('walletCapWei must cover a flawless round, or the gate would refuse this launch');
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
- const pointsPerEth = WEI_PER_ETH / this.weiPerPoint;
95
- if (pointsPerEth <= 0n || pointsPerEth > BigInt(Number.MAX_SAFE_INTEGER)) {
96
- throw new RangeError('weiPerPoint does not produce representable round terms');
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 usdPerEth = Number(pointsPerEth) / pointsPerDollar;
99
- const roundTrip = WEI_PER_ETH / BigInt(Math.round(usdPerEth * pointsPerDollar));
100
- if (roundTrip !== this.weiPerPoint) {
101
- throw new RangeError('weiPerPoint must round-trip through gate pricing terms');
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({ maxPointsPerPlayer, walletCapWei, weiPerPoint: this.weiPerPoint, pointsPerDollar, usdPerEth }),
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 earnedWei = BigInt(this.round.pointsFor(this.player)) * this.weiPerPoint;
184
- const availableWei = earnedWei - this.spentWei - this.heldWei;
198
+ const earned = BigInt(this.round.pointsFor(this.player)) * this.unitsPerPoint;
199
+ const available = earned - this.spent - this.held;
185
200
  return {
186
- weiPerPoint: this.weiPerPoint,
187
- earnedWei,
188
- heldWei: this.heldWei,
189
- spentWei: this.spentWei,
190
- availableWei: availableWei > 0n ? availableWei : 0n,
191
- holdExpiresAt: this.heldWei > 0n ? this.holdExpiresAt : null,
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
- weiPerPoint: 0n,
198
- earnedWei: 0n,
199
- heldWei: 0n,
200
- spentWei: 0n,
201
- availableWei: 0n,
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().availableWei,
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.heldWei += spend;
246
+ this.held += spend;
232
247
  this.holdExpiresAt = Date.now() + MOCK_HOLD_MS;
233
- this.buyProgress = { state: 'signing', spendWei: spend };
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.heldWei -= spend;
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', spendWei: spend };
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.heldWei -= spend;
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.heldWei -= spend;
255
- this.spentWei += spend;
256
- this.buyProgress = { state: 'confirmed', spentWei: spend };
269
+ this.held -= spend;
270
+ this.spent += spend;
271
+ this.buyProgress = { state: 'confirmed', spent: spend };
257
272
  this.emit();
258
- return { bought: true, spentWei: spend };
273
+ return { bought: true, spent: spend };
259
274
  },
260
275
  };
261
276
 
@@ -27,7 +27,7 @@ export interface MarketFixture {
27
27
  afterMs: number;
28
28
  player: string | null;
29
29
  side: 'buy' | 'sell';
30
- spendWei: bigint;
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
- spendWei: trade.spendWei,
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', spendWei: 12_000_000_000_000_000n, priceEth: 0.000_013 },
145
- { afterMs: 5_200, player: '0x2222222222222222222222222222222222222222', side: 'buy', spendWei: 40_000_000_000_000_000n, priceEth: 0.000_021 },
146
- { afterMs: 6_100, player: null, side: 'buy', spendWei: 8_000_000_000_000_000n, priceEth: 0.000_041 },
147
- { afterMs: 9_400, player: '0x3333333333333333333333333333333333333333', side: 'sell', spendWei: 5_000_000_000_000_000n, priceEth: 0.000_039 },
148
- { afterMs: 12_800, player: '0x1111111111111111111111111111111111111111', side: 'buy', spendWei: 25_000_000_000_000_000n, priceEth: 0.000_050 },
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
  };