@xgamesfun/solana 0.20.1 → 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 (112) hide show
  1. package/README.md +95 -52
  2. package/bin/xsolana.mjs +23 -8
  3. package/examples/blind-forge.json +2 -4
  4. package/examples/coin-flip-arena.json +4 -12
  5. package/examples/duel-arena.json +1 -4
  6. package/examples/elements-set.json +2 -9
  7. package/examples/expedition-guild.json +1 -8
  8. package/examples/fair-presale.json +1 -3
  9. package/examples/guild-missions.json +3 -10
  10. package/examples/halving-seasons.json +4 -9
  11. package/examples/hero-trials.json +15 -6
  12. package/examples/holder-den.json +38 -0
  13. package/examples/holders-drop.json +105 -0
  14. package/examples/ideas.json +788 -788
  15. package/examples/match-predictions.json +24 -0
  16. package/examples/merge-2048.json +5 -5
  17. package/examples/nft-raffle.json +1 -0
  18. package/examples/pack-league.json +108 -0
  19. package/examples/paper-hands-farm.json +4 -16
  20. package/examples/partner-vault.json +20 -7
  21. package/examples/sacrifice-raffle.json +2 -10
  22. package/examples/shelter-fair.json +1 -0
  23. package/examples/stakers-draw.json +4 -15
  24. package/examples/star-fleet.json +1 -8
  25. package/examples/starforce.json +6 -6
  26. package/examples/stride-sneakers.json +164 -0
  27. package/examples/trade-in-shop.json +2 -3
  28. package/js/approve/details.mjs +122 -52
  29. package/js/approve/ed25519.mjs +5 -1
  30. package/js/approve/live-check.mjs +83 -26
  31. package/js/approve/page.html +217 -78
  32. package/js/approve/page.js +43 -15
  33. package/js/approve/server.mjs +290 -73
  34. package/js/chain/base58.js +7 -1
  35. package/js/chain/borsh.js +24 -11
  36. package/js/chain/bytes.js +11 -8
  37. package/js/chain/core.js +35 -7
  38. package/js/chain/curve.js +1 -1
  39. package/js/chain/lookup.js +124 -36
  40. package/js/chain/mint.js +6 -2
  41. package/js/chain/pda.js +2 -2
  42. package/js/chain/programs.js +6 -6
  43. package/js/chain/rpc.js +44 -16
  44. package/js/chain/token.js +13 -2
  45. package/js/chain/token2022.js +55 -16
  46. package/js/chain/tx.js +32 -22
  47. package/js/client/accounts.js +653 -131
  48. package/js/client/compressed.js +4 -3
  49. package/js/client/grant.js +267 -83
  50. package/js/client/idl/loot.js +1 -1
  51. package/js/client/idl/loot.json +2406 -2176
  52. package/js/client/index.js +1 -0
  53. package/js/client/ix.js +3059 -664
  54. package/js/client/pda.js +39 -33
  55. package/js/client/policy.js +52 -24
  56. package/js/client/random.js +70 -0
  57. package/js/client/sizes.js +414 -16
  58. package/js/client/traits.js +14 -5
  59. package/js/connect/connect.mjs +59 -17
  60. package/js/economy/check.mjs +4153 -725
  61. package/js/economy/cost.mjs +403 -64
  62. package/js/economy/examples.mjs +176 -35
  63. package/js/economy/explain.mjs +466 -111
  64. package/js/economy/guide.mjs +296 -72
  65. package/js/economy/legal.mjs +12 -6
  66. package/js/economy/live.mjs +83 -19
  67. package/js/economy/model.mjs +462 -124
  68. package/js/economy/plan.mjs +1306 -219
  69. package/js/economy/schema.json +390 -484
  70. package/js/economy/secrets.mjs +17 -7
  71. package/js/economy/simulate.mjs +883 -223
  72. package/js/economy/txsize.mjs +15 -3
  73. package/js/economy/validate.mjs +61 -24
  74. package/js/mcp/server.mjs +42 -14
  75. package/js/mcp/tools.mjs +307 -88
  76. package/js/play/index.js +2864 -619
  77. package/js/play/keys.js +193 -0
  78. package/js/results/index.js +223 -30
  79. package/js/results/server.js +230 -0
  80. package/js/results/wire.js +64 -16
  81. package/package.json +6 -4
  82. package/release.json +5 -5
  83. package/skills/xsolana-economy/SKILL.md +39 -21
  84. package/skills/xsolana-economy/references/after-launch.md +58 -38
  85. package/skills/xsolana-economy/references/apps.md +97 -50
  86. package/skills/xsolana-economy/references/sentence-to-spec.md +148 -121
  87. package/skills/xsolana-economy/references/telling-the-person.md +61 -17
  88. package/types/chain/core.d.ts +4 -2
  89. package/types/chain/lookup.d.ts +5 -2
  90. package/types/chain/rpc.d.ts +3 -1
  91. package/types/client/accounts.d.ts +217 -56
  92. package/types/client/grant.d.ts +72 -27
  93. package/types/client/idl/loot.d.ts +602 -549
  94. package/types/client/index.d.ts +1 -0
  95. package/types/client/ix.d.ts +3884 -247
  96. package/types/client/pda.d.ts +22 -16
  97. package/types/client/policy.d.ts +39 -14
  98. package/types/client/random.d.ts +26 -0
  99. package/types/client/sizes.d.ts +93 -5
  100. package/types/economy/check.d.mts +5 -10
  101. package/types/economy/cost.d.mts +2 -1
  102. package/types/economy/guide.d.mts +5 -0
  103. package/types/economy/live.d.mts +16 -1
  104. package/types/economy/model.d.mts +151 -38
  105. package/types/economy/plan.d.mts +237 -194
  106. package/types/economy/simulate.d.mts +21 -13
  107. package/types/mcp/tools.d.mts +21 -13
  108. package/types/play/index.d.ts +453 -53
  109. package/types/play/keys.d.ts +51 -0
  110. package/types/results/index.d.ts +7 -6
  111. package/types/results/server.d.ts +126 -0
  112. package/types/results/wire.d.ts +1 -1
@@ -4,35 +4,89 @@
4
4
  //
5
5
  // It assumes the scenario's counts (`scenario.perDay`, default 100 of each player action a day) arrive evenly,
6
6
  // odds as written (stock odds start where the table starts), and one ticket per lottery purchase. Shares of a
7
- // pool are worked out continuously, so a pool that pays a share of itself settles at a level instead of emptying.
8
- // It is a guide, not a promise: the full simulation (js/sim) runs the real program.
9
- import { ACCOUNT_BYTES, receiptBytes } from '../client/index.js';
10
- import { boxBuys, boxRolls, COLLECTIBLE_RENT as COLLECTIBLE_RENT_LAMPORTS, outcomeBurns, outcomeParts, ESCROW_LAMPORTS, FEES, LAMPORTS_PER_SOL, ORACLE_FEE, RENT_PER_BYTE, SIGNATURE_FEE, destination, listOf, lotterySplit, model, placeOf, prizeLine, prizePool, prizeToken, farmEndWords, prizeWords, releaseWords, sharesOf, toBps, toSeconds, toTime, weights } from './model.mjs';
7
+ // pool (a schedule's, a decision's) are worked out continuously, so a pool that pays a share of itself settles at a level
8
+ // instead of emptying. A prize is a fixed amount, all of it or none. Money is counted per day while a box sells; when
9
+ // its items, stashes or named NFTs run out (its own opens alone), a box that stops selling (`whenOut` "stopSales") says
10
+ // the day it stops, and one that draws among what is left ("drawLeft") the day each outcome runs out and the odds of
11
+ // the rest from then (`stockWalk`). It is a guide, not a promise: the full simulation (js/sim) runs the real program.
12
+ import { entryWidth, LIMIT_RECORD_BYTES, ROUND_RECORD_BYTES } from '../client/index.js';
13
+ import { boxOrderBytes, roundRentOf } from './plan.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';
11
47
 
12
48
  const DAY = 86_400;
13
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;
14
52
  /** Bids a day on an auction the scenario doesn't name (`auction:<key>`); a grant it doesn't name is claimed by nobody. */
15
53
  const DEFAULT_BIDS = 10;
16
- const sol = (lamports) => Number(lamports) / Number(LAMPORTS_PER_SOL);
54
+ const sol = lamports => Number(lamports) / Number(LAMPORTS_PER_SOL);
17
55
  const PLAYER_FEE = sol(FEES.player);
18
56
  const PROJECT_FEE = sol(FEES.project);
19
57
  const NETWORK_FEE = sol(SIGNATURE_FEE);
20
58
  /** MagicBlock's oracle, per random number: a box's open (the player), a lottery's draw, a `roll` (model.mjs ORACLE_FEE). */
21
59
  const ORACLE = sol(ORACLE_FEE);
22
- /** A lottery purchase's record: rent the buyer pays and gets back once the round is done with it (lottery.rs). */
23
- const TICKET_LINE = sol((BigInt(ACCOUNT_BYTES.LotteryContribution) + 128n) * RENT_PER_BYTE);
60
+ /** A lottery purchase's entry in its round: the bytes the buyer adds (not refunded: they go to the treasury when the
61
+ * round closes), with a wallet kept for each share the buyer's own entry names (lottery.rs `entry_width`). */
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
+ );
24
71
  /** A collectible's Core account, paid from the SOL the order holds: its rent and Core's creation fee (model.mjs). */
25
72
  const COLLECTIBLE_RENT = sol(COLLECTIBLE_RENT_LAMPORTS);
26
73
  /** What a collectible a recipe burns gives back: its rent less one byte's (Core leaves a 1-byte account that keeps
27
74
  * the creation fee). 189 bytes × 5,080 lamports, measured by the full simulation. */
28
75
  const BURN_REFUND = 0.00096;
29
- /** A grant's wallet receipt at its first voucher: the current header plus one round hash. */
30
- const RECEIPT_RENT = sol((BigInt(receiptBytes(1)) + 128n) * RENT_PER_BYTE);
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
77
+ * (none for an open grant's claim). Back to the owner when the shards close after the grant. */
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
+ );
31
85
  /** A wallet's token account, which a grant that pays tokens opens from its funds when the winner has none. */
32
86
  const TOKEN_ACCOUNT_RENT = sol((165n + 128n) * RENT_PER_BYTE);
33
- /** The order's fixed allocation, returned at settle or refund. */
34
- const ORDER_RENT = sol((BigInt(ACCOUNT_BYTES.Order) + 128n) * RENT_PER_BYTE);
35
- const num = (v) => (v === undefined || v === null ? 0 : Number(v));
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. */
88
+ const orderRent = (b, m, opened = false) => sol((BigInt(boxOrderBytes(b, m, 1, { opened })) + 128n) * RENT_PER_BYTE);
89
+ const num = v => (v === undefined || v === null ? 0 : Number(v));
36
90
  export const round = (x, digits = 9) => (Number.isFinite(x) ? Number(x.toFixed(digits)) : x);
37
91
 
38
92
  /**
@@ -42,7 +96,19 @@ export const round = (x, digits = 9) => (Number.isFinite(x) ? Number(x.toFixed(d
42
96
  * `heldIs`: the SOL an order holds and its account, a ticket purchase's record) and, for SOL, the wrapped-SOL account a
43
97
  * purchase opens and closes again. A price that comes back (`priceBack`: a no-loss ticket) is held, not spent.
44
98
  */
45
- 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
+ }) {
46
112
  const extrasSol = PLAYER_FEE + oracle + signatures * NETWORK_FEE + rent;
47
113
  const hold = extrasSol - rent + held + (isSol ? price + TOKEN_ACCOUNT_RENT : 0);
48
114
  const spent = isSol && !priceBack ? price : 0;
@@ -101,7 +167,12 @@ export function salePrices(part, perDay, days, fallStartsAfter = 0, budget = wal
101
167
  if (!part.fall && !growth && step > 0) {
102
168
  const below = Math.min(count - 1, Math.max(0, Math.ceil((PRICE_CAP - listed) / step) - 1));
103
169
  const total = listed * (1 + below) + (step * below * (below + 1)) / 2 + PRICE_CAP * (count - 1 - below);
104
- 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
+ };
105
176
  }
106
177
  // On the sale clock (the default) a sale restarts the countdown, so after the first sale each sale comes `gap` after
107
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.
@@ -113,7 +184,10 @@ export function salePrices(part, perDay, days, fallStartsAfter = 0, budget = wal
113
184
  let first;
114
185
  let paid = listed;
115
186
  for (let k = 0; k < count; k++) {
116
- 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
+ );
117
191
  const at = (k + 0.5) * gap;
118
192
  paid = price;
119
193
  const steps = part.fall && every && at > since ? Math.floor((at - since) / every) : 0;
@@ -140,15 +214,44 @@ export function simulate(economy, { now = 0 } = {}) {
140
214
  const scenarioDays = days > 0 ? days : 1;
141
215
  const budget = walkBudget();
142
216
  const perDay = (kind, key) => num(m.scenario.perDay?.[`${kind}:${key}`] ?? DEFAULT_PER_DAY);
143
- const symbol = (key) => m.tokens.get(key)?.symbol ?? key;
217
+ const symbol = key => m.tokens.get(key)?.symbol ?? key;
144
218
 
145
219
  // Per pool: fixed money in and out per day, and a rate per day for what takes a share of what it holds.
146
- 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 }]));
147
- // Each lottery's pot is an account of its own, counted here as a pool named "lottery:<key>".
148
- 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
+ });
239
+ // Each farm's rewards vault is an account of its own too, counted as a pool named "farm:<key>".
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
+ });
149
251
  const flowIn = (pool, by, amount) => amount && pools.get(pool)?.in.push({ by, perDay: amount });
150
252
  const flowOut = (pool, by, amount) => amount && pools.get(pool)?.out.push({ by, perDay: amount });
151
- 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 } : {}) });
152
255
 
153
256
  const players = { pay: {}, get: {}, fees: 0, network: 0, rent: 0, oracle: 0 };
154
257
  const creator = { treasury: {}, fees: 0, network: 0, oracle: 0 };
@@ -156,15 +259,16 @@ export function simulate(economy, { now = 0 } = {}) {
156
259
  const accounts = {};
157
260
  const platform = { player: 0, project: 0 };
158
261
  const notes = [];
159
- /** Prizes that are a share of a pool: counted for players once each pool's level is known. */
262
+ /** What a schedule or a decision pays out as a share of a pool: counted for players once each pool's level is known. */
160
263
  const sharePrizes = [];
161
264
  /** Shares of a pool that are burned (a schedule, a decision): counted once each pool's level is known. */
162
265
  const shareBurns = [];
163
266
  /** NFTs taken out of a stash a day, by each box that gives one; and the rolls of a box prizes give a day. */
164
267
  const stashTakes = [];
165
268
  const prizeTakes = [];
166
- /** The SOL a prize's order holds while it waits (its rent, and the SOL for a collectible when the box can give one), back to the winner at settle. */
167
- const orderHeld = (box) => ORDER_RENT + sol(ESCROW_LAMPORTS) * Math.max(0, ...(m.box.get(box)?.outcomes ?? []).map((o) => outcomeParts(o).filter((x) => x.gives === 'item').length));
269
+ /** The SOL a prize's order holds while it waits (its rent), back to the winner at settle. Whoever settles it pays a
270
+ * collectible's rent. */
271
+ const orderHeld = box => orderRent(m.box.get(box), m);
168
272
  /**
169
273
  * A route gives prize line `line` `times` a day (prize.rs), `payer` ({ rent, oracle }) paying what it opens: a
170
274
  * collectible's rent, the winner's token account (the bound, not the average), a roll's order (back to the winner at
@@ -180,13 +284,9 @@ export function simulate(economy, { now = 0 } = {}) {
180
284
  if (worth !== undefined) add(players.get, game, times * num(worth));
181
285
  } else if (line.gives === 'tokens') {
182
286
  each = TOKEN_ACCOUNT_RENT;
183
- 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);
184
288
  if (prizePool(line)) flowOut(prizePool(line), by, times * num(line.amount));
185
289
  add(players.get, token, times * num(line.amount));
186
- } else if (line.gives === 'share') {
187
- each = TOKEN_ACCOUNT_RENT;
188
- rateOut(line.pool, by, times * ((toBps(line.percent) ?? 0) / 10_000));
189
- sharePrizes.push({ pool: line.pool, rate: times * ((toBps(line.percent) ?? 0) / 10_000) });
190
290
  } else if (line.gives === 'nft') {
191
291
  stashTakes.push({ stash: line.stash, perDay: times, by });
192
292
  } else if (line.gives === 'roll') {
@@ -198,27 +298,43 @@ export function simulate(economy, { now = 0 } = {}) {
198
298
  return each;
199
299
  };
200
300
  /** Seconds from going live to a fall's start. */
201
- 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;
202
303
  /** What a rising or falling price really comes to, in words. */
203
304
  const priceNote = (part, sold) => {
204
305
  if (!part.rise && !part.fall) return;
205
306
  const shared = part.fall?.clock === 'shared';
206
- const how = part.rise && part.fall
207
- ? `rises with each sale and falls with time${shared ? ' on a clock of its own, sales or not' : ', the countdown restarting at each sale'}`
208
- : part.rise ? 'rises with each sale'
209
- : 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)'
210
- : '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)';
211
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.`;
212
- 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
+ );
213
321
  };
214
322
 
215
- /** Sends `amount` of `token` a day to one destination (model `destination`): a pool, a lottery's pot, a burn, the treasury or an account. */
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. */
216
324
  const sendTo = (d, token, amount, by) => {
217
325
  if (d.kind === 'pool') flowIn(d.key, by, amount);
218
326
  else if (d.kind === 'lottery') flowIn(`lottery:${d.key}`, by, amount);
327
+ else if (d.kind === 'farm') flowIn(`farm:${d.key}`, by, amount);
219
328
  else if (d.kind === 'burn') add(burned, token, amount);
220
329
  else if (d.kind === 'treasury') add(creator.treasury, token, amount);
221
- 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
+ );
222
338
  else add(accounts, `${d.kind}:${d.key}`, amount);
223
339
  };
224
340
  /** Where `amount` of `token` goes under `split`, per day: its shares (`sendTo`), and the rest to the treasury (or into `restTo`, a pool). */
@@ -238,8 +354,119 @@ export function simulate(economy, { now = 0 } = {}) {
238
354
  players.network += count * signatures * NETWORK_FEE;
239
355
  };
240
356
 
357
+ /**
358
+ * What box `b`'s items with a supply, stashes and named NFTs come to over the scenario, with `opens` opens a day and
359
+ * its own opens alone taking from them (pools are walked with their own rows): when an outcome runs out, a box that
360
+ * stops selling stops (`stopsSellingInDays`), and one that draws among what is left goes on with the rest's chances
361
+ * rising (`runsOut`: each outcome's day, and every chance from then), until nothing in it is left. An item whose odds
362
+ * follow its stock (`stockOdds`) never runs out here: its chance shrinks instead. Null when nothing it gives runs out.
363
+ */
364
+ const stockWalk = (b, opens) => {
365
+ const w = weights(b);
366
+ const draws = drawsOf(b);
367
+ // What each stock holds at the start, and what one draw of each outcome takes from it.
368
+ const left = new Map();
369
+ const stockOf = x => {
370
+ const supply = Number(m.item.get(x.item)?.supply ?? 0);
371
+ if (x.gives === 'item' && supply > 0 && !b.stockOdds) return [`item:${x.item}`, supply];
372
+ if (x.gives === 'stash' && m.stash.get(x.stash))
373
+ return [`stash:${x.stash}`, (m.stash.get(x.stash).nfts ?? []).length];
374
+ if (x.gives === 'nft') return [`nft:${x.nft}`, 1];
375
+ return null;
376
+ };
377
+ const needs = (b.outcomes ?? []).map(o => {
378
+ const need = new Map();
379
+ for (const [key, holds] of outcomeParts(o).map(stockOf).filter(Boolean)) {
380
+ left.set(key, holds);
381
+ need.set(key, (need.get(key) ?? 0) + 1);
382
+ }
383
+ return need;
384
+ });
385
+ if (!left.size || !(opens > 0)) return null;
386
+ const stops = b.whenOut !== 'drawLeft';
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 => {
420
+ const sum = alive.reduce((n, i) => n + w[i], 0);
421
+ return w.map((x, i) => (sum && alive.includes(i) ? round((x / sum) * 100, 6) : 0));
422
+ };
423
+ let alive = w.map((_, i) => i).filter(there);
424
+ let day = 0;
425
+ const runsOut = [];
426
+ const gone = w.map((_, i) => i).filter(i => !alive.includes(i));
427
+ if (gone.length) {
428
+ runsOut.push({ inDays: 0, outcomes: gone.map(out), chancesAfter: chances(alive) });
429
+ }
430
+ while (alive.length) {
431
+ const sum = alive.reduce((n, i) => n + w[i], 0);
432
+ // Each stock's take a day, by the outcomes still there.
433
+ const rate = new Map();
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);
436
+ const next = [...rate].map(([k, r]) => ({ k, t: left.get(k) / r })).sort((a, z) => a.t - z.t)[0];
437
+ if (!next || day + next.t > days) break;
438
+ day += next.t;
439
+ for (const [k, r] of rate) left.set(k, Math.max(0, left.get(k) - r * next.t));
440
+ left.set(next.k, 0);
441
+ const ended = alive.filter(i => !there(i));
442
+ alive = alive.filter(there);
443
+ runsOut.push({ inDays: round(day, 4), outcomes: ended.map(out), chancesAfter: chances(alive) });
444
+ }
445
+ return runsOut.length
446
+ ? { whenOut: 'drawLeft', runsOut, ...(alive.length ? {} : { stopsSellingInDays: runsOut.at(-1).inDays }) }
447
+ : null;
448
+ };
449
+ /** The walk of a box's stock in words (`stockWalk`). */
450
+ const stockNote = (b, walk) => {
451
+ const named = list => list.map(x => `outcome ${x.outcome} (${x.gives})`).join(', ');
452
+ if (walk.whenOut === 'stopSales')
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
+ );
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.`;
466
+ };
467
+
241
468
  // ---- boxes
242
- const boxes = m.boxes.map((b) => {
469
+ const boxes = m.boxes.map(b => {
243
470
  const token = b.currency ?? game;
244
471
  const opens = perDay('box', b.key);
245
472
  // A price that rises or falls is counted at what the scenario's opens really pay, on average.
@@ -248,7 +475,6 @@ export function simulate(economy, { now = 0 } = {}) {
248
475
  const w = weights(b);
249
476
  const total = w.reduce((a, x) => a + x, 0) || 1;
250
477
  let value = 0;
251
- let burnChance = 0;
252
478
  const unpriced = [];
253
479
  const other = {};
254
480
  // One part of what an outcome gives (model `outcomeParts`), landing with chance `p`: its row, and its flows.
@@ -266,13 +492,6 @@ export function simulate(economy, { now = 0 } = {}) {
266
492
  Object.assign(row, { amount: num(o.amount), token: prizeToken, minted: true });
267
493
  if (prizeToken === token) value += p * num(o.amount);
268
494
  else add(other, prizeToken, p * num(o.amount));
269
- } else if (o.gives === 'share') {
270
- const bps = toBps(o.percent) ?? 0;
271
- const prizeToken = m.pool.get(o.pool)?.currency;
272
- Object.assign(row, { pool: o.pool, percent: o.percent, token: prizeToken });
273
- rateOut(o.pool, `box:${b.key}`, opens * p * (bps / 10_000));
274
- sharePrizes.push({ pool: o.pool, rate: opens * p * (bps / 10_000), box: b.key, perOpen: p * (bps / 10_000) });
275
- row.shareOfPool = true;
276
495
  } else if (o.gives === 'item') {
277
496
  const it = m.item.get(o.item);
278
497
  const worth = it?.value !== undefined ? num(it.value) : num(it?.price) > 0 ? num(it.price) : null;
@@ -283,7 +502,9 @@ export function simulate(economy, { now = 0 } = {}) {
283
502
  } else if (o.gives === 'stash' || o.gives === 'nft') {
284
503
  // A stash's NFT has whatever worth its collection has: the simulation counts how fast it is given away.
285
504
  Object.assign(row, { stash: o.stash, worth: null, ...(o.nft ? { nft: o.nft } : {}) });
286
- 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
+ );
287
508
  // A box switched off is opened by nobody: only the prizes that roll it take from the stash.
288
509
  if (b.active !== false) stashTakes.push({ stash: o.stash, perDay: opens * p, by: `box:${b.key}` });
289
510
  } else if (o.gives === 'roll') {
@@ -296,46 +517,56 @@ export function simulate(economy, { now = 0 } = {}) {
296
517
  };
297
518
  const outcomes = (b.outcomes ?? []).map((o, i) => {
298
519
  const p = w[i] / total;
299
- const parts = outcomeParts(o).map((part) => partRow(part, p));
300
- if (outcomeBurns(o)) burnChance += p;
301
- if (o.gives === 'bundle') return { gives: 'bundle', chance: round(p * 100, 6), prizes: parts, ...(o.burns ? { burns: true } : {}) };
520
+ const parts = outcomeParts(o).map(part => partRow(part, p));
521
+ if (o.gives === 'bundle') return { gives: 'bundle', chance: round(p * 100, 6), prizes: parts };
302
522
  return parts[0] ?? { gives: o.gives, chance: round(p * 100, 6) };
303
523
  });
304
524
  // Every part of every outcome, each with its outcome's chance.
305
- const partsOf = outcomes.flatMap((o) => (o.gives === 'bundle' ? o.prizes : [o]));
306
- const shares = partsOf.filter((o) => o.shareOfPool);
525
+ const partsOf = outcomes.flatMap(o => (o.gives === 'bundle' ? o.prizes : [o]));
307
526
  // How much the prizes swing from day to day, per pool: opens × (E[prize²] − E[prize]²).
308
- for (const key of new Set(partsOf.filter((o) => o.gives === 'pay').map((o) => o.pool))) {
309
- 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);
310
529
  const mean = rows.reduce((n, o) => n + (o.chance / 100) * o.amount, 0);
311
530
  const square = rows.reduce((n, o) => n + (o.chance / 100) * o.amount ** 2, 0);
312
531
  if (pools.has(key)) pools.get(key).variance += opens * (square - mean ** 2);
313
532
  }
314
- // Money: the payment is split unless the roll burns it all.
315
- carryOut(b.split, token, opens * price * (1 - burnChance), `box:${b.key}`);
316
- add(burned, token, opens * price * burnChance);
533
+ // Money: the payment is split (a share may burn it).
534
+ carryOut(b.split, token, opens * price, `box:${b.key}`);
317
535
  add(players.pay, token, opens * price);
318
536
  add(players.get, token, opens * value);
319
537
  for (const [t, v] of Object.entries(other)) add(players.get, t, opens * v);
320
- // Two signatures: the open, then the settle that carries the outcome out, both from the player's wallet. A box
321
- // that needs no roll is bought in one (`buy`), with no order; one of one entry that rolls on an NFT opens an order
322
- // that needs no oracle.
323
- const rolled = boxRolls(b);
324
- const bought = boxBuys(b);
538
+ // Two signatures: the open, then the settle that carries the outcome out (a throwaway key the open funds sends it).
539
+ // A box that needs no roll is bought in one (`buy`), with no order; every open asks the oracle.
540
+ const bought = boxBuys(b, m);
325
541
  const signatures = bought ? 1 : 2;
326
542
  playerAction(opens, signatures);
327
- if (rolled) players.oracle += opens * ORACLE;
328
- const collectibles = partsOf.filter((o) => o.gives === 'item' && m.item.get(o.item)?.kind !== 'normal').reduce((n, o) => n + o.chance / 100, 0);
543
+ if (!bought) players.oracle += opens * ORACLE;
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);
329
549
  players.rent += opens * collectibles * COLLECTIBLE_RENT;
330
- const known = !unpriced.length && !shares.length && !Object.keys(other).length;
550
+ const known = !unpriced.length && !Object.keys(other).length;
331
551
  // What one open really takes from the player's wallet: the price, the platform's fee, the oracle's and the network's.
332
552
  const isSol = Boolean(m.tokens.get(token)?.wsol);
333
- const oracle = rolled ? ORACLE : 0;
334
- // A collectible's rent comes out of the SOL the order holds when it settles: on average, the chance of one.
335
- const items = Math.max(0, ...(b.outcomes ?? []).map((o) => outcomeParts(o).filter((x) => x.gives === 'item').length));
336
- 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(ESCROW_LAMPORTS) * items + ORDER_RENT });
553
+ const oracle = bought ? 0 : ORACLE;
554
+ // A collectible's rent comes out of the SOL the open lends its settle key: on average, the chance of one.
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
+ });
337
565
  const extrasSol = realCost.extrasSol;
338
566
  priceNote(b, sold);
567
+ // What it gives that runs out, as the box says (`whenOut`): it stops selling, or draws among what is left.
568
+ const walk = !bought && b.active !== false ? stockWalk(b, opens) : null;
569
+ if (walk) notes.push(stockNote(b, walk));
339
570
  return {
340
571
  key: b.key,
341
572
  name: b.name,
@@ -352,14 +583,29 @@ export function simulate(economy, { now = 0 } = {}) {
352
583
  playerFee: PLAYER_FEE,
353
584
  oracleFee: oracle,
354
585
  realCost,
586
+ // What it gives can run out, and what the box does then (`whenOut`, `whenOutWords`).
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
+ : {}),
599
+ ...(!bought ? { whenOut: b.whenOut === 'drawLeft' ? 'drawLeft' : 'stopSales' } : {}),
600
+ ...(walk ? { stock: walk } : {}),
355
601
  ...(isSol && known && price ? { returnOfRealCostPercent: round((value / (price + extrasSol)) * 100, 4) } : {}),
356
602
  };
357
603
  });
358
604
 
359
605
  // ---- items sold in the shop
360
606
  const items = m.shop
361
- .map((b) => ({ ...m.item.get(b.shopOf), shopBox: b.key }))
362
- .map((it) => {
607
+ .map(b => ({ ...m.item.get(b.shopOf), shopBox: b.key }))
608
+ .map(it => {
363
609
  const sales = perDay('item', it.key);
364
610
  const sold = salePrices(it, sales, days, fallStart(it), budget);
365
611
  const price = sold.average;
@@ -373,32 +619,73 @@ export function simulate(economy, { now = 0 } = {}) {
373
619
  if (collectible) players.rent += sales * COLLECTIBLE_RENT;
374
620
  const listed = num(it.price);
375
621
  const rises = it.rise ? listed * (num(it.rise.growthPercent) / 100) + num(it.rise.step) : 0;
376
- 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 });
377
- 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
+ };
378
642
  });
379
643
 
380
644
  // ---- payments
381
- const payments = m.payments.map((p) => {
645
+ const payments = m.payments.map(p => {
382
646
  const token = p.currency ?? game;
383
647
  const count = perDay('payment', p.key);
384
648
  carryOut(p.split, token, count * num(p.amount), `payment:${p.key}`);
385
649
  add(players.pay, token, count * num(p.amount));
386
650
  playerAction(count);
387
- const realCost = realCostOf({ price: num(p.amount), token, isSol: Boolean(m.tokens.get(token)?.wsol), atLeast: Boolean(p.any) });
388
- 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
+ };
389
666
  });
390
667
 
391
668
  // ---- recipes
392
- const recipes = m.recipes.map((r) => {
669
+ const recipes = m.recipes.map(r => {
393
670
  const crafts = perDay('recipe', r.key);
394
671
  const takes = [];
395
672
  const gives = [];
396
673
  for (const line of r.in ?? []) {
397
674
  if (line.takes === 'burn') players.rent -= crafts * (line.count ?? 1) * BURN_REFUND;
398
675
  if (line.takes !== 'pay') {
399
- const what = line.takes === 'show' || line.takes === 'trade' ? `an NFT of ${line.collection ? `${line.collection.slice(0, 4)}…` : 'the collection'}` : line.item;
400
- const traded = (t) => (!t || t === 'burn' ? ' (traded in, burned)' : t.stash ? ` (traded in, into “${t.stash}”)` : ' (traded in, given on)');
401
- 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
+ );
402
689
  continue;
403
690
  }
404
691
  const token = line.currency ?? game;
@@ -407,9 +694,24 @@ export function simulate(economy, { now = 0 } = {}) {
407
694
  sendTo(destination(line.to), token, crafts * num(line.amount), `recipe:${r.key}`);
408
695
  }
409
696
  for (const x of r.raises ?? []) {
410
- 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}`);
411
- 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)`);
412
- 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
+ );
413
715
  }
414
716
  for (const line of r.out ?? []) {
415
717
  if (line.gives === 'item') {
@@ -428,10 +730,6 @@ export function simulate(economy, { now = 0 } = {}) {
428
730
  } else if (line.gives === 'nft') {
429
731
  gives.push(prizeWords(line, m));
430
732
  stashTakes.push({ stash: line.stash, perDay: crafts, by: `recipe:${r.key}` });
431
- } else if (line.gives === 'share') {
432
- gives.push(`${line.percent}% of ${line.pool}`);
433
- rateOut(line.pool, `recipe:${r.key}`, crafts * ((toBps(line.percent) ?? 0) / 10_000));
434
- sharePrizes.push({ pool: line.pool, rate: crafts * ((toBps(line.percent) ?? 0) / 10_000) });
435
733
  }
436
734
  }
437
735
  // The player's is the only signature: what a craft makes is at its item's and serial's address.
@@ -441,15 +739,17 @@ export function simulate(economy, { now = 0 } = {}) {
441
739
 
442
740
  // ---- schedules
443
741
  const schedules = m.schedules
444
- .filter((s) => s.active !== false)
445
- .map((s) => {
742
+ .filter(s => s.active !== false)
743
+ .map(s => {
446
744
  const every = toSeconds(s.every) || DAY;
447
745
  const runs = DAY / every;
448
746
  creator.network += runs * NETWORK_FEE; // run_schedule is free of the program's fee; whoever sends it pays the network
449
747
  if (s.does === 'issue') {
450
748
  const amount = num(s.amount);
451
- flowIn(s.pool, `schedule:${s.key}`, runs * amount);
452
- const halving = s.halveEvery ? `, halving every ${s.halveEvery} runs (every ${round((s.halveEvery * every) / DAY, 2)} days)` : '';
749
+ flowIn(destination(s.to ?? '').kind === 'farm' ? s.to : s.pool, `schedule:${s.key}`, runs * amount);
750
+ const halving = s.halveEvery
751
+ ? `, halving every ${s.halveEvery} runs (every ${round((s.halveEvery * every) / DAY, 2)} days)`
752
+ : '';
453
753
  // Halving every h runs: h runs at the amount, h at half, … (2 × h × amount in all, with no end); with
454
754
  // maxRuns, the full halvings it reaches and the runs after the last.
455
755
  const halvings = s.halveEvery && s.maxRuns ? Math.floor(s.maxRuns / s.halveEvery) : 0;
@@ -460,14 +760,28 @@ export function simulate(economy, { now = 0 } = {}) {
460
760
  : s.maxRuns
461
761
  ? amount * s.maxRuns
462
762
  : Infinity;
463
- 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
+ };
464
771
  }
465
772
  if (s.sealed) {
466
773
  // A sealed schedule pays from its own vault: what it was funded with, run by run, the last run the rest.
467
774
  const t = s.currency ?? m.currencyKey;
468
- 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`;
469
777
  if (s.percent === undefined) sendTo(destination(s.to), t, runs * num(s.amount), `schedule:${s.key}`);
470
- 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
+ };
471
785
  }
472
786
  const from = s.does === 'burn' ? s.pool : s.from;
473
787
  const sym = symbol(m.pool.get(from)?.currency);
@@ -479,7 +793,12 @@ export function simulate(economy, { now = 0 } = {}) {
479
793
  else if (s.does === 'burn') add(burned, m.pool.get(from)?.currency, runs * amount);
480
794
  else sendTo(destination(s.to), m.pool.get(from)?.currency, runs * amount, `schedule:${s.key}`);
481
795
  const where = s.does === 'burn' ? 'burns' : s.does === 'move' ? `moves to ${s.to}` : `pays ${s.to}`;
482
- 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
+ };
483
802
  }
484
803
  const share = (toBps(s.percent) ?? 0) / 10_000;
485
804
  if (s.does === 'burn') {
@@ -493,16 +812,21 @@ export function simulate(economy, { now = 0 } = {}) {
493
812
  sharePrizes.push({ pool: s.from, rate: runs * share, account: s.to, by: `schedule:${s.key}` });
494
813
  }
495
814
  const where = s.does === 'burn' ? 'burns' : s.does === 'move' ? `moves to ${s.to}` : `pays ${s.to}`;
496
- 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
+ };
497
821
  });
498
822
 
499
- // ---- stakes: everything that arrives in the rewards pool goes to stakers
823
+ // ---- stakes: everything that arrives in a farm’s rewards vault goes to its stakers
500
824
  // ---- lotteries: in steady state each round pays out what came in (what the places leave rolls over). One that draws
501
825
  // once (no `every`) has one round, from going live to its first draw (the scenario's end at the latest): its tickets,
502
826
  // its draw and its pot happen once, spread over the scenario's days like an auction's sale.
503
827
  /** Per lottery: whether it draws once, and the days its money a day is spread over. */
504
828
  const lotteryRun = new Map();
505
- const lotteries = m.lotteries.map((l) => {
829
+ const lotteries = m.lotteries.map(l => {
506
830
  const once = !toSeconds(l.every);
507
831
  const untilDraw = l.firstDraw ? toTime(l.firstDraw, now) - now : 0;
508
832
  const every = once ? Math.min(untilDraw > 0 ? untilDraw : Infinity, scenarioDays * DAY) : toSeconds(l.every);
@@ -520,30 +844,82 @@ export function simulate(economy, { now = 0 } = {}) {
520
844
  // No loss: every ticket comes back to its buyer after the draw; the pot is only what is paid into it.
521
845
  if (l.noLoss) add(players.get, ticketToken, tickets * price);
522
846
  else if (l.ticketCurrency) add(burned, ticketToken, tickets * price);
523
- else {
847
+ else if (keepsPot(l)) {
524
848
  // The split takes its shares as it does a sale's; the rest goes into the pot (not the treasury).
525
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}`);
526
855
  }
527
856
  playerAction(tickets);
528
- players.rent += tickets * TICKET_LINE;
857
+ // A lottery that holds SOL for its rounds (`roundRent`, on by default) pays each entry's bytes: the buyer pays none.
858
+ const entryRent = roundRentOf(l, m) > 0n ? 0 : ticketLine(l);
859
+ players.rent += tickets * entryRent;
529
860
  const placed = (l.places ?? [100]).map(placeOf);
530
- const places = placed.map((p) => Number(p.percent));
861
+ const places = placed.map(p => Number(p.percent));
531
862
  creator.network += (1 + places.length) * NETWORK_FEE * drawsPerDay; // the draw and each place's payment
532
863
  // A place's prize, once a round, given by whoever pays the place (the game's keeper).
533
- 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);
534
865
  creator.oracle += ORACLE * drawsPerDay; // the draw asks the oracle; whoever sends it (the game's keeper) pays
535
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
536
867
  // prize with no price (an NFT, a roll, an item with no value) leaves a ticket's return unsaid.
537
- const worthOf = (line) => {
538
- if (line.gives === 'tokens') return (prizePool(line) ? m.pool.get(prizePool(line))?.currency : line.currency ?? game) === potToken ? num(line.amount) : null;
539
- 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;
540
877
  return null;
541
878
  };
542
- 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)));
543
880
  // One ticket bought on its own: the fee and the record are per purchase, so a bundle of tickets shares them. The
544
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.
545
- const realCost = realCostOf({ price, token: ticketToken, isSol: Boolean(m.tokens.get(ticketToken)?.wsol), held: TICKET_LINE, heldIs: l.noLoss ? 'the ticket’s price and the purchase record’s rent' : 'the purchase record’s rent', priceBack: Boolean(l.noLoss) });
546
- 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, ...(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
+ };
547
923
  });
548
924
 
549
925
  // ---- decisions with a known rate
@@ -559,26 +935,37 @@ export function simulate(economy, { now = 0 } = {}) {
559
935
  rateOut(d.pool, `decision:${d.key}`, rate * ((toBps(d.percent) ?? 0) / 10_000));
560
936
  if (d.does === 'payout') sharePrizes.push({ pool: d.pool, rate: rate * ((toBps(d.percent) ?? 0) / 10_000) });
561
937
  else shareBurns.push({ pool: d.pool, rate: rate * ((toBps(d.percent) ?? 0) / 10_000) });
562
- }
563
- else flowOut(d.pool, `decision:${d.key}`, rate * num(d.amount));
938
+ } else flowOut(d.pool, `decision:${d.key}`, rate * num(d.amount));
564
939
  const token = m.pool.get(d.pool)?.currency;
565
940
  if (d.does === 'payout' && d.amount !== undefined) add(players.get, token, rate * num(d.amount));
566
941
  if (d.does === 'burn' && d.amount !== undefined) add(burned, token, rate * num(d.amount));
567
942
  }
568
943
  }
569
- const contests = m.contests.map((c) => {
944
+ const contests = m.contests.map(c => {
570
945
  const rake = sharesOf(c.split).reduce((n, s) => n + (s.bps ?? 0), 0) / 100;
571
946
  const opened = c.every ? DAY / toSeconds(c.every) : 0;
572
947
  creator.fees += opened * 2 * PROJECT_FEE; // opened and decided
573
948
  platform.project += opened * 2 * PROJECT_FEE;
574
- 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)` : '';
575
- 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
+ };
576
960
  });
577
961
  // Auctions: each bid tops the last by the least raise, so a run of N bids ends near reserve × (1 + raise)^(N − 1).
578
962
  // The scenario counts bids a day (`auction:<key>`, DEFAULT_BIDS a day unless it says), while the auction runs.
579
- const auctions = m.auctions.map((a) => {
963
+ const auctions = m.auctions.map(a => {
580
964
  const runDays = toSeconds(a.runs) / DAY;
581
- 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
+ );
582
969
  const reserve = num(a.reserve);
583
970
  const raise = (a.minRaise ?? 5) / 100;
584
971
  const price = bids ? reserve * (1 + raise) ** (bids - 1) : 0;
@@ -602,7 +989,9 @@ export function simulate(economy, { now = 0 } = {}) {
602
989
  bids,
603
990
  reserve: num(a.reserve ?? 1),
604
991
  expectedPrice: null,
605
- 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`,
606
995
  };
607
996
  }
608
997
  return {
@@ -619,21 +1008,71 @@ export function simulate(economy, { now = 0 } = {}) {
619
1008
  };
620
1009
  });
621
1010
  for (const f of m.farms) playerAction(perDay('farm', f.key));
622
- // Grants: each claim's sender pays the player fee; its receipt's rent comes from the grant's funds (the owner's SOL).
623
- const grants = m.grants.map((g) => {
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
+ };
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).
1054
+ const grants = m.grants.map(g => {
624
1055
  const claims = num(m.scenario.perDay?.[`grant:${g.key}`]);
625
1056
  playerAction(claims);
626
- creator.rent = (creator.rent ?? 0) + claims * RECEIPT_RENT;
1057
+ creator.rent = (creator.rent ?? 0) + claims * claimRecordsRent(g);
627
1058
  // Its prize, paid for by the grant's funds; a roll's order goes back to the winner at settle.
628
1059
  const line = prizeLine(g.prize ?? g.gives);
629
1060
  const each = givePrize(line, claims, `grant:${g.key}`, creator);
630
- return { key: g.key, name: g.name, claimsPerDay: claims, gives: prizeWords(line, m), fundsPerClaimSol: round(RECEIPT_RENT + 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
+ };
631
1070
  });
632
1071
  for (const h of m.holds) playerAction(perDay('hold', h.key));
633
1072
  for (const c of m.contests) playerAction(perDay('contest', c.key));
634
1073
 
635
1074
  // ---- the pools over time
636
- const settle = (p) => {
1075
+ const settle = p => {
637
1076
  const fixedIn = p.in.reduce((n, f) => n + f.perDay, 0);
638
1077
  const fixedOut = p.out.reduce((n, f) => n + f.perDay, 0);
639
1078
  const rate = p.rate.reduce((n, f) => n + f.perDay, 0);
@@ -644,7 +1083,9 @@ export function simulate(economy, { now = 0 } = {}) {
644
1083
  const levels = new Map();
645
1084
  for (let pass = 0; pass < 3; pass += 1)
646
1085
  for (const p of pools.values()) {
647
- 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
+ );
648
1089
  const { fixedIn, fixedOut, rate } = settle(p);
649
1090
  const inflow = fixedIn + moved.reduce((a, b) => a + b, 0);
650
1091
  const net = inflow - fixedOut;
@@ -653,48 +1094,82 @@ export function simulate(economy, { now = 0 } = {}) {
653
1094
  p.inflow = inflow;
654
1095
  }
655
1096
 
656
- // Farms: everything that arrives in the rewards pool is released to the seats over the farm's release span (its
657
- // longest lock, a day at least), by weight (stake × tier × boosts) as it is released; or a fixed rate for each seat
658
- // (minted, or from the pool while it lasts). In a steady state the release pays what arrives, a day at a time.
659
- /** A farm's ways out and tolls, in words (farm.rs): an early-exit fee, an unbonding wait, a toll; or null. */
660
- const farmExtras = (f) => {
661
- 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' : `to ${String(f.exitFeeTo).replace(/^pool:/, '')}`})` : null)).filter(Boolean);
1097
+ // Farms: everything that arrives in a farm's rewards vault is released to the seats over the farm's release span (its
1098
+ // longest lock, a day at least), by weight (stake × tier × boosts) as it is released; or a fixed rate for each seat,
1099
+ // or with `shared` for the whole farm (minted, or from the vault while it lasts). In a steady state the release pays
1100
+ // what arrives, a day at a time.
1101
+ /** A farm's ways out, in words (farm.rs): an early-exit fee, compounding; or null. */
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);
662
1110
  const parts = [
663
1111
  ...exits,
664
- f.unbond && `what is taken out waits ${f.unbond}, earning nothing, before it can leave`,
665
- f.toll && `${f.toll.percent}% of every payout of rewards is ${f.toll.to === 'burn' ? 'burned' : `sent to ${String(f.toll.to).replace(/^pool:/, '')}`}`,
666
- f.stakes !== 'collectibles' && m.pool.get(f.rewards)?.currency === f.stakes && 'its rewards can be compounded into the stake without restarting the lock',
667
- f.unpaid && 'it pays nothing: staking there only counts (a stake gate reads it)',
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`,
668
1117
  ].filter(Boolean);
669
1118
  return parts.length ? parts.join('; ') : null;
670
1119
  };
671
- /** What a seat of `f` can give back (farm.rs `Farm::give`): a forfeit, an early exit's waiting rewards, a watched NFT gone, a lapsed upkeep. */
672
- const giveBacks = (f) => ['a forfeit', (f.tiers ?? []).some((t) => t.exitFee) && 'an early exit’s waiting rewards', f.custody === 'watch' && 'a watched NFT sold', f.upkeepAttribute && 'what a lapsed upkeep earned'].filter(Boolean).join(', ');
673
- const farmRows = m.farms.map((f) => {
674
- const pool = pools.get(f.rewards);
1120
+ /** What a seat of `f` can give up (farm.rs `Farm::give`): a forfeit, a watched NFT gone. */
1121
+ const giveBacks = f => ['a forfeit', f.custody === 'watch' && 'a watched NFT sold'].filter(Boolean).join(', ');
1122
+ const farmRows = m.farms.map(f => {
1123
+ const pool = pools.get(`farm:${f.key}`);
675
1124
  const staked = num(m.scenario.staked?.[f.key]);
676
1125
  const perUnit = f.rate ? num(f.rate.perDay) : null;
677
- const wanted = f.rate ? perUnit * staked : Infinity;
1126
+ const wanted = !f.rate ? Infinity : f.rate.shared ? (staked ? perUnit : 0) : perUnit * staked;
678
1127
  const free = pool ? Math.max(0, pool.inflow - pool.out.reduce((n, x) => n + x.perDay, 0)) : 0;
679
- 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);
680
1131
  if (pool && !f.rate?.minted) pool.out.push({ by: `farm:${f.key}`, perDay: rewards });
681
- const sameToken = pool?.currency === f.stakes;
682
- const tiers = (f.tiers ?? [{ lock: 0, multiplier: 1 }]).map((t) => ({ lock: t.lock, multiplier: t.multiplier ?? 1, ...(prizeLine(t.prize) ? { prize: prizeWords(t.prize, m) } : {}) }));
1132
+ const sameToken = f.rewardsToken === f.stakes;
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
+ }));
683
1139
  const unit = f.stakes === 'collectibles' ? 'collectible' : symbol(f.stakes);
684
1140
  // Growth: the days a collectible at x1 needs for each level.
685
- const growth = listOf(f.growth).map((g) => {
1141
+ const growth = listOf(f.growth).map(g => {
686
1142
  const levels = (g.levels ?? []).map((points, i) => ({ level: i + 1, points, days: round(points / g.perDay, 2) }));
687
- 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
+ };
688
1149
  });
1150
+ const perSeat = !f.rate ? null : f.rate.shared ? (staked ? perUnit / staked : null) : perUnit;
689
1151
  return {
690
1152
  key: f.key,
691
1153
  name: f.name,
692
1154
  stakes: f.stakes,
693
- rewardsToken: pool?.currency,
1155
+ rewardsToken: f.rewardsToken,
694
1156
  rewardsPerDay: round(rewards),
695
- ...(f.rate ? { rate: { perDay: perUnit, minted: Boolean(f.rate.minted) } } : {}),
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
+ : {}),
696
1167
  tiers,
697
- ...(staked ? { staked, perUnitPerDay: round(f.rate ? perUnit : rewards / staked), unit } : f.rate ? { 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
+ : {}),
698
1173
  ...(staked && sameToken ? { yearlyPercent: round(((rewards * 365) / staked) * 100, 4) } : {}),
699
1174
  ...(growth.length ? { growth } : {}),
700
1175
  ...(f.runs ? { runs: f.runs } : {}),
@@ -703,48 +1178,66 @@ export function simulate(economy, { now = 0 } = {}) {
703
1178
  ...(f.upkeepAttribute ? { upkeepAttribute: f.upkeepAttribute } : {}),
704
1179
  ...(farmExtras(f) ? { extras: farmExtras(f) } : {}),
705
1180
  says: f.rate
706
- ? `each seat earns ${perUnit} a day for each ${unit} at x1, whoever else is in${f.rate.minted ? ' (minted, as far as the mint has room for: what it can’t mint is not owed later)' : ', while the pool lasts'}${perUnit > 0 ? `; what a seat gives back (${giveBacks(f)}) is ${f.rate.minted ? 'burned' : `sent back to “${f.rewards}”`} in the same move, never to the other seats` : ''}`
707
- : `what lands in the pool is released over ${releaseWords(f)} to the seats there as it is released, so a stake earns only while it stays; ${tiers.length > 1 || (f.boosts ?? []).length ? 'shares are by weight: a higher tier or a boosted collectible takes more of the same rewards, so the figures are for a seat at x1 among seats at x1' : 'shares are by what each seat stakes'}`,
1181
+ ? `${f.rate.shared ? `the farm pays ${perUnit} a day in all, shared by weight while anything is staked` : `each seat earns ${perUnit} a day for each ${unit} at x1, whoever else is in`}${f.rate.halveEvery ? `, halving every ${f.rate.halveEvery}` : ''}${f.rate.minted ? ' (minted when a seat is paid, as far as the mint has room for: what it can’t mint is not owed later)' : ', while its vault lasts'}${perUnit > 0 ? `; what a seat gives back (${giveBacks(f)}) is ${f.rate.minted ? 'never minted' : 'left free in its vault'}, never to the other seats` : ''}`
1182
+ : `what lands in its rewards vault is released over ${releaseWords(f)} to the seats there as it is released, so a stake earns only while it stays; ${tiers.length > 1 || (f.boosts ?? []).length ? 'shares are by weight: a higher tier or a boosted collectible takes more of the same rewards, so the figures are for a seat at x1 among seats at x1' : 'shares are by what each seat stakes'}`,
708
1183
  };
709
1184
  });
710
1185
  for (const row of farmRows) add(players.get, row.rewardsToken, row.rewardsPerDay);
711
1186
  // A farm that ends inside the scenario takes no stake from its end on (FarmOver), and pays nothing after it.
712
- for (const f of m.farms.filter((x) => x.runs && toSeconds(x.runs) < days * DAY))
713
- 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
+ );
714
1191
 
715
1192
  // Lotteries pay what came in, once the pot is in its steady state; one that draws once pays its seed and all that
716
1193
  // came in, at its one draw.
717
1194
  for (const l of lotteries) {
718
1195
  const pool = pools.get(`lottery:${l.key}`);
719
- const inflow = pool ? pool.inflow : 0;
720
1196
  const run = lotteryRun.get(l.key);
721
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;
722
1201
  pool?.out.push({ by: `lottery:${l.key}`, perDay: inflow });
723
- const potBeforeDraw = run.once ? run.seed + inflow * run.spreadDays : placesTotal ? (inflow * l.roundDays) / placesTotal : 0;
724
- 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;
725
1210
  l.potBeforeDraw = round(potBeforeDraw);
1211
+ if (!pool) l.roundMoneyPerDay = round(l.roundMoneyPerDay ?? 0);
726
1212
  l.paidPerRound = round(paid);
727
- l.prizes = l.places.map((pct) => round((potBeforeDraw * pct) / 100));
1213
+ l.prizes = l.places.map(pct => round((potBeforeDraw * pct) / 100));
728
1214
  const perTicket = l.ticketsPerRound ? (paid + l.prizeWorthPerRound) / l.ticketsPerRound : null;
729
1215
  l.expectedPerTicket = perTicket === null ? null : round(perTicket);
730
1216
  // A no-loss ticket comes back whole, so it returns its price and its share of the pot on top.
731
- 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;
732
1221
  add(players.get, l.potToken, run.once ? paid / run.spreadDays : inflow);
733
1222
  }
734
1223
 
735
- const poolRows = [...pools.values()].map((p) => {
1224
+ const poolRows = [...pools.values()].map(p => {
736
1225
  const { fixedOut, rate } = settle(p);
737
1226
  const inflow = p.inflow ?? 0;
738
1227
  const net = inflow - fixedOut;
739
1228
  const row = {
740
1229
  key: p.key,
741
1230
  ...(p.pot ? { pot: p.pot } : {}),
1231
+ ...(p.farm ? { farm: p.farm } : {}),
742
1232
  token: p.currency,
743
1233
  seed: p.seed,
744
1234
  inPerDay: round(inflow),
745
1235
  outPerDay: round(fixedOut + rate * (levels.get(p.key) ?? p.seed)),
746
- ins: p.in.map((f) => ({ by: f.by, perDay: round(f.perDay) })),
747
- 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
+ ],
748
1241
  };
749
1242
  if (rate > 0 && Math.abs(net) < 1e-12) {
750
1243
  // Only a share goes out: it shrinks by that share for ever, and never quite empties.
@@ -776,23 +1269,26 @@ export function simulate(economy, { now = 0 } = {}) {
776
1269
  row.swingPerDay = round(Math.sqrt(p.variance), 6);
777
1270
  const weekly = Math.sqrt(p.variance * 7);
778
1271
  if (row.lasts !== 'runs dry' && p.seed < 3 * weekly)
779
- 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 the box stops selling, and a prize rolled while it ran short pays only what it held, so a bad week stalls sales and shrinks wins 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
+ );
780
1275
  }
781
- 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
+ );
782
1280
  return row;
783
1281
  });
784
1282
 
785
1283
  for (const prize of sharePrizes) {
786
- const row = poolRows.find((r) => r.key === prize.pool);
1284
+ const row = poolRows.find(r => r.key === prize.pool);
787
1285
  const level = row ? (row.settlesAt ?? row.seed) : 0;
788
1286
  if (prize.account) sendTo(destination(prize.account), row?.token, prize.rate * level, prize.by);
789
1287
  else add(players.get, row?.token, prize.rate * level);
790
- const box = prize.box && boxes.find((b) => b.key === prize.box);
791
- if (box) add(box.alsoPays, row?.token, round(prize.perOpen * level));
792
1288
  }
793
- // A share burned is burned at the pool's level, as a share prize is paid.
1289
+ // A share burned is burned at the pool's level, as a share paid out is.
794
1290
  for (const b of shareBurns) {
795
- const row = poolRows.find((r) => r.key === b.pool);
1291
+ const row = poolRows.find(r => r.key === b.pool);
796
1292
  add(burned, row?.token, b.rate * (row ? (row.settlesAt ?? row.seed) : 0));
797
1293
  }
798
1294
  // Stashes: each roll that lands on one takes an NFT out; how long what it starts with lasts.
@@ -800,37 +1296,84 @@ export function simulate(economy, { now = 0 } = {}) {
800
1296
  const b = m.box.get(r.box);
801
1297
  const w = b ? weights(b) : [];
802
1298
  const total = w.reduce((a, x) => a + x, 0) || 1;
803
- (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
+ );
804
1306
  }
805
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).
806
1308
  const stashFills = [];
807
- 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)) {
808
1310
  const w = weights(b);
809
1311
  const total = w.reduce((a, x) => a + x, 0) || 1;
810
- (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
+ );
811
1321
  }
812
- 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}` });
813
- 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}` });
814
- const stashes = m.stashes.map((s) => {
815
- const takes = stashTakes.filter((t) => t.stash === s.key);
816
- 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);
817
1332
  const perDay = takes.reduce((n, t) => n + t.perDay, 0);
818
1333
  const inPerDay = fills.reduce((n, t) => n + t.perDay, 0);
819
1334
  const holds = (s.nfts ?? []).length;
820
1335
  const net = perDay - inPerDay;
821
- const filled = inPerDay > 0 ? `, and takes in about ${round(inPerDay, 4)} a day (${[...new Set(fills.map((t) => t.by))].join(', ')})` : '';
822
- 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 its outcome can’t be won until more are put in.`);
823
- 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 its outcome can’t be won)'}.`);
824
- 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
+ };
825
1356
  });
826
- const contestRolls = m.contests.filter((c) => prizeLine(c.prize)?.gives === 'roll');
827
- 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
+ );
828
1362
  for (const t of m.tokens.values())
829
- 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
+ );
830
1367
 
831
- 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)]));
832
1369
  return {
833
- 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
+ },
834
1377
  boxes,
835
1378
  items,
836
1379
  payments,
@@ -844,24 +1387,50 @@ export function simulate(economy, { now = 0 } = {}) {
844
1387
  stashes,
845
1388
  pools: poolRows,
846
1389
  perDay: {
847
- 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) },
848
- 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
+ },
849
1405
  oracle: { totalSol: round(players.oracle + creator.oracle) },
850
- 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
+ },
851
1411
  burned: totals(burned),
852
1412
  accounts: totals(accounts),
853
1413
  },
854
- fees: { playerSol: PLAYER_FEE, projectSol: PROJECT_FEE, oracleSol: ORACLE, networkPerSignatureSol: NETWORK_FEE, escrowSol: sol(ESCROW_LAMPORTS), ticketLineRentSol: round(TICKET_LINE), 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
+ },
855
1424
  notes,
856
1425
  };
857
1426
  }
858
1427
 
859
1428
  /** What the scenario doesn't name and doesn't count at its default, in words: ["grant claims none", "auction bids 10 a day"]. */
860
1429
  export function unnamedExceptions(sim) {
861
- const named = (k) => Object.hasOwn(sim.scenario.perDay ?? {}, k);
1430
+ const named = k => Object.hasOwn(sim.scenario.perDay ?? {}, k);
862
1431
  return [
863
- (sim.grants ?? []).some((g) => !named(`grant:${g.key}`)) && 'grant claims none',
864
- (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`,
865
1434
  ].filter(Boolean);
866
1435
  }
867
1436
  /** What the scenario assumes for what it doesn't name, in words: "100 a day (grant claims none, auction bids 10 a day)". */
@@ -872,10 +1441,11 @@ export function unnamedWords(sim) {
872
1441
 
873
1442
  /** What becomes of a pool (a row of `simulate`'s pools) over the scenario's `days`, in words: "settles at about 40". */
874
1443
  export function poolFate(p, days) {
875
- const n = (x) => String(round(x, 6));
1444
+ const n = x => String(round(x, 6));
876
1445
  if (p.lasts === 'runs dry') return `runs dry in about ${n(p.lastsDays)} days`;
877
1446
  if (p.lasts === 'settles') return `settles at about ${n(p.settlesAt)}`;
878
- 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)`;
879
1449
  if (p.lasts === 'grows') return `grows to about ${n(p.after)} after ${days} days`;
880
1450
  return 'holds steady';
881
1451
  }
@@ -886,67 +1456,157 @@ export function poolFate(p, days) {
886
1456
  */
887
1457
  export function inAll(c) {
888
1458
  if (!c) return '';
889
- const n = (x) => String(round(x, 6));
890
- 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(', ');
891
- 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})`;
892
- 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;
893
1475
  }
894
1476
 
895
1477
  /** The simulation in plain words. */
896
1478
  export function explain(sim, economy) {
897
1479
  const m = model(economy);
898
- const sym = (key) => m.tokens.get(key)?.symbol ?? key;
1480
+ const sym = key => m.tokens.get(key)?.symbol ?? key;
899
1481
  const lines = [];
900
- const n = (x) => (Number.isFinite(x) ? String(round(x, 6)) : String(x));
901
- 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
+ );
902
1490
  for (const b of sim.boxes) {
903
1491
  const edge = b.houseEdgePercent !== null ? `; the house keeps ${n(b.houseEdgePercent)}% of each price` : '';
904
1492
  const also = Object.entries(b.alsoPays).map(([t, v]) => `${n(v)} ${sym(t)}`);
905
- 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`;
906
1497
  const all = `; ${inAll(b.realCost)}`;
907
1498
  lines.push(`Box “${b.name}”: costs ${n(b.price)} ${sym(b.token)} (${all.slice(2)}), gives back ${back}${edge}.`);
908
- 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 === 'share' ? `${o.percent}% of ${o.pool}` : 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);
909
- for (const o of b.outcomes) lines.push(` ${n(o.chance)}%: ${o.gives === 'bundle' ? `${o.prizes.map(part).join(' + ')}${o.burns ? ', and the payment burned' : ''}` : 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)}`);
910
1515
  if (b.unpriced.length) lines.push(` Give ${b.unpriced.join(', ')} a "value" to count them in the return.`);
1516
+ if (b.limitedStock) lines.push(` ${whenOutWords(m.box.get(b.key))}.`);
911
1517
  }
912
1518
  for (const it of sim.items) lines.push(`Shop “${it.name}”: ${n(it.price)} ${sym(it.token)}; ${inAll(it.realCost)}.`);
913
- 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
+ );
914
1523
  for (const l of sim.lotteries) {
915
- 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`;
916
- 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)}.`);
917
- 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
+ );
918
1538
  }
919
1539
  for (const f of sim.farms) {
920
- 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
+ );
921
1543
  if (f.extras) lines.push(` ${f.extras[0].toUpperCase()}${f.extras.slice(1)}.`);
922
- 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' : ''}.`);
923
- 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}` : ''}.`);
924
- 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.`);
925
- 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.`);
926
- 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).`);
927
1562
  }
928
- 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)` : ''}.`);
929
- 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` : ''}.`);
930
- for (const r of sim.recipes) lines.push(`Recipe “${r.name}”: ${r.takes.join(' + ') || 'nothing'} → ${r.gives.join(' + ') || 'nothing'}, ${n(r.craftsPerDay)} a day.`);
931
- for (const s of sim.schedules) lines.push(`Schedule “${s.key}”: ${s.says}.`);
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) ?? {})}.`);
932
1577
  for (const c of sim.contests) lines.push(`Contest “${c.name}”: ${c.says}.`);
933
1578
  for (const a of sim.auctions ?? []) lines.push(`Auction “${a.name}”: ${a.says}.`);
934
- for (const p of sim.pools) lines.push(`${p.pot ? `The pot of “${p.pot}”` : `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
+ );
935
1583
  const d = sim.perDay;
936
- const list = (map) => Object.entries(map).map(([k, v]) => `${n(v)} ${sym(k)}`).join(', ') || 'nothing';
937
- 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
+ );
938
1591
  if (d.creator.oracleFeesSol) {
939
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.
940
1593
  const what = [
941
1594
  sim.lotteries.length && 'lottery draws',
942
- m.lotteries.some((l) => (l.places ?? []).some((p) => placeOf(p).prize?.gives === 'roll')) && 'the rolls lottery places give',
943
- 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',
944
- 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',
945
1601
  ].filter(Boolean);
946
- const words = what.length > 1 ? `${what.slice(0, -1).join(', ')} and ${what.at(-1)}` : what[0] ?? 'its rolls';
947
- 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
+ );
948
1606
  }
949
- 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
+ );
950
1610
  for (const note of sim.notes) lines.push(note);
951
1611
  return lines.join('\n');
952
1612
  }