@xgamesfun/solana 0.24.0 → 0.25.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.
Files changed (92) hide show
  1. package/README.md +94 -51
  2. package/bin/xsolana.mjs +23 -8
  3. package/examples/blind-forge.json +2 -4
  4. package/examples/coin-flip-arena.json +2 -5
  5. package/examples/duel-arena.json +1 -4
  6. package/examples/fair-presale.json +1 -3
  7. package/examples/hero-trials.json +14 -2
  8. package/examples/holder-den.json +38 -0
  9. package/examples/holders-drop.json +105 -0
  10. package/examples/ideas.json +788 -788
  11. package/examples/match-predictions.json +24 -0
  12. package/examples/nft-raffle.json +1 -0
  13. package/examples/pack-league.json +108 -0
  14. package/examples/partner-vault.json +19 -3
  15. package/examples/sacrifice-raffle.json +2 -10
  16. package/examples/shelter-fair.json +1 -0
  17. package/examples/stakers-draw.json +3 -7
  18. package/examples/stride-sneakers.json +164 -0
  19. package/examples/trade-in-shop.json +2 -3
  20. package/js/approve/details.mjs +117 -48
  21. package/js/approve/ed25519.mjs +5 -1
  22. package/js/approve/live-check.mjs +83 -26
  23. package/js/approve/page.html +217 -78
  24. package/js/approve/page.js +43 -15
  25. package/js/approve/server.mjs +276 -74
  26. package/js/chain/base58.js +7 -1
  27. package/js/chain/borsh.js +15 -11
  28. package/js/chain/bytes.js +11 -8
  29. package/js/chain/core.js +21 -5
  30. package/js/chain/curve.js +1 -1
  31. package/js/chain/lookup.js +118 -36
  32. package/js/chain/mint.js +6 -2
  33. package/js/chain/pda.js +2 -2
  34. package/js/chain/programs.js +6 -6
  35. package/js/chain/rpc.js +40 -14
  36. package/js/chain/token.js +13 -2
  37. package/js/chain/token2022.js +55 -16
  38. package/js/chain/tx.js +32 -22
  39. package/js/client/accounts.js +270 -80
  40. package/js/client/compressed.js +4 -3
  41. package/js/client/grant.js +165 -40
  42. package/js/client/idl/loot.js +1 -1
  43. package/js/client/idl/loot.json +104 -16
  44. package/js/client/ix.js +2121 -457
  45. package/js/client/pda.js +20 -18
  46. package/js/client/policy.js +12 -5
  47. package/js/client/random.js +1 -1
  48. package/js/client/sizes.js +220 -65
  49. package/js/client/traits.js +14 -5
  50. package/js/connect/connect.mjs +59 -17
  51. package/js/economy/check.mjs +3873 -690
  52. package/js/economy/cost.mjs +380 -56
  53. package/js/economy/examples.mjs +176 -35
  54. package/js/economy/explain.mjs +461 -110
  55. package/js/economy/guide.mjs +282 -59
  56. package/js/economy/legal.mjs +12 -6
  57. package/js/economy/live.mjs +60 -19
  58. package/js/economy/model.mjs +320 -97
  59. package/js/economy/plan.mjs +1022 -203
  60. package/js/economy/schema.json +127 -369
  61. package/js/economy/secrets.mjs +17 -7
  62. package/js/economy/simulate.mjs +771 -178
  63. package/js/economy/txsize.mjs +15 -3
  64. package/js/economy/validate.mjs +61 -24
  65. package/js/mcp/server.mjs +42 -14
  66. package/js/mcp/tools.mjs +293 -80
  67. package/js/play/index.js +1960 -461
  68. package/js/play/keys.js +35 -9
  69. package/js/results/index.js +223 -30
  70. package/js/results/server.js +230 -0
  71. package/js/results/wire.js +64 -16
  72. package/package.json +6 -4
  73. package/release.json +5 -5
  74. package/skills/xsolana-economy/SKILL.md +30 -16
  75. package/skills/xsolana-economy/references/after-launch.md +30 -20
  76. package/skills/xsolana-economy/references/apps.md +47 -36
  77. package/skills/xsolana-economy/references/sentence-to-spec.md +148 -129
  78. package/skills/xsolana-economy/references/telling-the-person.md +9 -6
  79. package/types/chain/core.d.ts +1 -1
  80. package/types/chain/lookup.d.ts +1 -1
  81. package/types/client/accounts.d.ts +24 -3
  82. package/types/client/grant.d.ts +2 -2
  83. package/types/client/idl/loot.d.ts +22 -0
  84. package/types/client/ix.d.ts +54 -45
  85. package/types/client/pda.d.ts +3 -1
  86. package/types/economy/guide.d.mts +5 -0
  87. package/types/economy/model.d.mts +48 -3
  88. package/types/economy/plan.d.mts +44 -2
  89. package/types/play/index.d.ts +8 -19
  90. package/types/results/index.d.ts +7 -6
  91. package/types/results/server.d.ts +126 -0
  92. package/types/results/wire.d.ts +1 -1
@@ -11,13 +11,47 @@
11
11
  // the rest from then (`stockWalk`). It is a guide, not a promise: the full simulation (js/sim) runs the real program.
12
12
  import { entryWidth, LIMIT_RECORD_BYTES, ROUND_RECORD_BYTES } from '../client/index.js';
13
13
  import { boxOrderBytes, roundRentOf } from './plan.mjs';
14
- import { boxBuys, COLLECTIBLE_RENT as COLLECTIBLE_RENT_LAMPORTS, drawsOf, settleUnits, holdsRent, outcomeParts, SETTLE_RENT_UNIT, FEES, LAMPORTS_PER_SOL, ORACLE_FEE, RENT_PER_BYTE, SIGNATURE_FEE, destination, listOf, lotterySplit, model, placeOf, prizeLine, prizePool, prizeToken, farmEndWords, lateRunWords, prizeWords, releaseWords, sharesOf, toBps, toSeconds, toTime, weights, whenOutWords } from './model.mjs';
14
+ import {
15
+ boxBuys,
16
+ COLLECTIBLE_RENT as COLLECTIBLE_RENT_LAMPORTS,
17
+ drawsOf,
18
+ settleUnits,
19
+ holdsRent,
20
+ outcomeParts,
21
+ SETTLE_RENT_UNIT,
22
+ FEES,
23
+ LAMPORTS_PER_SOL,
24
+ ORACLE_FEE,
25
+ RENT_PER_BYTE,
26
+ SIGNATURE_FEE,
27
+ destination,
28
+ listOf,
29
+ keepsPot,
30
+ lotterySplit,
31
+ model,
32
+ placeOf,
33
+ prizeLine,
34
+ prizePool,
35
+ prizeToken,
36
+ farmEndWords,
37
+ lateRunWords,
38
+ prizeWords,
39
+ releaseWords,
40
+ sharesOf,
41
+ toBps,
42
+ toSeconds,
43
+ toTime,
44
+ weights,
45
+ whenOutWords,
46
+ } from './model.mjs';
15
47
 
16
48
  const DAY = 86_400;
17
49
  const DEFAULT_PER_DAY = 100;
50
+ /** How many wallets the simulation's crowd is, next to one: what a person with many wallets could take from a grant. */
51
+ const MANY_WALLETS = 100;
18
52
  /** Bids a day on an auction the scenario doesn't name (`auction:<key>`); a grant it doesn't name is claimed by nobody. */
19
53
  const DEFAULT_BIDS = 10;
20
- const sol = (lamports) => Number(lamports) / Number(LAMPORTS_PER_SOL);
54
+ const sol = lamports => Number(lamports) / Number(LAMPORTS_PER_SOL);
21
55
  const PLAYER_FEE = sol(FEES.player);
22
56
  const PROJECT_FEE = sol(FEES.project);
23
57
  const NETWORK_FEE = sol(SIGNATURE_FEE);
@@ -25,7 +59,15 @@ const NETWORK_FEE = sol(SIGNATURE_FEE);
25
59
  const ORACLE = sol(ORACLE_FEE);
26
60
  /** A lottery purchase's entry in its round: the bytes the buyer adds (not refunded: they go to the treasury when the
27
61
  * round closes), with a wallet kept for each share the buyer's own entry names (lottery.rs `entry_width`). */
28
- const ticketLine = (l) => sol(BigInt(entryWidth(lotterySplit(l).some((x) => String(x.to).startsWith('chosen')), Boolean(l.ticketNft))) * RENT_PER_BYTE);
62
+ const ticketLine = l =>
63
+ sol(
64
+ BigInt(
65
+ entryWidth(
66
+ lotterySplit(l).some(x => String(x.to).startsWith('chosen')),
67
+ Boolean(l.ticketNft),
68
+ ),
69
+ ) * RENT_PER_BYTE,
70
+ );
29
71
  /** A collectible's Core account, paid from the SOL the order holds: its rent and Core's creation fee (model.mjs). */
30
72
  const COLLECTIBLE_RENT = sol(COLLECTIBLE_RENT_LAMPORTS);
31
73
  /** What a collectible a recipe burns gives back: its rent less one byte's (Core leaves a 1-byte account that keeps
@@ -33,12 +75,18 @@ const COLLECTIBLE_RENT = sol(COLLECTIBLE_RENT_LAMPORTS);
33
75
  const BURN_REFUND = 0.00096;
34
76
  /** What a grant's claim adds to its lists: the wallet's limit record (its first claim) and a record of the round paid
35
77
  * (none for an open grant's claim). Back to the owner when the shards close after the grant. */
36
- const claimRecordsRent = (g) => sol(BigInt(LIMIT_RECORD_BYTES + ((g.vouchers ?? []).length || (g.keys ?? []).length || (g.list ?? []).length ? ROUND_RECORD_BYTES : 0)) * RENT_PER_BYTE);
78
+ const claimRecordsRent = g =>
79
+ sol(
80
+ BigInt(
81
+ LIMIT_RECORD_BYTES +
82
+ ((g.vouchers ?? []).length || (g.keys ?? []).length || (g.list ?? []).length ? ROUND_RECORD_BYTES : 0),
83
+ ) * RENT_PER_BYTE,
84
+ );
37
85
  /** A wallet's token account, which a grant that pays tokens opens from its funds when the winner has none. */
38
86
  const TOKEN_ACCOUNT_RENT = sol((165n + 128n) * RENT_PER_BYTE);
39
87
  /** An order of box `b`'s rent (`orderBytes`), returned at settle or refund: an open's (`opened`, sized as `open_box` sizes it), or a roll's made inside another call. */
40
88
  const orderRent = (b, m, opened = false) => sol((BigInt(boxOrderBytes(b, m, 1, { opened })) + 128n) * RENT_PER_BYTE);
41
- const num = (v) => (v === undefined || v === null ? 0 : Number(v));
89
+ const num = v => (v === undefined || v === null ? 0 : Number(v));
42
90
  export const round = (x, digits = 9) => (Number.isFinite(x) ? Number(x.toFixed(digits)) : x);
43
91
 
44
92
  /**
@@ -48,7 +96,19 @@ export const round = (x, digits = 9) => (Number.isFinite(x) ? Number(x.toFixed(d
48
96
  * `heldIs`: the SOL an order holds and its account, a ticket purchase's record) and, for SOL, the wrapped-SOL account a
49
97
  * purchase opens and closes again. A price that comes back (`priceBack`: a no-loss ticket) is held, not spent.
50
98
  */
51
- function realCostOf({ price, token, isSol, oracle = 0, signatures = 1, rent = 0, rentIs = '', held = 0, heldIs = '', priceBack = false, atLeast = false }) {
99
+ function realCostOf({
100
+ price,
101
+ token,
102
+ isSol,
103
+ oracle = 0,
104
+ signatures = 1,
105
+ rent = 0,
106
+ rentIs = '',
107
+ held = 0,
108
+ heldIs = '',
109
+ priceBack = false,
110
+ atLeast = false,
111
+ }) {
52
112
  const extrasSol = PLAYER_FEE + oracle + signatures * NETWORK_FEE + rent;
53
113
  const hold = extrasSol - rent + held + (isSol ? price + TOKEN_ACCOUNT_RENT : 0);
54
114
  const spent = isSol && !priceBack ? price : 0;
@@ -107,7 +167,12 @@ export function salePrices(part, perDay, days, fallStartsAfter = 0, budget = wal
107
167
  if (!part.fall && !growth && step > 0) {
108
168
  const below = Math.min(count - 1, Math.max(0, Math.ceil((PRICE_CAP - listed) / step) - 1));
109
169
  const total = listed * (1 + below) + (step * below * (below + 1)) / 2 + PRICE_CAP * (count - 1 - below);
110
- return { count, average: total / count, first: listed, last: count === 1 ? listed : below === count - 1 ? listed + below * step : PRICE_CAP };
170
+ return {
171
+ count,
172
+ average: total / count,
173
+ first: listed,
174
+ last: count === 1 ? listed : below === count - 1 ? listed + below * step : PRICE_CAP,
175
+ };
111
176
  }
112
177
  // On the sale clock (the default) a sale restarts the countdown, so after the first sale each sale comes `gap` after
113
178
  // the last, and one closer than a step of the fall never lowers it. On a shared clock the steps keep their own time.
@@ -119,7 +184,10 @@ export function salePrices(part, perDay, days, fallStartsAfter = 0, budget = wal
119
184
  let first;
120
185
  let paid = listed;
121
186
  for (let k = 0; k < count; k++) {
122
- if (budget.left-- <= 0) throw new Error(`The scenario has ${count.toLocaleString('en-US')} sales of “${part.name ?? part.key}”, whose price rises or falls, and the quick simulation walks at most ${MAX_WALK.toLocaleString('en-US')} sales of prices that still move, for all boxes and items together. Give the scenario fewer days or fewer a day.`);
187
+ if (budget.left-- <= 0)
188
+ throw new Error(
189
+ `The scenario has ${count.toLocaleString('en-US')} sales of “${part.name ?? part.key}”, whose price rises or falls, and the quick simulation walks at most ${MAX_WALK.toLocaleString('en-US')} sales of prices that still move, for all boxes and items together. Give the scenario fewer days or fewer a day.`,
190
+ );
123
191
  const at = (k + 0.5) * gap;
124
192
  paid = price;
125
193
  const steps = part.fall && every && at > since ? Math.floor((at - since) / every) : 0;
@@ -146,17 +214,44 @@ export function simulate(economy, { now = 0 } = {}) {
146
214
  const scenarioDays = days > 0 ? days : 1;
147
215
  const budget = walkBudget();
148
216
  const perDay = (kind, key) => num(m.scenario.perDay?.[`${kind}:${key}`] ?? DEFAULT_PER_DAY);
149
- const symbol = (key) => m.tokens.get(key)?.symbol ?? key;
217
+ const symbol = key => m.tokens.get(key)?.symbol ?? key;
150
218
 
151
219
  // Per pool: fixed money in and out per day, and a rate per day for what takes a share of what it holds.
152
- const pools = new Map(m.pools.map((p) => [p.key, { key: p.key, currency: p.currency, seed: num(p.seed), in: [], out: [], rate: [], variance: 0 }]));
153
- // Each lottery's pot is an account of its own, counted here as a pool named "lottery:<key>".
154
- for (const l of m.lotteries) pools.set(`lottery:${l.key}`, { key: `lottery:${l.key}`, pot: l.name, currency: l.currency ?? game, seed: num(l.seed), in: [], out: [], rate: [], variance: 0 });
220
+ const pools = new Map(
221
+ m.pools.map(p => [
222
+ p.key,
223
+ { key: p.key, currency: p.currency, seed: num(p.seed), in: [], out: [], rate: [], variance: 0 },
224
+ ]),
225
+ );
226
+ // Each lottery's pot is an account of its own, counted here as a pool named "lottery:<key>" (one that pays each round
227
+ // from its own ticket money keeps none).
228
+ for (const l of m.lotteries.filter(keepsPot))
229
+ pools.set(`lottery:${l.key}`, {
230
+ key: `lottery:${l.key}`,
231
+ pot: l.name,
232
+ currency: l.currency ?? game,
233
+ seed: num(l.seed),
234
+ in: [],
235
+ out: [],
236
+ rate: [],
237
+ variance: 0,
238
+ });
155
239
  // Each farm's rewards vault is an account of its own too, counted as a pool named "farm:<key>".
156
- for (const f of m.farms) pools.set(`farm:${f.key}`, { key: `farm:${f.key}`, farm: f.name, currency: f.rewardsToken, seed: num(f.seed), in: [], out: [], rate: [], variance: 0 });
240
+ for (const f of m.farms)
241
+ pools.set(`farm:${f.key}`, {
242
+ key: `farm:${f.key}`,
243
+ farm: f.name,
244
+ currency: f.rewardsToken,
245
+ seed: num(f.seed),
246
+ in: [],
247
+ out: [],
248
+ rate: [],
249
+ variance: 0,
250
+ });
157
251
  const flowIn = (pool, by, amount) => amount && pools.get(pool)?.in.push({ by, perDay: amount });
158
252
  const flowOut = (pool, by, amount) => amount && pools.get(pool)?.out.push({ by, perDay: amount });
159
- const rateOut = (pool, by, perDayShare, to) => perDayShare && pools.get(pool)?.rate.push({ by, perDay: perDayShare, ...(to ? { to } : {}) });
253
+ const rateOut = (pool, by, perDayShare, to) =>
254
+ perDayShare && pools.get(pool)?.rate.push({ by, perDay: perDayShare, ...(to ? { to } : {}) });
160
255
 
161
256
  const players = { pay: {}, get: {}, fees: 0, network: 0, rent: 0, oracle: 0 };
162
257
  const creator = { treasury: {}, fees: 0, network: 0, oracle: 0 };
@@ -173,7 +268,7 @@ export function simulate(economy, { now = 0 } = {}) {
173
268
  const prizeTakes = [];
174
269
  /** The SOL a prize's order holds while it waits (its rent), back to the winner at settle. Whoever settles it pays a
175
270
  * collectible's rent. */
176
- const orderHeld = (box) => orderRent(m.box.get(box), m);
271
+ const orderHeld = box => orderRent(m.box.get(box), m);
177
272
  /**
178
273
  * A route gives prize line `line` `times` a day (prize.rs), `payer` ({ rent, oracle }) paying what it opens: a
179
274
  * collectible's rent, the winner's token account (the bound, not the average), a roll's order (back to the winner at
@@ -189,7 +284,7 @@ export function simulate(economy, { now = 0 } = {}) {
189
284
  if (worth !== undefined) add(players.get, game, times * num(worth));
190
285
  } else if (line.gives === 'tokens') {
191
286
  each = TOKEN_ACCOUNT_RENT;
192
- const token = prizePool(line) ? m.pool.get(prizePool(line))?.currency : line.currency ?? game;
287
+ const token = prizePool(line) ? m.pool.get(prizePool(line))?.currency : (line.currency ?? game);
193
288
  if (prizePool(line)) flowOut(prizePool(line), by, times * num(line.amount));
194
289
  add(players.get, token, times * num(line.amount));
195
290
  } else if (line.gives === 'nft') {
@@ -203,18 +298,26 @@ export function simulate(economy, { now = 0 } = {}) {
203
298
  return each;
204
299
  };
205
300
  /** Seconds from going live to a fall's start. */
206
- const fallStart = (part) => (part.fall?.start && part.fall.start !== 'publish' ? Math.max(0, toTime(part.fall.start, now) - now) : 0);
301
+ const fallStart = part =>
302
+ part.fall?.start && part.fall.start !== 'publish' ? Math.max(0, toTime(part.fall.start, now) - now) : 0;
207
303
  /** What a rising or falling price really comes to, in words. */
208
304
  const priceNote = (part, sold) => {
209
305
  if (!part.rise && !part.fall) return;
210
306
  const shared = part.fall?.clock === 'shared';
211
- const how = part.rise && part.fall
212
- ? `rises with each sale and falls with time${shared ? ' on a clock of its own, sales or not' : ', the countdown restarting at each sale'}`
213
- : part.rise ? 'rises with each sale'
214
- : shared ? 'falls on a clock of its own, sales or not (a sale keeps the price at what was paid, and the next step still comes on time)'
215
- : 'falls while nobody buys (a sale keeps the price at what was paid and restarts the countdown)';
307
+ const how =
308
+ part.rise && part.fall
309
+ ? `rises with each sale and falls with time${shared ? ' on a clock of its own, sales or not' : ', the countdown restarting at each sale'}`
310
+ : part.rise
311
+ ? 'rises with each sale'
312
+ : shared
313
+ ? 'falls on a clock of its own, sales or not (a sale keeps the price at what was paid, and the next step still comes on time)'
314
+ : 'falls while nobody buys (a sale keeps the price at what was paid and restarts the countdown)';
216
315
  const said = `“${part.name}”’s price ${how}: over the scenario’s ${sold.count} sales it averages ${round(sold.average, 6)} (the first ${round(sold.first, 6)}, the last ${round(sold.last, 6)}), and the money below counts that.`;
217
- notes.push(sold.last >= 10 * num(part.price) ? `${said} It ends ${sold.last >= PRICE_CAP ? 'beyond any price' : `${round(sold.last / num(part.price), 1)} times`} where it started: players stop buying long before that. A smaller growth keeps it in reach.` : said);
316
+ notes.push(
317
+ sold.last >= 10 * num(part.price)
318
+ ? `${said} It ends ${sold.last >= PRICE_CAP ? 'beyond any price' : `${round(sold.last / num(part.price), 1)} times`} where it started: players stop buying long before that. A smaller growth keeps it in reach.`
319
+ : said,
320
+ );
218
321
  };
219
322
 
220
323
  /** Sends `amount` of `token` a day to one destination (model `destination`): a pool, a lottery's pot, a farm's rewards vault, a burn, the treasury or an account. */
@@ -224,7 +327,14 @@ export function simulate(economy, { now = 0 } = {}) {
224
327
  else if (d.kind === 'farm') flowIn(`farm:${d.key}`, by, amount);
225
328
  else if (d.kind === 'burn') add(burned, token, amount);
226
329
  else if (d.kind === 'treasury') add(creator.treasury, token, amount);
227
- else if (d.kind === 'chosen') add(accounts, d.key ? `the holder of the ${m.item.get(d.key)?.name ?? d.key} the buyer names` : `the wallet the ${String(by).startsWith('payment:') ? 'payer' : 'buyer'} names`, amount);
330
+ else if (d.kind === 'chosen')
331
+ add(
332
+ accounts,
333
+ d.key
334
+ ? `the holder of the ${m.item.get(d.key)?.name ?? d.key} the buyer names`
335
+ : `the wallet the ${String(by).startsWith('payment:') ? 'payer' : 'buyer'} names`,
336
+ amount,
337
+ );
228
338
  else add(accounts, `${d.kind}:${d.key}`, amount);
229
339
  };
230
340
  /** Where `amount` of `token` goes under `split`, per day: its shares (`sendTo`), and the rest to the treasury (or into `restTo`, a pool). */
@@ -256,14 +366,15 @@ export function simulate(economy, { now = 0 } = {}) {
256
366
  const draws = drawsOf(b);
257
367
  // What each stock holds at the start, and what one draw of each outcome takes from it.
258
368
  const left = new Map();
259
- const stockOf = (x) => {
369
+ const stockOf = x => {
260
370
  const supply = Number(m.item.get(x.item)?.supply ?? 0);
261
371
  if (x.gives === 'item' && supply > 0 && !b.stockOdds) return [`item:${x.item}`, supply];
262
- if (x.gives === 'stash' && m.stash.get(x.stash)) return [`stash:${x.stash}`, (m.stash.get(x.stash).nfts ?? []).length];
372
+ if (x.gives === 'stash' && m.stash.get(x.stash))
373
+ return [`stash:${x.stash}`, (m.stash.get(x.stash).nfts ?? []).length];
263
374
  if (x.gives === 'nft') return [`nft:${x.nft}`, 1];
264
375
  return null;
265
376
  };
266
- const needs = (b.outcomes ?? []).map((o) => {
377
+ const needs = (b.outcomes ?? []).map(o => {
267
378
  const need = new Map();
268
379
  for (const [key, holds] of outcomeParts(o).map(stockOf).filter(Boolean)) {
269
380
  left.set(key, holds);
@@ -273,49 +384,89 @@ export function simulate(economy, { now = 0 } = {}) {
273
384
  });
274
385
  if (!left.size || !(opens > 0)) return null;
275
386
  const stops = b.whenOut !== 'drawLeft';
276
- const there = (i) => [...needs[i]].every(([k, n]) => left.get(k) >= n);
277
- const chances = (alive) => {
387
+ const out = i => ({
388
+ outcome: i,
389
+ gives: (b.outcomes[i].gives === 'bundle'
390
+ ? (b.outcomes[i].prizes ?? []).map(x => x.item ?? x.stash ?? x.gives)
391
+ : [b.outcomes[i].item ?? b.outcomes[i].stash ?? b.outcomes[i].gives]
392
+ ).join(' + '),
393
+ });
394
+ if (stops) {
395
+ // A box that stops selling covers each purchase by stock (order.rs `covers`): each item, stash or named NFT must
396
+ // hold the most any outcome takes of it in one draw, times the draws of a purchase; it stops once one can't.
397
+ const most = new Map();
398
+ for (const need of needs) for (const [k, n] of need) most.set(k, Math.max(most.get(k) ?? 0, n));
399
+ const short = [...most].filter(([k, n]) => left.get(k) < n * draws).map(([k]) => k);
400
+ const by = keys =>
401
+ w
402
+ .map((_, i) => i)
403
+ .filter(i => keys.some(k => needs[i].has(k)))
404
+ .map(out);
405
+ if (short.length) return { whenOut: 'stopSales', stopsSellingInDays: 0, by: by(short) };
406
+ const sum = w.reduce((n, x) => n + x, 0);
407
+ if (!sum) return null;
408
+ const rate = new Map();
409
+ w.forEach((x, i) => needs[i].forEach((n, k) => rate.set(k, (rate.get(k) ?? 0) + (opens * draws * x * n) / sum)));
410
+ const next = [...rate]
411
+ .filter(([, r]) => r > 0)
412
+ .map(([k, r]) => ({ k, t: (left.get(k) - most.get(k) * draws + 1) / r }))
413
+ .sort((a, z) => a.t - z.t)[0];
414
+ return next && next.t <= days
415
+ ? { whenOut: 'stopSales', stopsSellingInDays: round(next.t, 4), by: by([next.k]) }
416
+ : null;
417
+ }
418
+ const there = i => [...needs[i]].every(([k, n]) => left.get(k) >= n);
419
+ const chances = alive => {
278
420
  const sum = alive.reduce((n, i) => n + w[i], 0);
279
421
  return w.map((x, i) => (sum && alive.includes(i) ? round((x / sum) * 100, 6) : 0));
280
422
  };
281
423
  let alive = w.map((_, i) => i).filter(there);
282
424
  let day = 0;
283
425
  const runsOut = [];
284
- const out = (i) => ({ outcome: i, gives: (b.outcomes[i].gives === 'bundle' ? (b.outcomes[i].prizes ?? []).map((x) => x.item ?? x.stash ?? x.gives) : [b.outcomes[i].item ?? b.outcomes[i].stash ?? b.outcomes[i].gives]).join(' + ') });
285
- const gone = w.map((_, i) => i).filter((i) => !alive.includes(i));
426
+ const gone = w.map((_, i) => i).filter(i => !alive.includes(i));
286
427
  if (gone.length) {
287
- if (stops) return { whenOut: 'stopSales', stopsSellingInDays: 0, by: gone.map(out) };
288
428
  runsOut.push({ inDays: 0, outcomes: gone.map(out), chancesAfter: chances(alive) });
289
429
  }
290
430
  while (alive.length) {
291
431
  const sum = alive.reduce((n, i) => n + w[i], 0);
292
432
  // Each stock's take a day, by the outcomes still there.
293
433
  const rate = new Map();
294
- for (const i of alive) for (const [k, n] of needs[i]) rate.set(k, (rate.get(k) ?? 0) + (opens * draws * w[i] * n) / sum);
434
+ for (const i of alive)
435
+ for (const [k, n] of needs[i]) rate.set(k, (rate.get(k) ?? 0) + (opens * draws * w[i] * n) / sum);
295
436
  const next = [...rate].map(([k, r]) => ({ k, t: left.get(k) / r })).sort((a, z) => a.t - z.t)[0];
296
437
  if (!next || day + next.t > days) break;
297
438
  day += next.t;
298
439
  for (const [k, r] of rate) left.set(k, Math.max(0, left.get(k) - r * next.t));
299
440
  left.set(next.k, 0);
300
- const ended = alive.filter((i) => !there(i));
441
+ const ended = alive.filter(i => !there(i));
301
442
  alive = alive.filter(there);
302
- if (stops) return { whenOut: 'stopSales', stopsSellingInDays: round(day, 4), by: ended.map(out) };
303
443
  runsOut.push({ inDays: round(day, 4), outcomes: ended.map(out), chancesAfter: chances(alive) });
304
444
  }
305
- if (stops) return null;
306
- return runsOut.length ? { whenOut: 'drawLeft', runsOut, ...(alive.length ? {} : { stopsSellingInDays: runsOut.at(-1).inDays }) } : null;
445
+ return runsOut.length
446
+ ? { whenOut: 'drawLeft', runsOut, ...(alive.length ? {} : { stopsSellingInDays: runsOut.at(-1).inDays }) }
447
+ : null;
307
448
  };
308
449
  /** The walk of a box's stock in words (`stockWalk`). */
309
450
  const stockNote = (b, walk) => {
310
- const named = (list) => list.map((x) => `outcome ${x.outcome} (${x.gives})`).join(', ');
451
+ const named = list => list.map(x => `outcome ${x.outcome} (${x.gives})`).join(', ');
311
452
  if (walk.whenOut === 'stopSales')
312
- return `“${b.name}” stops selling ${walk.stopsSellingInDays ? `in about ${walk.stopsSellingInDays} days` : 'from the start'}, when ${named(walk.by)} runs out (its own opens alone): it isn’t sold while any outcome couldn’t cover a purchase and those still waiting for the oracle ("whenOut": "stopSales"), so every roll is on the odds as written. Restock it, or edit the box, to sell again; "drawLeft" would keep selling, the rest’s chances rising.`;
313
- const steps = walk.runsOut.map((r) => `${named(r.outcomes)} ${r.inDays ? `in about ${r.inDays} days` : 'from the start'}${r.chancesAfter.some((c) => c > 0) ? `, then ${r.chancesAfter.map((c, i) => (c > 0 ? `outcome ${i} ${round(c, 2)}%` : null)).filter(Boolean).join(', ')}` : ''}`);
453
+ return `“${b.name}” stops selling ${walk.stopsSellingInDays ? `in about ${walk.stopsSellingInDays} days` : 'from the start'}, when ${named(walk.by)} runs out (its own opens alone): it isn’t sold while an item, a stash or a named NFT couldn’t cover, at the most any outcome takes of it in one draw, the draws of a purchase and of those still waiting for the oracle ("whenOut": "stopSales"), so every roll is on the odds as written. Restock it, or edit the box, to sell again; "drawLeft" would keep selling, the rest’s chances rising.`;
454
+ const steps = walk.runsOut.map(
455
+ r =>
456
+ `${named(r.outcomes)} ${r.inDays ? `in about ${r.inDays} days` : 'from the start'}${
457
+ r.chancesAfter.some(c => c > 0)
458
+ ? `, then ${r.chancesAfter
459
+ .map((c, i) => (c > 0 ? `outcome ${i} ${round(c, 2)}%` : null))
460
+ .filter(Boolean)
461
+ .join(', ')}`
462
+ : ''
463
+ }`,
464
+ );
314
465
  return `“${b.name}” keeps selling as its outcomes run out ("whenOut": "drawLeft"; its own opens alone): ${steps.join('; ')}${walk.stopsSellingInDays !== undefined ? `; with nothing left, it stops selling` : ''}. Each roll draws among what is left by the written weights, so a player can empty a common outcome to make a rare one likelier; "stopSales" keeps the odds as written.`;
315
466
  };
316
467
 
317
468
  // ---- boxes
318
- const boxes = m.boxes.map((b) => {
469
+ const boxes = m.boxes.map(b => {
319
470
  const token = b.currency ?? game;
320
471
  const opens = perDay('box', b.key);
321
472
  // A price that rises or falls is counted at what the scenario's opens really pay, on average.
@@ -351,7 +502,9 @@ export function simulate(economy, { now = 0 } = {}) {
351
502
  } else if (o.gives === 'stash' || o.gives === 'nft') {
352
503
  // A stash's NFT has whatever worth its collection has: the simulation counts how fast it is given away.
353
504
  Object.assign(row, { stash: o.stash, worth: null, ...(o.nft ? { nft: o.nft } : {}) });
354
- unpriced.push(o.gives === 'nft' ? `the NFT ${String(o.nft).slice(0, 4)}… of “${o.stash}”` : `the NFTs of “${o.stash}”`);
505
+ unpriced.push(
506
+ o.gives === 'nft' ? `the NFT ${String(o.nft).slice(0, 4)}… of “${o.stash}”` : `the NFTs of “${o.stash}”`,
507
+ );
355
508
  // A box switched off is opened by nobody: only the prizes that roll it take from the stash.
356
509
  if (b.active !== false) stashTakes.push({ stash: o.stash, perDay: opens * p, by: `box:${b.key}` });
357
510
  } else if (o.gives === 'roll') {
@@ -364,15 +517,15 @@ export function simulate(economy, { now = 0 } = {}) {
364
517
  };
365
518
  const outcomes = (b.outcomes ?? []).map((o, i) => {
366
519
  const p = w[i] / total;
367
- const parts = outcomeParts(o).map((part) => partRow(part, p));
520
+ const parts = outcomeParts(o).map(part => partRow(part, p));
368
521
  if (o.gives === 'bundle') return { gives: 'bundle', chance: round(p * 100, 6), prizes: parts };
369
522
  return parts[0] ?? { gives: o.gives, chance: round(p * 100, 6) };
370
523
  });
371
524
  // Every part of every outcome, each with its outcome's chance.
372
- const partsOf = outcomes.flatMap((o) => (o.gives === 'bundle' ? o.prizes : [o]));
525
+ const partsOf = outcomes.flatMap(o => (o.gives === 'bundle' ? o.prizes : [o]));
373
526
  // How much the prizes swing from day to day, per pool: opens × (E[prize²] − E[prize]²).
374
- for (const key of new Set(partsOf.filter((o) => o.gives === 'pay').map((o) => o.pool))) {
375
- const rows = partsOf.filter((o) => o.gives === 'pay' && o.pool === key);
527
+ for (const key of new Set(partsOf.filter(o => o.gives === 'pay').map(o => o.pool))) {
528
+ const rows = partsOf.filter(o => o.gives === 'pay' && o.pool === key);
376
529
  const mean = rows.reduce((n, o) => n + (o.chance / 100) * o.amount, 0);
377
530
  const square = rows.reduce((n, o) => n + (o.chance / 100) * o.amount ** 2, 0);
378
531
  if (pools.has(key)) pools.get(key).variance += opens * (square - mean ** 2);
@@ -388,14 +541,27 @@ export function simulate(economy, { now = 0 } = {}) {
388
541
  const signatures = bought ? 1 : 2;
389
542
  playerAction(opens, signatures);
390
543
  if (!bought) players.oracle += opens * ORACLE;
391
- const collectibles = drawsOf(b) * partsOf.filter((o) => holdsRent(o, m) && m.item.get(o.item)?.kind !== 'normal').reduce((n, o) => n + o.chance / 100, 0);
544
+ const collectibles =
545
+ drawsOf(b) *
546
+ partsOf
547
+ .filter(o => holdsRent(o, m) && m.item.get(o.item)?.kind !== 'normal')
548
+ .reduce((n, o) => n + o.chance / 100, 0);
392
549
  players.rent += opens * collectibles * COLLECTIBLE_RENT;
393
550
  const known = !unpriced.length && !Object.keys(other).length;
394
551
  // What one open really takes from the player's wallet: the price, the platform's fee, the oracle's and the network's.
395
552
  const isSol = Boolean(m.tokens.get(token)?.wsol);
396
553
  const oracle = bought ? 0 : ORACLE;
397
554
  // A collectible's rent comes out of the SOL the open lends its settle key: on average, the chance of one.
398
- const realCost = realCostOf({ price, token, isSol, oracle, signatures, rent: collectibles * COLLECTIBLE_RENT, rentIs: collectibles < 1 ? 'the collectible’s rent, when it gives one' : 'the collectible’s rent', held: bought ? 0 : sol(SETTLE_RENT_UNIT) * settleUnits(b, m) + orderRent(b, m, true) });
555
+ const realCost = realCostOf({
556
+ price,
557
+ token,
558
+ isSol,
559
+ oracle,
560
+ signatures,
561
+ rent: collectibles * COLLECTIBLE_RENT,
562
+ rentIs: collectibles < 1 ? 'the collectible’s rent, when it gives one' : 'the collectible’s rent',
563
+ held: bought ? 0 : sol(SETTLE_RENT_UNIT) * settleUnits(b, m) + orderRent(b, m, true),
564
+ });
399
565
  const extrasSol = realCost.extrasSol;
400
566
  priceNote(b, sold);
401
567
  // What it gives that runs out, as the box says (`whenOut`): it stops selling, or draws among what is left.
@@ -418,7 +584,18 @@ export function simulate(economy, { now = 0 } = {}) {
418
584
  oracleFee: oracle,
419
585
  realCost,
420
586
  // What it gives can run out, and what the box does then (`whenOut`, `whenOutWords`).
421
- ...(!bought && outcomes.flatMap((o) => (o.gives === 'bundle' ? o.prizes : [o])).some((o) => o.gives === 'pay' || o.gives === 'stash' || o.gives === 'nft' || (o.gives === 'item' && Number(m.item.get(o.item)?.supply ?? 0) > 0)) ? { limitedStock: true } : {}),
587
+ ...(!bought &&
588
+ outcomes
589
+ .flatMap(o => (o.gives === 'bundle' ? o.prizes : [o]))
590
+ .some(
591
+ o =>
592
+ o.gives === 'pay' ||
593
+ o.gives === 'stash' ||
594
+ o.gives === 'nft' ||
595
+ (o.gives === 'item' && Number(m.item.get(o.item)?.supply ?? 0) > 0),
596
+ )
597
+ ? { limitedStock: true }
598
+ : {}),
422
599
  ...(!bought ? { whenOut: b.whenOut === 'drawLeft' ? 'drawLeft' : 'stopSales' } : {}),
423
600
  ...(walk ? { stock: walk } : {}),
424
601
  ...(isSol && known && price ? { returnOfRealCostPercent: round((value / (price + extrasSol)) * 100, 4) } : {}),
@@ -427,8 +604,8 @@ export function simulate(economy, { now = 0 } = {}) {
427
604
 
428
605
  // ---- items sold in the shop
429
606
  const items = m.shop
430
- .map((b) => ({ ...m.item.get(b.shopOf), shopBox: b.key }))
431
- .map((it) => {
607
+ .map(b => ({ ...m.item.get(b.shopOf), shopBox: b.key }))
608
+ .map(it => {
432
609
  const sales = perDay('item', it.key);
433
610
  const sold = salePrices(it, sales, days, fallStart(it), budget);
434
611
  const price = sold.average;
@@ -442,32 +619,73 @@ export function simulate(economy, { now = 0 } = {}) {
442
619
  if (collectible) players.rent += sales * COLLECTIBLE_RENT;
443
620
  const listed = num(it.price);
444
621
  const rises = it.rise ? listed * (num(it.rise.growthPercent) / 100) + num(it.rise.step) : 0;
445
- const realCost = realCostOf({ price: listed, token: game, isSol: Boolean(m.tokens.get(game)?.wsol), signatures: 1, rent: collectible ? COLLECTIBLE_RENT : 0, rentIs: 'the collectible’s rent', held: 0 });
446
- return { key: it.key, name: it.name, price: listed, ...(price !== listed ? { averagePrice: round(price) } : {}), token: game, salesPerDay: sales, ...(rises ? { nextPrice: round(listed + rises) } : {}), ...(it.supply && sales > 0 ? { soldOutInDays: round(it.supply / sales, 2) } : {}), realCost };
622
+ const realCost = realCostOf({
623
+ price: listed,
624
+ token: game,
625
+ isSol: Boolean(m.tokens.get(game)?.wsol),
626
+ signatures: 1,
627
+ rent: collectible ? COLLECTIBLE_RENT : 0,
628
+ rentIs: 'the collectible’s rent',
629
+ held: 0,
630
+ });
631
+ return {
632
+ key: it.key,
633
+ name: it.name,
634
+ price: listed,
635
+ ...(price !== listed ? { averagePrice: round(price) } : {}),
636
+ token: game,
637
+ salesPerDay: sales,
638
+ ...(rises ? { nextPrice: round(listed + rises) } : {}),
639
+ ...(it.supply && sales > 0 ? { soldOutInDays: round(it.supply / sales, 2) } : {}),
640
+ realCost,
641
+ };
447
642
  });
448
643
 
449
644
  // ---- payments
450
- const payments = m.payments.map((p) => {
645
+ const payments = m.payments.map(p => {
451
646
  const token = p.currency ?? game;
452
647
  const count = perDay('payment', p.key);
453
648
  carryOut(p.split, token, count * num(p.amount), `payment:${p.key}`);
454
649
  add(players.pay, token, count * num(p.amount));
455
650
  playerAction(count);
456
- const realCost = realCostOf({ price: num(p.amount), token, isSol: Boolean(m.tokens.get(token)?.wsol), atLeast: Boolean(p.any) });
457
- return { key: p.key, name: p.name, amount: num(p.amount), ...(p.any ? { atLeast: true } : {}), token, perDay: count, realCost };
651
+ const realCost = realCostOf({
652
+ price: num(p.amount),
653
+ token,
654
+ isSol: Boolean(m.tokens.get(token)?.wsol),
655
+ atLeast: Boolean(p.any),
656
+ });
657
+ return {
658
+ key: p.key,
659
+ name: p.name,
660
+ amount: num(p.amount),
661
+ ...(p.any ? { atLeast: true } : {}),
662
+ token,
663
+ perDay: count,
664
+ realCost,
665
+ };
458
666
  });
459
667
 
460
668
  // ---- recipes
461
- const recipes = m.recipes.map((r) => {
669
+ const recipes = m.recipes.map(r => {
462
670
  const crafts = perDay('recipe', r.key);
463
671
  const takes = [];
464
672
  const gives = [];
465
673
  for (const line of r.in ?? []) {
466
674
  if (line.takes === 'burn') players.rent -= crafts * (line.count ?? 1) * BURN_REFUND;
467
675
  if (line.takes !== 'pay') {
468
- const what = line.takes === 'show' || line.takes === 'trade' ? `an NFT of ${line.collection ? `${line.collection.slice(0, 4)}…` : 'the collection'}` : line.item;
469
- const traded = (t) => (!t || t === 'burn' ? ' (traded in, burned)' : t.stash ? ` (traded in, into “${t.stash}”)` : ' (traded in, given on)');
470
- takes.push(`${line.takes === 'burn' ? `${line.count ?? 1} × ` : ''}${what}${line.takes === 'burn' ? ' (burned)' : line.takes === 'trade' ? traded(line.then) : line.takes === 'renew' ? ' (renewed)' : line.takes === 'staked' ? ` (kept, and staked in “${m.farm.get(line.farm)?.name ?? line.farm}”)` : ' (kept)'}`);
676
+ const what =
677
+ line.takes === 'show' || line.takes === 'trade'
678
+ ? `an NFT of ${line.collection ? `${line.collection.slice(0, 4)}…` : 'the collection'}`
679
+ : line.item;
680
+ const traded = t =>
681
+ !t || t === 'burn'
682
+ ? ' (traded in, burned)'
683
+ : t.stash
684
+ ? ` (traded in, into “${t.stash}”)`
685
+ : ' (traded in, given on)';
686
+ takes.push(
687
+ `${line.takes === 'burn' ? `${line.count ?? 1} × ` : ''}${what}${line.takes === 'burn' ? ' (burned)' : line.takes === 'trade' ? traded(line.then) : line.takes === 'renew' ? ' (renewed)' : line.takes === 'staked' ? ` (kept, and staked in “${m.farm.get(line.farm)?.name ?? line.farm}”)` : ' (kept)'}`,
688
+ );
471
689
  continue;
472
690
  }
473
691
  const token = line.currency ?? game;
@@ -476,9 +694,24 @@ export function simulate(economy, { now = 0 } = {}) {
476
694
  sendTo(destination(line.to), token, crafts * num(line.amount), `recipe:${r.key}`);
477
695
  }
478
696
  for (const x of r.raises ?? []) {
479
- if (!x.fromNow && typeof x.by === 'string') gives.push(String(x.by).startsWith('-') ? `${x.key} ${String(x.by).slice(1)} sooner on collectible ${x.place}` : `${x.key} ${x.by} later on collectible ${x.place}`);
480
- else if (x.fromNow) gives.push(String(x.by).startsWith('-') ? `${x.key} ${String(x.by).slice(1)} sooner on collectible ${x.place}` : `${x.key} ${x.by} later on collectible ${x.place}, from now when it is past (at most ${x.upTo} ahead)`);
481
- else gives.push(x.by > 0 ? `+${x.by} ${x.key} on collectible ${x.place} (up to ${x.upTo})` : `${x.by} ${x.key} on collectible ${x.place} (never below 0)`);
697
+ if (!x.fromNow && typeof x.by === 'string')
698
+ gives.push(
699
+ String(x.by).startsWith('-')
700
+ ? `${x.key} ${String(x.by).slice(1)} sooner on collectible ${x.place}`
701
+ : `${x.key} ${x.by} later on collectible ${x.place}`,
702
+ );
703
+ else if (x.fromNow)
704
+ gives.push(
705
+ String(x.by).startsWith('-')
706
+ ? `${x.key} ${String(x.by).slice(1)} sooner on collectible ${x.place}`
707
+ : `${x.key} ${x.by} later on collectible ${x.place}, from now when it is past (at most ${x.upTo} ahead)`,
708
+ );
709
+ else
710
+ gives.push(
711
+ x.by > 0
712
+ ? `+${x.by} ${x.key} on collectible ${x.place} (up to ${x.upTo})`
713
+ : `${x.by} ${x.key} on collectible ${x.place} (never below 0)`,
714
+ );
482
715
  }
483
716
  for (const line of r.out ?? []) {
484
717
  if (line.gives === 'item') {
@@ -506,15 +739,17 @@ export function simulate(economy, { now = 0 } = {}) {
506
739
 
507
740
  // ---- schedules
508
741
  const schedules = m.schedules
509
- .filter((s) => s.active !== false)
510
- .map((s) => {
742
+ .filter(s => s.active !== false)
743
+ .map(s => {
511
744
  const every = toSeconds(s.every) || DAY;
512
745
  const runs = DAY / every;
513
746
  creator.network += runs * NETWORK_FEE; // run_schedule is free of the program's fee; whoever sends it pays the network
514
747
  if (s.does === 'issue') {
515
748
  const amount = num(s.amount);
516
749
  flowIn(destination(s.to ?? '').kind === 'farm' ? s.to : s.pool, `schedule:${s.key}`, runs * amount);
517
- const halving = s.halveEvery ? `, halving every ${s.halveEvery} runs (every ${round((s.halveEvery * every) / DAY, 2)} days)` : '';
750
+ const halving = s.halveEvery
751
+ ? `, halving every ${s.halveEvery} runs (every ${round((s.halveEvery * every) / DAY, 2)} days)`
752
+ : '';
518
753
  // Halving every h runs: h runs at the amount, h at half, … (2 × h × amount in all, with no end); with
519
754
  // maxRuns, the full halvings it reaches and the runs after the last.
520
755
  const halvings = s.halveEvery && s.maxRuns ? Math.floor(s.maxRuns / s.halveEvery) : 0;
@@ -525,14 +760,28 @@ export function simulate(economy, { now = 0 } = {}) {
525
760
  : s.maxRuns
526
761
  ? amount * s.maxRuns
527
762
  : Infinity;
528
- return { key: s.key, does: 'issue', perDay: round(runs * amount), token: m.pool.get(s.pool)?.currency, says: `mints ${round(runs * amount)} ${symbol(m.pool.get(s.pool)?.currency)} a day into ${s.pool}${halving}`, mintsInAll: Number.isFinite(totalEver) ? round(totalEver) : null };
763
+ return {
764
+ key: s.key,
765
+ does: 'issue',
766
+ perDay: round(runs * amount),
767
+ token: m.pool.get(s.pool)?.currency,
768
+ says: `mints ${round(runs * amount)} ${symbol(m.pool.get(s.pool)?.currency)} a day into ${s.pool}${halving}`,
769
+ mintsInAll: Number.isFinite(totalEver) ? round(totalEver) : null,
770
+ };
529
771
  }
530
772
  if (s.sealed) {
531
773
  // A sealed schedule pays from its own vault: what it was funded with, run by run, the last run the rest.
532
774
  const t = s.currency ?? m.currencyKey;
533
- const each = s.percent === undefined ? `${num(s.amount)} ${symbol(t)}` : `${s.percent}% of what its vault holds`;
775
+ const each =
776
+ s.percent === undefined ? `${num(s.amount)} ${symbol(t)}` : `${s.percent}% of what its vault holds`;
534
777
  if (s.percent === undefined) sendTo(destination(s.to), t, runs * num(s.amount), `schedule:${s.key}`);
535
- return { key: s.key, does: 'pay', sealed: true, perDay: s.percent === undefined ? round(runs * num(s.amount)) : 0, says: `sealed: pays ${s.to} ${each} every ${round(every / 3600, 3)} h for ${s.maxRuns} runs from a vault of its own${s.fund ? ` (${num(s.fund)} ${symbol(t)} put in at go-live${s.fundFrom === 'mint' ? ', minted' : s.fundFrom?.startsWith('pool:') ? ` from ${s.fundFrom.slice(5)}` : ' from the owner’s wallet'})` : ''}; the last run pays whatever is left, and nobody, the owner included, can change it, stop it or take its tokens back` };
778
+ return {
779
+ key: s.key,
780
+ does: 'pay',
781
+ sealed: true,
782
+ perDay: s.percent === undefined ? round(runs * num(s.amount)) : 0,
783
+ says: `sealed: pays ${s.to} ${each} every ${round(every / 3600, 3)} h for ${s.maxRuns} runs from a vault of its own${s.fund ? ` (${num(s.fund)} ${symbol(t)} put in at go-live${s.fundFrom === 'mint' ? ', minted' : s.fundFrom?.startsWith('pool:') ? ` from ${s.fundFrom.slice(5)}` : ' from the owner’s wallet'})` : ''}; the last run pays whatever is left, and nobody, the owner included, can change it, stop it or take its tokens back`,
784
+ };
536
785
  }
537
786
  const from = s.does === 'burn' ? s.pool : s.from;
538
787
  const sym = symbol(m.pool.get(from)?.currency);
@@ -544,7 +793,12 @@ export function simulate(economy, { now = 0 } = {}) {
544
793
  else if (s.does === 'burn') add(burned, m.pool.get(from)?.currency, runs * amount);
545
794
  else sendTo(destination(s.to), m.pool.get(from)?.currency, runs * amount, `schedule:${s.key}`);
546
795
  const where = s.does === 'burn' ? 'burns' : s.does === 'move' ? `moves to ${s.to}` : `pays ${s.to}`;
547
- return { key: s.key, does: s.does, perDay: round(runs * amount), says: `${where} ${amount} ${sym} from ${from} every ${round(every / 3600, 3)} h` };
796
+ return {
797
+ key: s.key,
798
+ does: s.does,
799
+ perDay: round(runs * amount),
800
+ says: `${where} ${amount} ${sym} from ${from} every ${round(every / 3600, 3)} h`,
801
+ };
548
802
  }
549
803
  const share = (toBps(s.percent) ?? 0) / 10_000;
550
804
  if (s.does === 'burn') {
@@ -558,7 +812,12 @@ export function simulate(economy, { now = 0 } = {}) {
558
812
  sharePrizes.push({ pool: s.from, rate: runs * share, account: s.to, by: `schedule:${s.key}` });
559
813
  }
560
814
  const where = s.does === 'burn' ? 'burns' : s.does === 'move' ? `moves to ${s.to}` : `pays ${s.to}`;
561
- return { key: s.key, does: s.does, percentPerDay: round(runs * s.percent, 6), says: `${where} ${s.percent}% of ${from} every ${round(every / 3600, 3)} h` };
815
+ return {
816
+ key: s.key,
817
+ does: s.does,
818
+ percentPerDay: round(runs * s.percent, 6),
819
+ says: `${where} ${s.percent}% of ${from} every ${round(every / 3600, 3)} h`,
820
+ };
562
821
  });
563
822
 
564
823
  // ---- stakes: everything that arrives in a farm’s rewards vault goes to its stakers
@@ -567,7 +826,7 @@ export function simulate(economy, { now = 0 } = {}) {
567
826
  // its draw and its pot happen once, spread over the scenario's days like an auction's sale.
568
827
  /** Per lottery: whether it draws once, and the days its money a day is spread over. */
569
828
  const lotteryRun = new Map();
570
- const lotteries = m.lotteries.map((l) => {
829
+ const lotteries = m.lotteries.map(l => {
571
830
  const once = !toSeconds(l.every);
572
831
  const untilDraw = l.firstDraw ? toTime(l.firstDraw, now) - now : 0;
573
832
  const every = once ? Math.min(untilDraw > 0 ? untilDraw : Infinity, scenarioDays * DAY) : toSeconds(l.every);
@@ -585,32 +844,82 @@ export function simulate(economy, { now = 0 } = {}) {
585
844
  // No loss: every ticket comes back to its buyer after the draw; the pot is only what is paid into it.
586
845
  if (l.noLoss) add(players.get, ticketToken, tickets * price);
587
846
  else if (l.ticketCurrency) add(burned, ticketToken, tickets * price);
588
- else {
847
+ else if (keepsPot(l)) {
589
848
  // The split takes its shares as it does a sale's; the rest goes into the pot (not the treasury).
590
849
  carryOut(split, potToken, tickets * price, `lottery:${l.key}`, `lottery:${l.key}`);
850
+ } else {
851
+ // Paid from each round's own money: the split takes its shares, the places their percents of the rest, and
852
+ // what they leave goes to `rest` (below, once the places are known).
853
+ for (const share of sharesOf(split))
854
+ sendTo(share, potToken, (tickets * price * (share.bps ?? 0)) / 10_000, `lottery:${l.key}`);
591
855
  }
592
856
  playerAction(tickets);
593
857
  // A lottery that holds SOL for its rounds (`roundRent`, on by default) pays each entry's bytes: the buyer pays none.
594
858
  const entryRent = roundRentOf(l, m) > 0n ? 0 : ticketLine(l);
595
859
  players.rent += tickets * entryRent;
596
860
  const placed = (l.places ?? [100]).map(placeOf);
597
- const places = placed.map((p) => Number(p.percent));
861
+ const places = placed.map(p => Number(p.percent));
598
862
  creator.network += (1 + places.length) * NETWORK_FEE * drawsPerDay; // the draw and each place's payment
599
863
  // A place's prize, once a round, given by whoever pays the place (the game's keeper).
600
- for (const p of placed.filter((p) => p.prize)) givePrize(p.prize, drawsPerDay, `lottery:${l.key}`, creator);
864
+ for (const p of placed.filter(p => p.prize)) givePrize(p.prize, drawsPerDay, `lottery:${l.key}`, creator);
601
865
  creator.oracle += ORACLE * drawsPerDay; // the draw asks the oracle; whoever sends it (the game's keeper) pays
602
866
  // What the places' prizes are worth a round in the pot's token: tokens of it, or an item with a value in it. A
603
867
  // prize with no price (an NFT, a roll, an item with no value) leaves a ticket's return unsaid.
604
- const worthOf = (line) => {
605
- if (line.gives === 'tokens') return (prizePool(line) ? m.pool.get(prizePool(line))?.currency : line.currency ?? game) === potToken ? num(line.amount) : null;
606
- if (line.gives === 'item') return m.item.get(line.item)?.value !== undefined && game === potToken ? num(m.item.get(line.item).value) : null;
868
+ const worthOf = line => {
869
+ if (line.gives === 'tokens')
870
+ return (prizePool(line) ? m.pool.get(prizePool(line))?.currency : (line.currency ?? game)) === potToken
871
+ ? num(line.amount)
872
+ : null;
873
+ if (line.gives === 'item')
874
+ return m.item.get(line.item)?.value !== undefined && game === potToken
875
+ ? num(m.item.get(line.item).value)
876
+ : null;
607
877
  return null;
608
878
  };
609
- const prized = placed.filter((p) => prizeLine(p.prize)).map((p) => worthOf(prizeLine(p.prize)));
879
+ const prized = placed.filter(p => prizeLine(p.prize)).map(p => worthOf(prizeLine(p.prize)));
610
880
  // One ticket bought on its own: the fee and the record are per purchase, so a bundle of tickets shares them. The
611
881
  // record's rent comes back once the round is done with it, and a no-loss ticket's price after the draw: both held.
612
- const realCost = realCostOf({ price, token: ticketToken, isSol: Boolean(m.tokens.get(ticketToken)?.wsol), rent: entryRent, rentIs: 'the purchase’s entry in the round', ...(l.noLoss ? { heldIs: 'the ticket’s price' } : {}), priceBack: Boolean(l.noLoss) });
613
- return { key: l.key, name: l.name, price, token: ticketToken, potToken, ...(l.minSold ? { minSold: l.minSold } : {}), ticketsPerDay: round(tickets, 6), ticketsPerRound: round(perRound, 4), roundDays: round(every / DAY, 4), places, placePrizes: placed.map((p) => (p.prize ? prizeWords(p.prize, m) : null)), prizeWorthPerRound: round(prized.reduce((n, w) => n + (w ?? 0), 0)), ...(prized.includes(null) ? { unpricedPrizes: true } : {}), cutPercent: num(l.cutPercent), splitPercent: round(cut * 100, 4), drawsOnce: once, roundRentSol: round(Number(roundRentOf(l, m)) / 1e9), ...(l.ticketNft ? { ticketNft: true } : {}), ...(l.noLoss ? { noLoss: true } : {}), realCost };
882
+ const realCost = realCostOf({
883
+ price,
884
+ token: ticketToken,
885
+ isSol: Boolean(m.tokens.get(ticketToken)?.wsol),
886
+ rent: entryRent,
887
+ rentIs: 'the purchase’s entry in the round',
888
+ ...(l.noLoss ? { heldIs: 'the ticket’s price' } : {}),
889
+ priceBack: Boolean(l.noLoss),
890
+ });
891
+ // Each round's own money a day, for its places and its `rest` (none reaches them when tickets are burned or NFTs).
892
+ const roundMoney = keepsPot(l) || l.noLoss || l.ticketCurrency ? 0 : tickets * price * (1 - cut);
893
+ if (!keepsPot(l))
894
+ sendTo(
895
+ { ...destination(l.rest) },
896
+ potToken,
897
+ roundMoney * (1 - places.reduce((a, b) => a + b, 0) / 100),
898
+ `lottery:${l.key}`,
899
+ );
900
+ return {
901
+ key: l.key,
902
+ name: l.name,
903
+ price,
904
+ token: ticketToken,
905
+ potToken,
906
+ ...(keepsPot(l) ? { pot: l.pot } : { rest: l.rest, roundMoneyPerDay: roundMoney }),
907
+ ...(l.minSold ? { minSold: l.minSold } : {}),
908
+ ticketsPerDay: round(tickets, 6),
909
+ ticketsPerRound: round(perRound, 4),
910
+ roundDays: round(every / DAY, 4),
911
+ places,
912
+ placePrizes: placed.map(p => (p.prize ? prizeWords(p.prize, m) : null)),
913
+ prizeWorthPerRound: round(prized.reduce((n, w) => n + (w ?? 0), 0)),
914
+ ...(prized.includes(null) ? { unpricedPrizes: true } : {}),
915
+ cutPercent: num(l.cutPercent),
916
+ splitPercent: round(cut * 100, 4),
917
+ drawsOnce: once,
918
+ roundRentSol: round(Number(roundRentOf(l, m)) / 1e9),
919
+ ...(l.ticketNft ? { ticketNft: true } : {}),
920
+ ...(l.noLoss ? { noLoss: true } : {}),
921
+ realCost,
922
+ };
614
923
  });
615
924
 
616
925
  // ---- decisions with a known rate
@@ -626,26 +935,37 @@ export function simulate(economy, { now = 0 } = {}) {
626
935
  rateOut(d.pool, `decision:${d.key}`, rate * ((toBps(d.percent) ?? 0) / 10_000));
627
936
  if (d.does === 'payout') sharePrizes.push({ pool: d.pool, rate: rate * ((toBps(d.percent) ?? 0) / 10_000) });
628
937
  else shareBurns.push({ pool: d.pool, rate: rate * ((toBps(d.percent) ?? 0) / 10_000) });
629
- }
630
- else flowOut(d.pool, `decision:${d.key}`, rate * num(d.amount));
938
+ } else flowOut(d.pool, `decision:${d.key}`, rate * num(d.amount));
631
939
  const token = m.pool.get(d.pool)?.currency;
632
940
  if (d.does === 'payout' && d.amount !== undefined) add(players.get, token, rate * num(d.amount));
633
941
  if (d.does === 'burn' && d.amount !== undefined) add(burned, token, rate * num(d.amount));
634
942
  }
635
943
  }
636
- const contests = m.contests.map((c) => {
944
+ const contests = m.contests.map(c => {
637
945
  const rake = sharesOf(c.split).reduce((n, s) => n + (s.bps ?? 0), 0) / 100;
638
946
  const opened = c.every ? DAY / toSeconds(c.every) : 0;
639
947
  creator.fees += opened * 2 * PROJECT_FEE; // opened and decided
640
948
  platform.project += opened * 2 * PROJECT_FEE;
641
- const split = prizeLine(c.prize)?.split ? `, and ${prizeWords(c.prize, m)} (set aside whole when it is decided; short of it, the decision is refused)` : '';
642
- return { key: c.key, name: c.name, sides: c.sides, rakePercent: rake, winnersSharePercent: 100 - rake, says: `the winning side shares ${100 - rake}% of the pot, by what each put in${split}` };
949
+ const split = prizeLine(c.prize)?.split
950
+ ? `, and ${prizeWords(c.prize, m)} (set aside whole when it is decided; short of it, the decision is refused; a part a winner gives up stays for the pool, not for later winners)`
951
+ : '';
952
+ return {
953
+ key: c.key,
954
+ name: c.name,
955
+ sides: c.sides,
956
+ rakePercent: rake,
957
+ winnersSharePercent: 100 - rake,
958
+ says: `the winning side shares ${100 - rake}% of the pot as it stands when the side is named, by what each put in${split}: each winner's part is written then, whoever claims first, and anything sent to the pot after it goes to nobody (burned at the close)`,
959
+ };
643
960
  });
644
961
  // Auctions: each bid tops the last by the least raise, so a run of N bids ends near reserve × (1 + raise)^(N − 1).
645
962
  // The scenario counts bids a day (`auction:<key>`, DEFAULT_BIDS a day unless it says), while the auction runs.
646
- const auctions = m.auctions.map((a) => {
963
+ const auctions = m.auctions.map(a => {
647
964
  const runDays = toSeconds(a.runs) / DAY;
648
- const bids = Math.max(0, Math.round(num(m.scenario.perDay?.[`auction:${a.key}`] ?? DEFAULT_BIDS) * Math.max(runDays, 1 / 24)));
965
+ const bids = Math.max(
966
+ 0,
967
+ Math.round(num(m.scenario.perDay?.[`auction:${a.key}`] ?? DEFAULT_BIDS) * Math.max(runDays, 1 / 24)),
968
+ );
649
969
  const reserve = num(a.reserve);
650
970
  const raise = (a.minRaise ?? 5) / 100;
651
971
  const price = bids ? reserve * (1 + raise) ** (bids - 1) : 0;
@@ -669,7 +989,9 @@ export function simulate(economy, { now = 0 } = {}) {
669
989
  bids,
670
990
  reserve: num(a.reserve ?? 1),
671
991
  expectedPrice: null,
672
- says: !bids ? 'no bids: the lot goes home' : `about ${bids} bids over ${round(runDays)} days, each a bundle of up to three NFTs: the bundle with the most NFTs wins (at least ${num(a.reserve ?? 1)}), so it goes for NFTs, not money, and nothing is counted in what players pay; every topped bundle goes back to its bidder at once`,
992
+ says: !bids
993
+ ? 'no bids: the lot goes home'
994
+ : `about ${bids} bids over ${round(runDays)} days, each a bundle of up to three NFTs: the bundle with the most NFTs wins (at least ${num(a.reserve ?? 1)}), so it goes for NFTs, not money, and nothing is counted in what players pay; every topped bundle goes back to its bidder at once`,
673
995
  };
674
996
  }
675
997
  return {
@@ -686,21 +1008,71 @@ export function simulate(economy, { now = 0 } = {}) {
686
1008
  };
687
1009
  });
688
1010
  for (const f of m.farms) playerAction(perDay('farm', f.key));
1011
+ /**
1012
+ * What many wallets could take from a grant a day, next to one: wallets are free, so per-wallet limits bound a person
1013
+ * only when the grant counts people (vouchers that name a person) or each claim must show something bought. `perDay`
1014
+ * caps it whatever happens. Leaves out a list (each entry is paid once) and codes that each pay a set number of times.
1015
+ */
1016
+ const MANY = MANY_WALLETS;
1017
+ const manyWallets = g => {
1018
+ const l = g.limits ?? {};
1019
+ const vouched = (g.vouchers ?? []).length > 0 || (g.keys ?? []).length > 0;
1020
+ const listed = (g.list ?? []).length > 0;
1021
+ if ((listed && !vouched) || ((g.keys ?? []).length && l.perKey && !(g.vouchers ?? []).length)) return {};
1022
+ const who = vouched
1023
+ ? 'its voucher signer vouches for'
1024
+ : g.gate
1025
+ ? 'each showing what its gate asks'
1026
+ : '(free to make)';
1027
+ if (l.perWalletEach?.count) {
1028
+ const each = l.perWalletEach.count / Math.max((toSeconds(l.perWalletEach.every ?? '1d') || DAY) / DAY, 1);
1029
+ const many = Math.min(MANY * each, l.perDay || Infinity);
1030
+ return {
1031
+ many: {
1032
+ one: round(each),
1033
+ all: round(many),
1034
+ per: 'a day',
1035
+ who,
1036
+ capped: Boolean(l.perDay && MANY * each > l.perDay),
1037
+ },
1038
+ };
1039
+ }
1040
+ if (!l.perWallet) return {};
1041
+ const many = Math.min(MANY * l.perWallet, l.total || Infinity);
1042
+ return {
1043
+ many: {
1044
+ one: l.perWallet,
1045
+ all: many,
1046
+ per: 'in all',
1047
+ who,
1048
+ capped: Boolean(l.total && MANY * l.perWallet > l.total),
1049
+ perDay: l.perDay ?? null,
1050
+ },
1051
+ };
1052
+ };
689
1053
  // Grants: each claim's sender pays the player fee; its records' rent comes from the grant's funds (the owner's SOL, back when its shards close).
690
- const grants = m.grants.map((g) => {
1054
+ const grants = m.grants.map(g => {
691
1055
  const claims = num(m.scenario.perDay?.[`grant:${g.key}`]);
692
1056
  playerAction(claims);
693
1057
  creator.rent = (creator.rent ?? 0) + claims * claimRecordsRent(g);
694
1058
  // Its prize, paid for by the grant's funds; a roll's order goes back to the winner at settle.
695
1059
  const line = prizeLine(g.prize ?? g.gives);
696
1060
  const each = givePrize(line, claims, `grant:${g.key}`, creator);
697
- return { key: g.key, name: g.name, claimsPerDay: claims, gives: prizeWords(line, m), fundsPerClaimSol: round(claimRecordsRent(g) + each), ...(line?.gives === 'roll' ? { orderHeldSol: round(orderHeld(line.box)) } : {}) };
1061
+ return {
1062
+ key: g.key,
1063
+ name: g.name,
1064
+ claimsPerDay: claims,
1065
+ gives: prizeWords(line, m),
1066
+ fundsPerClaimSol: round(claimRecordsRent(g) + each),
1067
+ ...(line?.gives === 'roll' ? { orderHeldSol: round(orderHeld(line.box)) } : {}),
1068
+ ...manyWallets(g),
1069
+ };
698
1070
  });
699
1071
  for (const h of m.holds) playerAction(perDay('hold', h.key));
700
1072
  for (const c of m.contests) playerAction(perDay('contest', c.key));
701
1073
 
702
1074
  // ---- the pools over time
703
- const settle = (p) => {
1075
+ const settle = p => {
704
1076
  const fixedIn = p.in.reduce((n, f) => n + f.perDay, 0);
705
1077
  const fixedOut = p.out.reduce((n, f) => n + f.perDay, 0);
706
1078
  const rate = p.rate.reduce((n, f) => n + f.perDay, 0);
@@ -711,7 +1083,9 @@ export function simulate(economy, { now = 0 } = {}) {
711
1083
  const levels = new Map();
712
1084
  for (let pass = 0; pass < 3; pass += 1)
713
1085
  for (const p of pools.values()) {
714
- const moved = [...pools.values()].flatMap((q) => q.rate.filter((r) => r.to === p.key).map((r) => r.perDay * (levels.get(q.key) ?? q.seed)));
1086
+ const moved = [...pools.values()].flatMap(q =>
1087
+ q.rate.filter(r => r.to === p.key).map(r => r.perDay * (levels.get(q.key) ?? q.seed)),
1088
+ );
715
1089
  const { fixedIn, fixedOut, rate } = settle(p);
716
1090
  const inflow = fixedIn + moved.reduce((a, b) => a + b, 0);
717
1091
  const net = inflow - fixedOut;
@@ -725,32 +1099,53 @@ export function simulate(economy, { now = 0 } = {}) {
725
1099
  // or with `shared` for the whole farm (minted, or from the vault while it lasts). In a steady state the release pays
726
1100
  // what arrives, a day at a time.
727
1101
  /** A farm's ways out, in words (farm.rs): an early-exit fee, compounding; or null. */
728
- const farmExtras = (f) => {
729
- const exits = (f.tiers ?? []).map((t, i) => (t.exitFee ? `tier ${i + 1} may leave before its ${t.lock} lock by leaving ${t.exitFee}% of what it takes out (${f.exitFeeTo === 'burn' ? 'burned' : f.exitFeeTo === 'rewards' ? 'into its own rewards vault' : `to ${String(f.exitFeeTo).replace(/^pool:/, '')}`}), keeping the rewards it was credited` : null)).filter(Boolean);
1102
+ const farmExtras = f => {
1103
+ const exits = (f.tiers ?? [])
1104
+ .map((t, i) =>
1105
+ t.exitFee
1106
+ ? `tier ${i + 1} may leave before its ${t.lock} lock by leaving ${t.exitFee}% of what it takes out (${f.exitFeeTo === 'burn' ? 'burned' : f.exitFeeTo === 'rewards' ? 'into its own rewards vault' : `to ${String(f.exitFeeTo).replace(/^pool:/, '')}`}), keeping the rewards it was credited`
1107
+ : null,
1108
+ )
1109
+ .filter(Boolean);
730
1110
  const parts = [
731
1111
  ...exits,
732
- f.stakes !== 'collectibles' && f.rewardsToken === f.stakes && 'its rewards can be compounded into the stake without restarting the lock',
733
- (f.tiers ?? []).some((t) => t.earnsFor) && `a seat earns only for its tier’s term (${(f.tiers ?? []).map((t) => t.earnsFor ?? 'no end').join(', ')}) after its lock started`,
1112
+ f.stakes !== 'collectibles' &&
1113
+ f.rewardsToken === f.stakes &&
1114
+ 'its rewards can be compounded into the stake without restarting the lock',
1115
+ (f.tiers ?? []).some(t => t.earnsFor) &&
1116
+ `a seat earns only for its tier’s term (${(f.tiers ?? []).map(t => t.earnsFor ?? 'no end').join(', ')}) after its lock started`,
734
1117
  ].filter(Boolean);
735
1118
  return parts.length ? parts.join('; ') : null;
736
1119
  };
737
1120
  /** What a seat of `f` can give up (farm.rs `Farm::give`): a forfeit, a watched NFT gone. */
738
- const giveBacks = (f) => ['a forfeit', f.custody === 'watch' && 'a watched NFT sold'].filter(Boolean).join(', ');
739
- const farmRows = m.farms.map((f) => {
1121
+ const giveBacks = f => ['a forfeit', f.custody === 'watch' && 'a watched NFT sold'].filter(Boolean).join(', ');
1122
+ const farmRows = m.farms.map(f => {
740
1123
  const pool = pools.get(`farm:${f.key}`);
741
1124
  const staked = num(m.scenario.staked?.[f.key]);
742
1125
  const perUnit = f.rate ? num(f.rate.perDay) : null;
743
1126
  const wanted = !f.rate ? Infinity : f.rate.shared ? (staked ? perUnit : 0) : perUnit * staked;
744
1127
  const free = pool ? Math.max(0, pool.inflow - pool.out.reduce((n, x) => n + x.perDay, 0)) : 0;
745
- const rewards = f.rate?.minted ? wanted : Math.min(wanted, f.rate ? free + (pool?.seed ?? 0) / Math.max(1, m.scenario.days ?? 30) : free);
1128
+ const rewards = f.rate?.minted
1129
+ ? wanted
1130
+ : Math.min(wanted, f.rate ? free + (pool?.seed ?? 0) / Math.max(1, m.scenario.days ?? 30) : free);
746
1131
  if (pool && !f.rate?.minted) pool.out.push({ by: `farm:${f.key}`, perDay: rewards });
747
1132
  const sameToken = f.rewardsToken === f.stakes;
748
- const tiers = (f.tiers ?? [{ lock: 0, multiplier: 1 }]).map((t) => ({ lock: t.lock, multiplier: t.multiplier ?? 1, ...(t.earnsFor ? { earnsFor: t.earnsFor } : {}), ...(prizeLine(t.prize) ? { prize: prizeWords(t.prize, m) } : {}) }));
1133
+ const tiers = (f.tiers ?? [{ lock: 0, multiplier: 1 }]).map(t => ({
1134
+ lock: t.lock,
1135
+ multiplier: t.multiplier ?? 1,
1136
+ ...(t.earnsFor ? { earnsFor: t.earnsFor } : {}),
1137
+ ...(prizeLine(t.prize) ? { prize: prizeWords(t.prize, m) } : {}),
1138
+ }));
749
1139
  const unit = f.stakes === 'collectibles' ? 'collectible' : symbol(f.stakes);
750
1140
  // Growth: the days a collectible at x1 needs for each level.
751
- const growth = listOf(f.growth).map((g) => {
1141
+ const growth = listOf(f.growth).map(g => {
752
1142
  const levels = (g.levels ?? []).map((points, i) => ({ level: i + 1, points, days: round(points / g.perDay, 2) }));
753
- return { attribute: g.attribute ?? 'xp', perDay: g.perDay, ...(levels.length ? { level: g.level ?? 'level', levels } : {}), streak: Boolean(g.streak) };
1143
+ return {
1144
+ attribute: g.attribute ?? 'xp',
1145
+ perDay: g.perDay,
1146
+ ...(levels.length ? { level: g.level ?? 'level', levels } : {}),
1147
+ streak: Boolean(g.streak),
1148
+ };
754
1149
  });
755
1150
  const perSeat = !f.rate ? null : f.rate.shared ? (staked ? perUnit / staked : null) : perUnit;
756
1151
  return {
@@ -759,9 +1154,22 @@ export function simulate(economy, { now = 0 } = {}) {
759
1154
  stakes: f.stakes,
760
1155
  rewardsToken: f.rewardsToken,
761
1156
  rewardsPerDay: round(rewards),
762
- ...(f.rate ? { rate: { perDay: perUnit, minted: Boolean(f.rate.minted), ...(f.rate.shared ? { shared: true } : {}), ...(f.rate.halveEvery ? { halveEvery: f.rate.halveEvery } : {}) } } : {}),
1157
+ ...(f.rate
1158
+ ? {
1159
+ rate: {
1160
+ perDay: perUnit,
1161
+ minted: Boolean(f.rate.minted),
1162
+ ...(f.rate.shared ? { shared: true } : {}),
1163
+ ...(f.rate.halveEvery ? { halveEvery: f.rate.halveEvery } : {}),
1164
+ },
1165
+ }
1166
+ : {}),
763
1167
  tiers,
764
- ...(staked ? { staked, perUnitPerDay: round(f.rate ? perSeat : rewards / staked), unit } : f.rate && !f.rate.shared ? { perUnitPerDay: perUnit, unit } : {}),
1168
+ ...(staked
1169
+ ? { staked, perUnitPerDay: round(f.rate ? perSeat : rewards / staked), unit }
1170
+ : f.rate && !f.rate.shared
1171
+ ? { perUnitPerDay: perUnit, unit }
1172
+ : {}),
765
1173
  ...(staked && sameToken ? { yearlyPercent: round(((rewards * 365) / staked) * 100, 4) } : {}),
766
1174
  ...(growth.length ? { growth } : {}),
767
1175
  ...(f.runs ? { runs: f.runs } : {}),
@@ -776,30 +1184,44 @@ export function simulate(economy, { now = 0 } = {}) {
776
1184
  });
777
1185
  for (const row of farmRows) add(players.get, row.rewardsToken, row.rewardsPerDay);
778
1186
  // A farm that ends inside the scenario takes no stake from its end on (FarmOver), and pays nothing after it.
779
- for (const f of m.farms.filter((x) => x.runs && toSeconds(x.runs) < days * DAY))
780
- notes.push(`“${f.name ?? f.key}” runs ${f.runs}, less than the scenario’s ${days} days: its figures are a day while it runs; from its end it takes no stake and pays nothing.`);
1187
+ for (const f of m.farms.filter(x => x.runs && toSeconds(x.runs) < days * DAY))
1188
+ notes.push(
1189
+ `“${f.name ?? f.key}” runs ${f.runs}, less than the scenario’s ${days} days: its figures are a day while it runs; from its end it takes no stake and pays nothing.`,
1190
+ );
781
1191
 
782
1192
  // Lotteries pay what came in, once the pot is in its steady state; one that draws once pays its seed and all that
783
1193
  // came in, at its one draw.
784
1194
  for (const l of lotteries) {
785
1195
  const pool = pools.get(`lottery:${l.key}`);
786
- const inflow = pool ? pool.inflow : 0;
787
1196
  const run = lotteryRun.get(l.key);
788
1197
  const placesTotal = l.places.reduce((a, b) => a + b, 0) / 100;
1198
+ // A pot pays what came in once it is steady; a round paid from its own money pays its places' percents of it, the
1199
+ // rest gone to `rest` already.
1200
+ const inflow = pool ? pool.inflow : (l.roundMoneyPerDay ?? 0) * placesTotal;
789
1201
  pool?.out.push({ by: `lottery:${l.key}`, perDay: inflow });
790
- const potBeforeDraw = run.once ? run.seed + inflow * run.spreadDays : placesTotal ? (inflow * l.roundDays) / placesTotal : 0;
791
- const paid = run.once ? potBeforeDraw * placesTotal : inflow * l.roundDays;
1202
+ const potBeforeDraw = !pool
1203
+ ? (l.roundMoneyPerDay ?? 0) * (run.once ? run.spreadDays : l.roundDays)
1204
+ : run.once
1205
+ ? run.seed + inflow * run.spreadDays
1206
+ : placesTotal
1207
+ ? (inflow * l.roundDays) / placesTotal
1208
+ : 0;
1209
+ const paid = run.once || !pool ? potBeforeDraw * placesTotal : inflow * l.roundDays;
792
1210
  l.potBeforeDraw = round(potBeforeDraw);
1211
+ if (!pool) l.roundMoneyPerDay = round(l.roundMoneyPerDay ?? 0);
793
1212
  l.paidPerRound = round(paid);
794
- l.prizes = l.places.map((pct) => round((potBeforeDraw * pct) / 100));
1213
+ l.prizes = l.places.map(pct => round((potBeforeDraw * pct) / 100));
795
1214
  const perTicket = l.ticketsPerRound ? (paid + l.prizeWorthPerRound) / l.ticketsPerRound : null;
796
1215
  l.expectedPerTicket = perTicket === null ? null : round(perTicket);
797
1216
  // A no-loss ticket comes back whole, so it returns its price and its share of the pot on top.
798
- l.returnPercent = perTicket !== null && l.price && l.token === l.potToken && !l.unpricedPrizes ? round((perTicket / l.price) * 100 + (l.noLoss ? 100 : 0), 4) : null;
1217
+ l.returnPercent =
1218
+ perTicket !== null && l.price && l.token === l.potToken && !l.unpricedPrizes
1219
+ ? round((perTicket / l.price) * 100 + (l.noLoss ? 100 : 0), 4)
1220
+ : null;
799
1221
  add(players.get, l.potToken, run.once ? paid / run.spreadDays : inflow);
800
1222
  }
801
1223
 
802
- const poolRows = [...pools.values()].map((p) => {
1224
+ const poolRows = [...pools.values()].map(p => {
803
1225
  const { fixedOut, rate } = settle(p);
804
1226
  const inflow = p.inflow ?? 0;
805
1227
  const net = inflow - fixedOut;
@@ -811,8 +1233,11 @@ export function simulate(economy, { now = 0 } = {}) {
811
1233
  seed: p.seed,
812
1234
  inPerDay: round(inflow),
813
1235
  outPerDay: round(fixedOut + rate * (levels.get(p.key) ?? p.seed)),
814
- ins: p.in.map((f) => ({ by: f.by, perDay: round(f.perDay) })),
815
- outs: [...p.out.map((f) => ({ by: f.by, perDay: round(f.perDay) })), ...p.rate.map((f) => ({ by: f.by, percentPerDay: round(f.perDay * 100, 6) }))],
1236
+ ins: p.in.map(f => ({ by: f.by, perDay: round(f.perDay) })),
1237
+ outs: [
1238
+ ...p.out.map(f => ({ by: f.by, perDay: round(f.perDay) })),
1239
+ ...p.rate.map(f => ({ by: f.by, percentPerDay: round(f.perDay * 100, 6) })),
1240
+ ],
816
1241
  };
817
1242
  if (rate > 0 && Math.abs(net) < 1e-12) {
818
1243
  // Only a share goes out: it shrinks by that share for ever, and never quite empties.
@@ -844,21 +1269,26 @@ export function simulate(economy, { now = 0 } = {}) {
844
1269
  row.swingPerDay = round(Math.sqrt(p.variance), 6);
845
1270
  const weekly = Math.sqrt(p.variance * 7);
846
1271
  if (row.lasts !== 'runs dry' && p.seed < 3 * weekly)
847
- notes.push(`The pool “${p.key}” ${row.lasts === 'grows' ? 'takes in more than it pays on average' : row.lasts === 'settles' ? 'settles on average' : 'pays even on average'}, but luck moves it by about ±${round(row.swingPerDay, 4)} ${symbol(p.currency)} a day (±${round(weekly, 4)} a week), against a seed of ${p.seed}. While it can’t cover a fixed prize, a box that stops selling when out (the default) sells nothing, and one that draws among what is left never lands on that prize, so a bad week stalls sales or takes the big wins away until it refills. A seed of about ${round(3 * weekly, 2)} (three times a week’s swing) rides out almost any week.`);
1272
+ notes.push(
1273
+ `The pool “${p.key}” ${row.lasts === 'grows' ? 'takes in more than it pays on average' : row.lasts === 'settles' ? 'settles on average' : 'pays even on average'}, but luck moves it by about ±${round(row.swingPerDay, 4)} ${symbol(p.currency)} a day (±${round(weekly, 4)} a week), against a seed of ${p.seed}. While it can’t cover a fixed prize, a box that stops selling when out (the default) sells nothing, and one that draws among what is left never lands on that prize, so a bad week stalls sales or takes the big wins away until it refills. A seed of about ${round(3 * weekly, 2)} (three times a week’s swing) rides out almost any week.`,
1274
+ );
848
1275
  }
849
- if (row.lasts === 'runs dry') notes.push(`The pool “${p.key}” pays out more than comes in: it lasts about ${row.lastsDays} days from its seed of ${p.seed} ${symbol(p.currency)}.`);
1276
+ if (row.lasts === 'runs dry')
1277
+ notes.push(
1278
+ `The pool “${p.key}” pays out more than comes in: it lasts about ${row.lastsDays} days from its seed of ${p.seed} ${symbol(p.currency)}.`,
1279
+ );
850
1280
  return row;
851
1281
  });
852
1282
 
853
1283
  for (const prize of sharePrizes) {
854
- const row = poolRows.find((r) => r.key === prize.pool);
1284
+ const row = poolRows.find(r => r.key === prize.pool);
855
1285
  const level = row ? (row.settlesAt ?? row.seed) : 0;
856
1286
  if (prize.account) sendTo(destination(prize.account), row?.token, prize.rate * level, prize.by);
857
1287
  else add(players.get, row?.token, prize.rate * level);
858
1288
  }
859
1289
  // A share burned is burned at the pool's level, as a share paid out is.
860
1290
  for (const b of shareBurns) {
861
- const row = poolRows.find((r) => r.key === b.pool);
1291
+ const row = poolRows.find(r => r.key === b.pool);
862
1292
  add(burned, row?.token, b.rate * (row ? (row.settlesAt ?? row.seed) : 0));
863
1293
  }
864
1294
  // Stashes: each roll that lands on one takes an NFT out; how long what it starts with lasts.
@@ -866,37 +1296,84 @@ export function simulate(economy, { now = 0 } = {}) {
866
1296
  const b = m.box.get(r.box);
867
1297
  const w = b ? weights(b) : [];
868
1298
  const total = w.reduce((a, x) => a + x, 0) || 1;
869
- (b?.outcomes ?? []).forEach((o, i) => outcomeParts(o).forEach((x) => (x.gives === 'stash' || x.gives === 'nft') && stashTakes.push({ stash: x.stash, perDay: (r.perDay * w[i]) / total, by: r.by })));
1299
+ (b?.outcomes ?? []).forEach((o, i) =>
1300
+ outcomeParts(o).forEach(
1301
+ x =>
1302
+ (x.gives === 'stash' || x.gives === 'nft') &&
1303
+ stashTakes.push({ stash: x.stash, perDay: (r.perDay * w[i]) / total, by: r.by }),
1304
+ ),
1305
+ );
870
1306
  }
871
1307
  // What fills a stash besides its owner: NFTs paid as a price (a box's `then`, a recipe's trade-in, a lottery's ticket).
872
1308
  const stashFills = [];
873
- for (const b of m.boxes.filter((b) => b.rollsOn && b.active !== false)) {
1309
+ for (const b of m.boxes.filter(b => b.rollsOn && b.active !== false)) {
874
1310
  const w = weights(b);
875
1311
  const total = w.reduce((a, x) => a + x, 0) || 1;
876
- (b.outcomes ?? []).forEach((o, i) => (o.then ?? b.then)?.stash && stashFills.push({ stash: (o.then ?? b.then).stash, perDay: (perDay('box', b.key) * w[i]) / total, by: `box:${b.key}` }));
1312
+ (b.outcomes ?? []).forEach(
1313
+ (o, i) =>
1314
+ (o.then ?? b.then)?.stash &&
1315
+ stashFills.push({
1316
+ stash: (o.then ?? b.then).stash,
1317
+ perDay: (perDay('box', b.key) * w[i]) / total,
1318
+ by: `box:${b.key}`,
1319
+ }),
1320
+ );
877
1321
  }
878
- for (const r of m.recipes) for (const l of r.in ?? []) if (l.takes === 'trade' && l.then?.stash) stashFills.push({ stash: l.then.stash, perDay: perDay('recipe', r.key), by: `recipe:${r.key}` });
879
- for (const l of m.lotteries) if (l.ticketNft?.then?.stash) stashFills.push({ stash: l.ticketNft.then.stash, perDay: perDay('lottery', l.key), by: `lottery:${l.key}` });
880
- const stashes = m.stashes.map((s) => {
881
- const takes = stashTakes.filter((t) => t.stash === s.key);
882
- const fills = stashFills.filter((t) => t.stash === s.key);
1322
+ for (const r of m.recipes)
1323
+ for (const l of r.in ?? [])
1324
+ if (l.takes === 'trade' && l.then?.stash)
1325
+ stashFills.push({ stash: l.then.stash, perDay: perDay('recipe', r.key), by: `recipe:${r.key}` });
1326
+ for (const l of m.lotteries)
1327
+ if (l.ticketNft?.then?.stash)
1328
+ stashFills.push({ stash: l.ticketNft.then.stash, perDay: perDay('lottery', l.key), by: `lottery:${l.key}` });
1329
+ const stashes = m.stashes.map(s => {
1330
+ const takes = stashTakes.filter(t => t.stash === s.key);
1331
+ const fills = stashFills.filter(t => t.stash === s.key);
883
1332
  const perDay = takes.reduce((n, t) => n + t.perDay, 0);
884
1333
  const inPerDay = fills.reduce((n, t) => n + t.perDay, 0);
885
1334
  const holds = (s.nfts ?? []).length;
886
1335
  const net = perDay - inPerDay;
887
- const filled = inPerDay > 0 ? `, and takes in about ${round(inPerDay, 4)} a day (${[...new Set(fills.map((t) => t.by))].join(', ')})` : '';
888
- if (perDay > 0 && net > 0) notes.push(`The stash “${s.name}” starts with ${holds} NFT${holds === 1 ? '' : 's'} and gives about ${round(perDay, 4)} a day (${[...new Set(takes.map((t) => t.by))].join(', ')})${filled}: ${holds ? `empty in about ${round(holds / net, 2)} days` : 'empty from the start'}, after which a draw that lands on its outcome is not made, and gives back that draw’s worth, until more are put in.`);
889
- else if (perDay > 0) notes.push(`The stash “${s.name}” starts with ${holds} NFT${holds === 1 ? '' : 's'}, gives about ${round(perDay, 4)} a day (${[...new Set(takes.map((t) => t.by))].join(', ')})${filled}: it keeps up${holds ? '' : ', once the first come in (until then a draw that lands on its outcome is not made)'}.`);
890
- return { key: s.key, name: s.name, holds, givenPerDay: round(perDay, 6), ...(inPerDay > 0 ? { takenInPerDay: round(inPerDay, 6) } : {}), ...(net > 0 ? { lastsDays: round(holds / net, 4) } : {}) };
1336
+ const filled =
1337
+ inPerDay > 0
1338
+ ? `, and takes in about ${round(inPerDay, 4)} a day (${[...new Set(fills.map(t => t.by))].join(', ')})`
1339
+ : '';
1340
+ if (perDay > 0 && net > 0)
1341
+ notes.push(
1342
+ `The stash “${s.name}” starts with ${holds} NFT${holds === 1 ? '' : 's'} and gives about ${round(perDay, 4)} a day (${[...new Set(takes.map(t => t.by))].join(', ')})${filled}: ${holds ? `empty in about ${round(holds / net, 2)} days` : 'empty from the start'}, after which a draw that lands on its outcome is not made, and gives back that draw’s worth, until more are put in.`,
1343
+ );
1344
+ else if (perDay > 0)
1345
+ notes.push(
1346
+ `The stash “${s.name}” starts with ${holds} NFT${holds === 1 ? '' : 's'}, gives about ${round(perDay, 4)} a day (${[...new Set(takes.map(t => t.by))].join(', ')})${filled}: it keeps up${holds ? '' : ', once the first come in (until then a draw that lands on its outcome is not made)'}.`,
1347
+ );
1348
+ return {
1349
+ key: s.key,
1350
+ name: s.name,
1351
+ holds,
1352
+ givenPerDay: round(perDay, 6),
1353
+ ...(inPerDay > 0 ? { takenInPerDay: round(inPerDay, 6) } : {}),
1354
+ ...(net > 0 ? { lastsDays: round(holds / net, 4) } : {}),
1355
+ };
891
1356
  });
892
- const contestRolls = m.contests.filter((c) => prizeLine(c.prize)?.gives === 'roll');
893
- if (contestRolls.length) notes.push(`${contestRolls.map((c) => `“${c.name}”`).join(', ')} give${contestRolls.length === 1 ? 's' : ''} every winning entry a roll: whoever claims it pays the roll’s order (about ${round(orderHeld(prizeLine(contestRolls[0].prize).box), 4)} SOL, back to the winner at settle), and whoever asks for the roll pays the oracle.`);
1357
+ const contestRolls = m.contests.filter(c => prizeLine(c.prize)?.gives === 'roll');
1358
+ if (contestRolls.length)
1359
+ notes.push(
1360
+ `${contestRolls.map(c => `“${c.name}”`).join(', ')} give${contestRolls.length === 1 ? 's' : ''} every winning entry a roll: whoever claims it pays the roll’s order (about ${round(orderHeld(prizeLine(contestRolls[0].prize).box), 4)} SOL, back to the winner at settle), and whoever asks for the roll pays the oracle.`,
1361
+ );
894
1362
  for (const t of m.tokens.values())
895
- if (t.powers?.transferFeePercent > 0) notes.push(`${t.symbol} keeps ${t.powers.transferFeePercent}% of every transfer: pools, prizes, stakes and the treasury receive that much less than shown here.`);
1363
+ if (t.powers?.transferFeePercent > 0)
1364
+ notes.push(
1365
+ `${t.symbol} keeps ${t.powers.transferFeePercent}% of every transfer: pools, prizes, stakes and the treasury receive that much less than shown here.`,
1366
+ );
896
1367
 
897
- const totals = (map) => Object.fromEntries(Object.entries(map).map(([k, v]) => [k, round(v)]));
1368
+ const totals = map => Object.fromEntries(Object.entries(map).map(([k, v]) => [k, round(v)]));
898
1369
  return {
899
- scenario: { days, perDay: m.scenario.perDay ?? {}, defaultPerDay: DEFAULT_PER_DAY, defaultBidsPerDay: DEFAULT_BIDS, defaultClaimsPerDay: 0 },
1370
+ scenario: {
1371
+ days,
1372
+ perDay: m.scenario.perDay ?? {},
1373
+ defaultPerDay: DEFAULT_PER_DAY,
1374
+ defaultBidsPerDay: DEFAULT_BIDS,
1375
+ defaultClaimsPerDay: 0,
1376
+ },
900
1377
  boxes,
901
1378
  items,
902
1379
  payments,
@@ -910,24 +1387,50 @@ export function simulate(economy, { now = 0 } = {}) {
910
1387
  stashes,
911
1388
  pools: poolRows,
912
1389
  perDay: {
913
- players: { pay: totals(players.pay), getBack: totals(players.get), playerFeesSol: round(players.fees), oracleFeesSol: round(players.oracle), networkFeesSol: round(players.network), rentSol: round(players.rent) },
914
- creator: { treasury: totals(creator.treasury), projectFeesSol: round(creator.fees), oracleFeesSol: round(creator.oracle), networkFeesSol: round(creator.network), grantRentSol: round(creator.rent ?? 0) },
1390
+ players: {
1391
+ pay: totals(players.pay),
1392
+ getBack: totals(players.get),
1393
+ playerFeesSol: round(players.fees),
1394
+ oracleFeesSol: round(players.oracle),
1395
+ networkFeesSol: round(players.network),
1396
+ rentSol: round(players.rent),
1397
+ },
1398
+ creator: {
1399
+ treasury: totals(creator.treasury),
1400
+ projectFeesSol: round(creator.fees),
1401
+ oracleFeesSol: round(creator.oracle),
1402
+ networkFeesSol: round(creator.network),
1403
+ grantRentSol: round(creator.rent ?? 0),
1404
+ },
915
1405
  oracle: { totalSol: round(players.oracle + creator.oracle) },
916
- platform: { playerFeesSol: round(platform.player), projectFeesSol: round(platform.project), totalSol: round(platform.player + platform.project) },
1406
+ platform: {
1407
+ playerFeesSol: round(platform.player),
1408
+ projectFeesSol: round(platform.project),
1409
+ totalSol: round(platform.player + platform.project),
1410
+ },
917
1411
  burned: totals(burned),
918
1412
  accounts: totals(accounts),
919
1413
  },
920
- fees: { playerSol: PLAYER_FEE, projectSol: PROJECT_FEE, oracleSol: ORACLE, networkPerSignatureSol: NETWORK_FEE, settleRentUnitSol: sol(SETTLE_RENT_UNIT), ticketLineRentSol: round(ticketLine({})), collectibleRentSol: COLLECTIBLE_RENT, burnRefundSol: BURN_REFUND },
1414
+ fees: {
1415
+ playerSol: PLAYER_FEE,
1416
+ projectSol: PROJECT_FEE,
1417
+ oracleSol: ORACLE,
1418
+ networkPerSignatureSol: NETWORK_FEE,
1419
+ settleRentUnitSol: sol(SETTLE_RENT_UNIT),
1420
+ ticketLineRentSol: round(ticketLine({})),
1421
+ collectibleRentSol: COLLECTIBLE_RENT,
1422
+ burnRefundSol: BURN_REFUND,
1423
+ },
921
1424
  notes,
922
1425
  };
923
1426
  }
924
1427
 
925
1428
  /** What the scenario doesn't name and doesn't count at its default, in words: ["grant claims none", "auction bids 10 a day"]. */
926
1429
  export function unnamedExceptions(sim) {
927
- const named = (k) => Object.hasOwn(sim.scenario.perDay ?? {}, k);
1430
+ const named = k => Object.hasOwn(sim.scenario.perDay ?? {}, k);
928
1431
  return [
929
- (sim.grants ?? []).some((g) => !named(`grant:${g.key}`)) && 'grant claims none',
930
- (sim.auctions ?? []).some((a) => !named(`auction:${a.key}`)) && `auction bids ${DEFAULT_BIDS} a day`,
1432
+ (sim.grants ?? []).some(g => !named(`grant:${g.key}`)) && 'grant claims none',
1433
+ (sim.auctions ?? []).some(a => !named(`auction:${a.key}`)) && `auction bids ${DEFAULT_BIDS} a day`,
931
1434
  ].filter(Boolean);
932
1435
  }
933
1436
  /** What the scenario assumes for what it doesn't name, in words: "100 a day (grant claims none, auction bids 10 a day)". */
@@ -938,10 +1441,11 @@ export function unnamedWords(sim) {
938
1441
 
939
1442
  /** What becomes of a pool (a row of `simulate`'s pools) over the scenario's `days`, in words: "settles at about 40". */
940
1443
  export function poolFate(p, days) {
941
- const n = (x) => String(round(x, 6));
1444
+ const n = x => String(round(x, 6));
942
1445
  if (p.lasts === 'runs dry') return `runs dry in about ${n(p.lastsDays)} days`;
943
1446
  if (p.lasts === 'settles') return `settles at about ${n(p.settlesAt)}`;
944
- if (p.lasts === 'shrinks') return `shrinks to about ${n(p.after)} after ${days} days (half gone every ${n(p.halfLifeDays)} days)`;
1447
+ if (p.lasts === 'shrinks')
1448
+ return `shrinks to about ${n(p.after)} after ${days} days (half gone every ${n(p.halfLifeDays)} days)`;
945
1449
  if (p.lasts === 'grows') return `grows to about ${n(p.after)} after ${days} days`;
946
1450
  return 'holds steady';
947
1451
  }
@@ -952,68 +1456,157 @@ export function poolFate(p, days) {
952
1456
  */
953
1457
  export function inAll(c) {
954
1458
  if (!c) return '';
955
- const n = (x) => String(round(x, 6));
956
- const parts = ['the player fee', c.oracleSol ? 'the oracle' : '', 'the network', c.rentSol ? (/comes back/.test(c.rentIs) ? c.rentIs : `${c.rentIs}, which never comes back`) : ''].filter(Boolean).join(', ');
957
- const head = c.totalSol !== undefined ? `about ${n(c.totalSol)} SOL in all${c.atLeast ? ' at the least amount' : ''} (${c.priceBack ? '' : 'the price, '}${parts})` : `${n(c.extrasSol)} SOL on top of the price (${parts})`;
958
- return c.holdSol > (c.totalSol ?? c.extrasSol) + 1e-9 ? `${head}; the wallet needs about ${n(c.holdSol)} SOL when it signs, and the rest${c.heldIs ? ` (${c.heldIs})` : ''} comes back` : head;
1459
+ const n = x => String(round(x, 6));
1460
+ const parts = [
1461
+ 'the player fee',
1462
+ c.oracleSol ? 'the oracle' : '',
1463
+ 'the network',
1464
+ c.rentSol ? (/comes back/.test(c.rentIs) ? c.rentIs : `${c.rentIs}, which never comes back`) : '',
1465
+ ]
1466
+ .filter(Boolean)
1467
+ .join(', ');
1468
+ const head =
1469
+ c.totalSol !== undefined
1470
+ ? `about ${n(c.totalSol)} SOL in all${c.atLeast ? ' at the least amount' : ''} (${c.priceBack ? '' : 'the price, '}${parts})`
1471
+ : `${n(c.extrasSol)} SOL on top of the price (${parts})`;
1472
+ return c.holdSol > (c.totalSol ?? c.extrasSol) + 1e-9
1473
+ ? `${head}; the wallet needs about ${n(c.holdSol)} SOL when it signs, and the rest${c.heldIs ? ` (${c.heldIs})` : ''} comes back`
1474
+ : head;
959
1475
  }
960
1476
 
961
1477
  /** The simulation in plain words. */
962
1478
  export function explain(sim, economy) {
963
1479
  const m = model(economy);
964
- const sym = (key) => m.tokens.get(key)?.symbol ?? key;
1480
+ const sym = key => m.tokens.get(key)?.symbol ?? key;
965
1481
  const lines = [];
966
- const n = (x) => (Number.isFinite(x) ? String(round(x, 6)) : String(x));
967
- lines.push(`Assuming ${Object.entries(sim.scenario.perDay).map(([k, v]) => `${v} × ${k}`).join(', ') || 'nothing in particular'} a day (anything not named: ${unnamedWords(sim)}), over ${sim.scenario.days} days.`);
1482
+ const n = x => (Number.isFinite(x) ? String(round(x, 6)) : String(x));
1483
+ lines.push(
1484
+ `Assuming ${
1485
+ Object.entries(sim.scenario.perDay)
1486
+ .map(([k, v]) => `${v} × ${k}`)
1487
+ .join(', ') || 'nothing in particular'
1488
+ } a day (anything not named: ${unnamedWords(sim)}), over ${sim.scenario.days} days.`,
1489
+ );
968
1490
  for (const b of sim.boxes) {
969
1491
  const edge = b.houseEdgePercent !== null ? `; the house keeps ${n(b.houseEdgePercent)}% of each price` : '';
970
1492
  const also = Object.entries(b.alsoPays).map(([t, v]) => `${n(v)} ${sym(t)}`);
971
- const back = b.returnPercent !== null ? `${n(b.expectedBack)} ${sym(b.token)} on average (${n(b.returnPercent)}%)${also.length ? ` and ${also.join(', ')}` : ''}` : `${also.join(', ') || 'nothing'} on average`;
1493
+ const back =
1494
+ b.returnPercent !== null
1495
+ ? `${n(b.expectedBack)} ${sym(b.token)} on average (${n(b.returnPercent)}%)${also.length ? ` and ${also.join(', ')}` : ''}`
1496
+ : `${also.join(', ') || 'nothing'} on average`;
972
1497
  const all = `; ${inAll(b.realCost)}`;
973
1498
  lines.push(`Box “${b.name}”: costs ${n(b.price)} ${sym(b.token)} (${all.slice(2)}), gives back ${back}${edge}.`);
974
- const part = (o) => (o.gives === 'pay' ? `${n(o.amount)} ${sym(o.token)} from ${o.pool}` : o.gives === 'mint' ? `${n(o.amount)} ${sym(o.token)}, minted` : o.gives === 'stash' ? `an NFT of the stash “${o.stash}”, at random` : o.gives === 'nft' ? `the NFT ${String(o.nft).slice(0, 4)}… of “${o.stash}”` : o.gives === 'roll' ? `a roll of “${o.box}”` : o.gives === 'item' ? `${o.item}${o.worth !== null ? ` (worth ${n(o.worth)})` : ' (no value given)'}` : o.gives);
975
- for (const o of b.outcomes) lines.push(` ${n(o.chance)}%: ${o.gives === 'bundle' ? o.prizes.map(part).join(' + ') : part(o)}`);
1499
+ const part = o =>
1500
+ o.gives === 'pay'
1501
+ ? `${n(o.amount)} ${sym(o.token)} from ${o.pool}`
1502
+ : o.gives === 'mint'
1503
+ ? `${n(o.amount)} ${sym(o.token)}, minted`
1504
+ : o.gives === 'stash'
1505
+ ? `an NFT of the stash “${o.stash}”, at random`
1506
+ : o.gives === 'nft'
1507
+ ? `the NFT ${String(o.nft).slice(0, 4)}… of “${o.stash}”`
1508
+ : o.gives === 'roll'
1509
+ ? `a roll of “${o.box}”`
1510
+ : o.gives === 'item'
1511
+ ? `${o.item}${o.worth !== null ? ` (worth ${n(o.worth)})` : ' (no value given)'}`
1512
+ : o.gives;
1513
+ for (const o of b.outcomes)
1514
+ lines.push(` ${n(o.chance)}%: ${o.gives === 'bundle' ? o.prizes.map(part).join(' + ') : part(o)}`);
976
1515
  if (b.unpriced.length) lines.push(` Give ${b.unpriced.join(', ')} a "value" to count them in the return.`);
977
1516
  if (b.limitedStock) lines.push(` ${whenOutWords(m.box.get(b.key))}.`);
978
1517
  }
979
1518
  for (const it of sim.items) lines.push(`Shop “${it.name}”: ${n(it.price)} ${sym(it.token)}; ${inAll(it.realCost)}.`);
980
- for (const p of sim.payments) lines.push(`Payment “${p.name}”: ${p.atLeast ? 'at least ' : ''}${n(p.amount)} ${sym(p.token)}; ${inAll(p.realCost)}.`);
1519
+ for (const p of sim.payments)
1520
+ lines.push(
1521
+ `Payment “${p.name}”: ${p.atLeast ? 'at least ' : ''}${n(p.amount)} ${sym(p.token)}; ${inAll(p.realCost)}.`,
1522
+ );
981
1523
  for (const l of sim.lotteries) {
982
- const rounds = l.drawsOnce ? `${n(l.ticketsPerRound)} tickets in its one round of ${n(l.roundDays)} days (it draws once)` : `${n(l.ticketsPerRound)} tickets a round of ${n(l.roundDays)} days`;
983
- lines.push(`Lottery “${l.name}”: ${rounds}; the pot reaches about ${n(l.potBeforeDraw)} ${sym(l.potToken)} and pays ${l.prizes.map((v, i) => `${n(v)}${l.placePrizes?.[i] ? ` and ${l.placePrizes[i]}` : ''}`).join(' / ')}; ${l.ticketNft ? (l.noLoss ? 'a ticket (one NFT, back to its buyer after the draw)' : 'a ticket (one NFT, spent at once, never refunded)') : `a ${n(l.price)} ${sym(l.token)} ticket${l.noLoss ? ' (back to its buyer after the draw)' : ''}`} ${l.noLoss ? 'wins' : 'is worth'} ${n(l.expectedPerTicket)} ${sym(l.potToken)} on average${l.noLoss ? ' on top' : ''}${l.unpricedPrizes ? ', plus its chance at the places’ prizes (no price given)' : ''}${l.returnPercent !== null ? ` (${n(l.returnPercent)}%${l.noLoss ? ' back, the ticket included' : ''})` : ''}; one ticket bought alone: ${inAll(l.realCost)}.`);
984
- if (l.minSold) lines.push(` Fewer than ${l.minSold} tickets sold by the draw: the round is cancelled and every ticket refunded.`);
1524
+ const rounds = l.drawsOnce
1525
+ ? `${n(l.ticketsPerRound)} tickets in its one round of ${n(l.roundDays)} days (it draws once)`
1526
+ : `${n(l.ticketsPerRound)} tickets a round of ${n(l.roundDays)} days`;
1527
+ const from =
1528
+ l.rest !== undefined
1529
+ ? `each round pays from its own ticket money, about ${n(l.potBeforeDraw)} ${sym(l.potToken)}, what its places leave going to “${l.rest}”,`
1530
+ : `the pot reaches about ${n(l.potBeforeDraw)} ${sym(l.potToken)} and`;
1531
+ lines.push(
1532
+ `Lottery “${l.name}”: ${rounds}; ${from} pays ${l.prizes.map((v, i) => `${n(v)}${l.placePrizes?.[i] ? ` and ${l.placePrizes[i]}` : ''}`).join(' / ')}; ${l.ticketNft ? (l.noLoss ? 'a ticket (one NFT, back to its buyer after the draw)' : 'a ticket (one NFT, spent at once, never refunded)') : `a ${n(l.price)} ${sym(l.token)} ticket${l.noLoss ? ' (back to its buyer after the draw)' : ''}`} ${l.noLoss ? 'wins' : 'is worth'} ${n(l.expectedPerTicket)} ${sym(l.potToken)} on average${l.noLoss ? ' on top' : ''}${l.unpricedPrizes ? ', plus its chance at the places’ prizes (no price given)' : ''}${l.returnPercent !== null ? ` (${n(l.returnPercent)}%${l.noLoss ? ' back, the ticket included' : ''})` : ''}; one ticket bought alone: ${inAll(l.realCost)}.`,
1533
+ );
1534
+ if (l.minSold)
1535
+ lines.push(
1536
+ ` Fewer than ${l.minSold} tickets sold by the draw: the round is cancelled and every ticket refunded.`,
1537
+ );
985
1538
  }
986
1539
  for (const f of sim.farms) {
987
- lines.push(`Farm “${f.name}”: ${n(f.rewardsPerDay)} ${sym(f.rewardsToken)} a day to everyone staked${f.perUnitPerDay !== undefined ? `, ${n(f.perUnitPerDay)} a day for each ${f.unit} staked` : ''}${f.yearlyPercent !== undefined ? ` (${n(f.yearlyPercent)}% a year)` : ''}; ${f.says}.`);
1540
+ lines.push(
1541
+ `Farm “${f.name}”: ${n(f.rewardsPerDay)} ${sym(f.rewardsToken)} a day to everyone staked${f.perUnitPerDay !== undefined ? `, ${n(f.perUnitPerDay)} a day for each ${f.unit} staked` : ''}${f.yearlyPercent !== undefined ? ` (${n(f.yearlyPercent)}% a year)` : ''}; ${f.says}.`,
1542
+ );
988
1543
  if (f.extras) lines.push(` ${f.extras[0].toUpperCase()}${f.extras.slice(1)}.`);
989
- for (const g of f.growth ?? []) lines.push(` Growth: ${g.perDay} ${g.attribute} a day at x1${g.levels ? `; ${g.levels.map((l) => `level ${l.level} after ${n(l.days)} days`).join(', ')}` : ''}${g.streak ? ', back to 0 when it leaves' : ''}.`);
990
- if (f.runs) lines.push(` Runs ${f.runs} from when it goes live: it takes no stake at its end or after it, and pays and grows nothing more${f.end ? `; ${f.end}` : ''}.`);
991
- if (f.lockAttribute) lines.push(` Its lock is written on each collectible under “${f.lockAttribute}”: whatever moves that time lets it out sooner or keeps it longer.`);
992
- if (f.upkeepAttribute) lines.push(` Upkeep: each collectible earns and grows only until the time under “${f.upkeepAttribute}”; the figures assume every one is kept topped up.`);
993
- for (const t of f.tiers.filter((t) => t.prize)) lines.push(` Mission (lock ${t.lock}): ${t.prize} for each one that stays it (tokens grown by its boosts).`);
1544
+ for (const g of f.growth ?? [])
1545
+ lines.push(
1546
+ ` Growth: ${g.perDay} ${g.attribute} a day at x1${g.levels ? `; ${g.levels.map(l => `level ${l.level} after ${n(l.days)} days`).join(', ')}` : ''}${g.streak ? ', back to 0 when it leaves' : ''}.`,
1547
+ );
1548
+ if (f.runs)
1549
+ lines.push(
1550
+ ` Runs ${f.runs} from when it goes live: it takes no stake at its end or after it, and pays and grows nothing more${f.end ? `; ${f.end}` : ''}.`,
1551
+ );
1552
+ if (f.lockAttribute)
1553
+ lines.push(
1554
+ ` Its lock is written on each collectible under “${f.lockAttribute}”: whatever moves that time lets it out sooner or keeps it longer.`,
1555
+ );
1556
+ if (f.upkeepAttribute)
1557
+ lines.push(
1558
+ ` Upkeep: each collectible earns and grows only until the time under “${f.upkeepAttribute}”; the figures assume every one is kept topped up.`,
1559
+ );
1560
+ for (const t of f.tiers.filter(t => t.prize))
1561
+ lines.push(` Mission (lock ${t.lock}): ${t.prize} for each one that stays it (tokens grown by its boosts).`);
994
1562
  }
995
- for (const g of sim.grants) lines.push(`Grant “${g.name}”: ${n(g.claimsPerDay)} claims a day, each giving ${g.gives}; each claim costs its funds about ${n(g.fundsPerClaimSol)} SOL${g.orderHeldSol ? ` (${n(g.orderHeldSol)} of it the roll’s order, which goes to the winner when it is settled; whoever asks for the roll pays the oracle)` : ''}.`);
996
- for (const s of sim.stashes ?? []) lines.push(`Stash “${s.name}”: ${s.holds} NFT${s.holds === 1 ? '' : 's'} to start${s.takenInPerDay ? `, about ${n(s.takenInPerDay)} a day come in` : ''}${s.lastsDays !== undefined ? `, given at about ${n(s.givenPerDay)} a day: lasts about ${n(s.lastsDays)} days` : s.givenPerDay ? `, given at about ${n(s.givenPerDay)} a day` : ''}.`);
997
- for (const r of sim.recipes) lines.push(`Recipe “${r.name}”: ${r.takes.join(' + ') || 'nothing'} → ${r.gives.join(' + ') || 'nothing'}, ${n(r.craftsPerDay)} a day.`);
998
- for (const s of sim.schedules) lines.push(`Schedule “${s.key}”: ${s.says}; ${lateRunWords(m.schedules.find((x) => x.key === s.key) ?? {})}.`);
1563
+ for (const g of sim.grants)
1564
+ lines.push(
1565
+ `Grant “${g.name}”: ${n(g.claimsPerDay)} claims a day, each giving ${g.gives}; each claim costs its funds about ${n(g.fundsPerClaimSol)} SOL${g.orderHeldSol ? ` (${n(g.orderHeldSol)} of it the roll’s order, which goes to the winner when it is settled; whoever asks for the roll pays the oracle)` : ''}.${g.many ? ` One wallet may claim ${n(g.many.one)} ${g.many.per}; ${MANY_WALLETS} wallets ${g.many.who} could claim ${n(g.many.all)} ${g.many.per}${g.many.capped ? `, its "${g.many.per === 'a day' ? 'perDay' : 'total'}" the cap` : ''}${g.many.perDay ? `, at most ${n(g.many.perDay)} a day` : ''}.` : ''}`,
1566
+ );
1567
+ for (const s of sim.stashes ?? [])
1568
+ lines.push(
1569
+ `Stash “${s.name}”: ${s.holds} NFT${s.holds === 1 ? '' : 's'} to start${s.takenInPerDay ? `, about ${n(s.takenInPerDay)} a day come in` : ''}${s.lastsDays !== undefined ? `, given at about ${n(s.givenPerDay)} a day: lasts about ${n(s.lastsDays)} days` : s.givenPerDay ? `, given at about ${n(s.givenPerDay)} a day` : ''}.`,
1570
+ );
1571
+ for (const r of sim.recipes)
1572
+ lines.push(
1573
+ `Recipe “${r.name}”: ${r.takes.join(' + ') || 'nothing'} → ${r.gives.join(' + ') || 'nothing'}, ${n(r.craftsPerDay)} a day.`,
1574
+ );
1575
+ for (const s of sim.schedules)
1576
+ lines.push(`Schedule “${s.key}”: ${s.says}; ${lateRunWords(m.schedules.find(x => x.key === s.key) ?? {})}.`);
999
1577
  for (const c of sim.contests) lines.push(`Contest “${c.name}”: ${c.says}.`);
1000
1578
  for (const a of sim.auctions ?? []) lines.push(`Auction “${a.name}”: ${a.says}.`);
1001
- for (const p of sim.pools) lines.push(`${p.pot ? `The pot of “${p.pot}”` : p.farm ? `The rewards vault of “${p.farm}”` : `Pool “${p.key}”`}: ${n(p.inPerDay)} ${sym(p.token)} in and ${n(p.outPerDay)} out a day from a seed of ${n(p.seed)}; it ${poolFate(p, sim.scenario.days)}.`);
1579
+ for (const p of sim.pools)
1580
+ lines.push(
1581
+ `${p.pot ? `The pot of “${p.pot}”` : p.farm ? `The rewards vault of “${p.farm}”` : `Pool “${p.key}”`}: ${n(p.inPerDay)} ${sym(p.token)} in and ${n(p.outPerDay)} out a day from a seed of ${n(p.seed)}; it ${poolFate(p, sim.scenario.days)}.`,
1582
+ );
1002
1583
  const d = sim.perDay;
1003
- const list = (map) => Object.entries(map).map(([k, v]) => `${n(v)} ${sym(k)}`).join(', ') || 'nothing';
1004
- lines.push(`Each day players pay ${list(d.players.pay)} and get back ${list(d.players.getBack)} on average (items at the value you gave them), plus ${n(d.players.playerFeesSol)} SOL in player fees, ${n(d.players.oracleFeesSol)} SOL to MagicBlock’s oracle and ${n(d.players.networkFeesSol)} SOL in network fees.`);
1584
+ const list = map =>
1585
+ Object.entries(map)
1586
+ .map(([k, v]) => `${n(v)} ${sym(k)}`)
1587
+ .join(', ') || 'nothing';
1588
+ lines.push(
1589
+ `Each day players pay ${list(d.players.pay)} and get back ${list(d.players.getBack)} on average (items at the value you gave them), plus ${n(d.players.playerFeesSol)} SOL in player fees, ${n(d.players.oracleFeesSol)} SOL to MagicBlock’s oracle and ${n(d.players.networkFeesSol)} SOL in network fees.`,
1590
+ );
1005
1591
  if (d.creator.oracleFeesSol) {
1006
1592
  // Every roll the simulation counts as the project's: a draw, a roll a lottery's place or a grant gives, the authority's.
1007
1593
  const what = [
1008
1594
  sim.lotteries.length && 'lottery draws',
1009
- m.lotteries.some((l) => (l.places ?? []).some((p) => placeOf(p).prize?.gives === 'roll')) && 'the rolls lottery places give',
1010
- sim.grants.some((g) => g.claimsPerDay > 0 && prizeLine(m.grant.get(g.key)?.prize ?? m.grant.get(g.key)?.gives)?.gives === 'roll') && 'the rolls grants give',
1011
- m.decisions.some((x) => x.does === 'roll' && Number(x.perDay) > 0) && 'the authority’s rolls',
1595
+ m.lotteries.some(l => (l.places ?? []).some(p => placeOf(p).prize?.gives === 'roll')) &&
1596
+ 'the rolls lottery places give',
1597
+ sim.grants.some(
1598
+ g => g.claimsPerDay > 0 && prizeLine(m.grant.get(g.key)?.prize ?? m.grant.get(g.key)?.gives)?.gives === 'roll',
1599
+ ) && 'the rolls grants give',
1600
+ m.decisions.some(x => x.does === 'roll' && Number(x.perDay) > 0) && 'the authority’s rolls',
1012
1601
  ].filter(Boolean);
1013
- const words = what.length > 1 ? `${what.slice(0, -1).join(', ')} and ${what.at(-1)}` : what[0] ?? 'its rolls';
1014
- lines.push(`The oracle also takes about ${n(d.creator.oracleFeesSol)} SOL a day for ${words}, from whoever asks for them (the game’s keeper or the authority).`);
1602
+ const words = what.length > 1 ? `${what.slice(0, -1).join(', ')} and ${what.at(-1)}` : (what[0] ?? 'its rolls');
1603
+ lines.push(
1604
+ `The oracle also takes about ${n(d.creator.oracleFeesSol)} SOL a day for ${words}, from whoever asks for them: anyone may (a player's page, or a ticket bought once a draw is due, sends it).`,
1605
+ );
1015
1606
  }
1016
- lines.push(`The treasury gets ${list(d.creator.treasury)} a day; burned: ${list(d.burned)}. The platform’s fees: ${n(d.platform.totalSol)} SOL a day.`);
1607
+ lines.push(
1608
+ `The treasury gets ${list(d.creator.treasury)} a day; burned: ${list(d.burned)}. The platform’s fees: ${n(d.platform.totalSol)} SOL a day.`,
1609
+ );
1017
1610
  for (const note of sim.notes) lines.push(note);
1018
1611
  return lines.join('\n');
1019
1612
  }