@xgamesfun/solana 0.0.0-stage → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/DISCLAIMER.md +41 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +13 -0
  4. package/README.md +172 -2
  5. package/bin/xsolana.mjs +189 -0
  6. package/examples/artwork-auction.json +51 -0
  7. package/examples/blind-forge.json +128 -0
  8. package/examples/coin-flip-arena.json +108 -0
  9. package/examples/duel-arena.json +137 -0
  10. package/examples/elements-set.json +281 -0
  11. package/examples/event-badges.json +67 -0
  12. package/examples/expedition-guild.json +161 -0
  13. package/examples/fair-presale.json +48 -0
  14. package/examples/falling-drop.json +90 -0
  15. package/examples/guild-missions.json +144 -0
  16. package/examples/halving-seasons.json +112 -0
  17. package/examples/hero-trials.json +96 -0
  18. package/examples/ideas.json +789 -0
  19. package/examples/level-ladder.json +65 -0
  20. package/examples/merge-2048.json +128 -0
  21. package/examples/nft-raffle.json +33 -0
  22. package/examples/paper-hands-farm.json +56 -0
  23. package/examples/partner-vault.json +115 -0
  24. package/examples/sacrifice-raffle.json +118 -0
  25. package/examples/scorched-treasury.json +84 -0
  26. package/examples/shelter-fair.json +164 -0
  27. package/examples/spark-pity.json +153 -0
  28. package/examples/stakers-draw.json +125 -0
  29. package/examples/star-fleet.json +157 -0
  30. package/examples/starforce.json +116 -0
  31. package/examples/stud-farm.json +134 -0
  32. package/examples/team-vesting.json +70 -0
  33. package/examples/trade-in-shop.json +168 -0
  34. package/js/approve/details.mjs +262 -0
  35. package/js/approve/ed25519.mjs +36 -0
  36. package/js/approve/live-check.mjs +144 -0
  37. package/js/approve/page.html +86 -0
  38. package/js/approve/page.js +203 -0
  39. package/js/approve/server.mjs +711 -0
  40. package/js/chain/base58.js +73 -0
  41. package/js/chain/borsh.js +261 -0
  42. package/js/chain/bytes.js +141 -0
  43. package/js/chain/core.js +169 -0
  44. package/js/chain/curve.js +52 -0
  45. package/js/chain/lookup.js +273 -0
  46. package/js/chain/mint.js +96 -0
  47. package/js/chain/pda.js +66 -0
  48. package/js/chain/programs.js +102 -0
  49. package/js/chain/rpc.js +217 -0
  50. package/js/chain/sha256.js +68 -0
  51. package/js/chain/token.js +85 -0
  52. package/js/chain/token2022.js +247 -0
  53. package/js/chain/tx.js +224 -0
  54. package/js/client/accounts.js +347 -0
  55. package/js/client/compressed.js +46 -0
  56. package/js/client/grant.js +240 -0
  57. package/js/client/idl/loot.js +2 -0
  58. package/js/client/idl/loot.json +17064 -0
  59. package/js/client/index.js +9 -0
  60. package/js/client/ix.js +2156 -0
  61. package/js/client/pda.js +110 -0
  62. package/js/client/policy.js +48 -0
  63. package/js/client/sizes.js +54 -0
  64. package/js/client/traits.js +44 -0
  65. package/js/connect/connect.mjs +182 -0
  66. package/js/economy/check.mjs +1991 -0
  67. package/js/economy/cost.mjs +170 -0
  68. package/js/economy/examples.mjs +114 -0
  69. package/js/economy/explain.mjs +235 -0
  70. package/js/economy/guide.mjs +231 -0
  71. package/js/economy/legal.mjs +15 -0
  72. package/js/economy/live.mjs +107 -0
  73. package/js/economy/model.mjs +583 -0
  74. package/js/economy/plan.mjs +887 -0
  75. package/js/economy/schema.json +2923 -0
  76. package/js/economy/secrets.mjs +60 -0
  77. package/js/economy/simulate.mjs +952 -0
  78. package/js/economy/txsize.mjs +45 -0
  79. package/js/economy/validate.mjs +85 -0
  80. package/js/index.js +10 -0
  81. package/js/mcp/server.mjs +113 -0
  82. package/js/mcp/tools.mjs +479 -0
  83. package/js/play/index.js +2109 -0
  84. package/js/results/index.js +184 -0
  85. package/js/results/wire.js +90 -0
  86. package/package.json +100 -4
  87. package/release.json +8 -0
  88. package/skills/xsolana-economy/SKILL.md +105 -0
  89. package/skills/xsolana-economy/references/after-launch.md +114 -0
  90. package/skills/xsolana-economy/references/apps.md +157 -0
  91. package/skills/xsolana-economy/references/sentence-to-spec.md +172 -0
  92. package/skills/xsolana-economy/references/telling-the-person.md +93 -0
  93. package/types/chain/base58.d.ts +10 -0
  94. package/types/chain/borsh.d.ts +23 -0
  95. package/types/chain/bytes.d.ts +45 -0
  96. package/types/chain/core.d.ts +50 -0
  97. package/types/chain/curve.d.ts +1 -0
  98. package/types/chain/lookup.d.ts +191 -0
  99. package/types/chain/mint.d.ts +25 -0
  100. package/types/chain/pda.d.ts +25 -0
  101. package/types/chain/programs.d.ts +65 -0
  102. package/types/chain/rpc.d.ts +66 -0
  103. package/types/chain/sha256.d.ts +1 -0
  104. package/types/chain/token.d.ts +141 -0
  105. package/types/chain/token2022.d.ts +53 -0
  106. package/types/chain/tx.d.ts +60 -0
  107. package/types/client/accounts.d.ts +178 -0
  108. package/types/client/compressed.d.ts +29 -0
  109. package/types/client/grant.d.ts +125 -0
  110. package/types/client/idl/loot.d.ts +4467 -0
  111. package/types/client/index.d.ts +8 -0
  112. package/types/client/ix.d.ts +1687 -0
  113. package/types/client/pda.d.ts +91 -0
  114. package/types/client/policy.d.ts +23 -0
  115. package/types/client/sizes.d.ts +13 -0
  116. package/types/client/traits.d.ts +12 -0
  117. package/types/economy/check.d.mts +105 -0
  118. package/types/economy/cost.d.mts +68 -0
  119. package/types/economy/examples.d.mts +27 -0
  120. package/types/economy/explain.d.mts +19 -0
  121. package/types/economy/guide.d.mts +140 -0
  122. package/types/economy/legal.d.mts +12 -0
  123. package/types/economy/live.d.mts +109 -0
  124. package/types/economy/model.d.mts +363 -0
  125. package/types/economy/plan.d.mts +1013 -0
  126. package/types/economy/secrets.d.mts +4 -0
  127. package/types/economy/simulate.d.mts +156 -0
  128. package/types/economy/txsize.d.mts +17 -0
  129. package/types/economy/validate.d.mts +3 -0
  130. package/types/index.d.ts +9 -0
  131. package/types/mcp/tools.d.mts +627 -0
  132. package/types/play/index.d.ts +469 -0
  133. package/types/results/index.d.ts +155 -0
  134. package/types/results/wire.d.ts +122 -0
@@ -0,0 +1,952 @@
1
+ // The quick simulation: expected values from the spec alone, instant. What each box, ticket and craft is worth
2
+ // against its price, the house edge, each pool's money in and out per day and how long it lasts, and who pays what:
3
+ // players, the owner, the platform's fees. Amounts are in whole units of their token. Pure.
4
+ //
5
+ // It assumes the scenario's counts (`scenario.perDay`, default 100 of each player action a day) arrive evenly,
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';
11
+
12
+ const DAY = 86_400;
13
+ const DEFAULT_PER_DAY = 100;
14
+ /** Bids a day on an auction the scenario doesn't name (`auction:<key>`); a grant it doesn't name is claimed by nobody. */
15
+ const DEFAULT_BIDS = 10;
16
+ const sol = (lamports) => Number(lamports) / Number(LAMPORTS_PER_SOL);
17
+ const PLAYER_FEE = sol(FEES.player);
18
+ const PROJECT_FEE = sol(FEES.project);
19
+ const NETWORK_FEE = sol(SIGNATURE_FEE);
20
+ /** MagicBlock's oracle, per random number: a box's open (the player), a lottery's draw, a `roll` (model.mjs ORACLE_FEE). */
21
+ 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);
24
+ /** A collectible's Core account, paid from the SOL the order holds: its rent and Core's creation fee (model.mjs). */
25
+ const COLLECTIBLE_RENT = sol(COLLECTIBLE_RENT_LAMPORTS);
26
+ /** What a collectible a recipe burns gives back: its rent less one byte's (Core leaves a 1-byte account that keeps
27
+ * the creation fee). 189 bytes × 5,080 lamports, measured by the full simulation. */
28
+ 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);
31
+ /** A wallet's token account, which a grant that pays tokens opens from its funds when the winner has none. */
32
+ 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));
36
+ export const round = (x, digits = 9) => (Number.isFinite(x) ? Number(x.toFixed(digits)) : x);
37
+
38
+ /**
39
+ * What one action takes from the player's wallet for good, and what the wallet must hold when it signs. `totalSol`
40
+ * (only for a price in SOL) is the price, the platform's fee, the oracle's, the network's and rent that never comes
41
+ * back (a collectible's own): the one number to tell a person. `holdSol` adds what comes back later (`held`, named by
42
+ * `heldIs`: the SOL an order holds and its account, a ticket purchase's record) and, for SOL, the wrapped-SOL account a
43
+ * purchase opens and closes again. A price that comes back (`priceBack`: a no-loss ticket) is held, not spent.
44
+ */
45
+ function realCostOf({ price, token, isSol, oracle = 0, signatures = 1, rent = 0, rentIs = '', held = 0, heldIs = '', priceBack = false, atLeast = false }) {
46
+ const extrasSol = PLAYER_FEE + oracle + signatures * NETWORK_FEE + rent;
47
+ const hold = extrasSol - rent + held + (isSol ? price + TOKEN_ACCOUNT_RENT : 0);
48
+ const spent = isSol && !priceBack ? price : 0;
49
+ return {
50
+ price: round(price),
51
+ token,
52
+ ...(atLeast ? { atLeast: true } : {}),
53
+ ...(priceBack ? { priceBack: true } : {}),
54
+ playerFeeSol: PLAYER_FEE,
55
+ oracleSol: oracle,
56
+ networkSol: round(signatures * NETWORK_FEE),
57
+ rentSol: round(rent),
58
+ ...(rent ? { rentIs } : {}),
59
+ ...(held && heldIs ? { heldSol: round(held), heldIs } : {}),
60
+ extrasSol: round(extrasSol),
61
+ ...(isSol ? { totalSol: round(spent + extrasSol) } : {}),
62
+ holdSol: round(Math.max(hold, extrasSol + (isSol ? price : 0))),
63
+ };
64
+ }
65
+
66
+ /** Sums { token: amount } maps. */
67
+ const add = (into, token, amount) => {
68
+ if (!amount) return;
69
+ into[token] = (into[token] ?? 0) + amount;
70
+ };
71
+
72
+ /** Prices are capped here so a price that compounds without end stays a number. */
73
+ const PRICE_CAP = 1e18;
74
+ /**
75
+ * The most sales one simulation walks one by one while a price still moves, all its boxes and items together: the
76
+ * counts come from the spec, so the walk is bounded however large they are.
77
+ */
78
+ const MAX_WALK = 10_000_000;
79
+ /** A budget of sales to walk, shared by every salePrices call of one simulation. */
80
+ export const walkBudget = () => ({ left: MAX_WALK });
81
+
82
+ /**
83
+ * What `perDay` sales a day over `days` pay, one every 1/perDay of a day (the first half a gap in), with the part's
84
+ * rise and fall exactly as the program prices them (state.rs `Fall::sell`): each sale pays the price fallen since the
85
+ * later of the fall's start and the last sale, and the price after it is what was paid plus the rise. A price that stops
86
+ * moving (at its cap, or at its floor with no rise) is counted at that price for every sale after; a rise by a fixed step
87
+ * alone (no percent, no fall) is an arithmetic series, summed at once. Every other sale walked comes out of `budget`
88
+ * (one a simulation, shared by its calls), and the walk throws once it runs out. { count, average, first, last }, in
89
+ * whole units.
90
+ */
91
+ export function salePrices(part, perDay, days, fallStartsAfter = 0, budget = walkBudget()) {
92
+ const listed = num(part.price);
93
+ const count = Math.round(perDay * days);
94
+ if (!count || (!part.rise && !part.fall)) return { count, average: listed, first: listed, last: listed };
95
+ const gap = DAY / perDay;
96
+ const growth = num(part.rise?.growthPercent) / 100;
97
+ const step = num(part.rise?.step);
98
+ const every = part.fall ? toSeconds(part.fall.every) : 0;
99
+ const floor = num(part.fall?.floor);
100
+ // a rise by a step alone: the k-th sale after the first pays the listed price plus k steps, up to the cap
101
+ if (!part.fall && !growth && step > 0) {
102
+ const below = Math.min(count - 1, Math.max(0, Math.ceil((PRICE_CAP - listed) / step) - 1));
103
+ 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 };
105
+ }
106
+ // On the sale clock (the default) a sale restarts the countdown, so after the first sale each sale comes `gap` after
107
+ // the last, and one closer than a step of the fall never lowers it. On a shared clock the steps keep their own time.
108
+ const shared = part.fall?.clock === 'shared';
109
+ const falls = Boolean(part.fall && every && num(part.fall.step) && (shared || gap >= every * (1 - 1e-9)));
110
+ let price = listed;
111
+ let since = fallStartsAfter;
112
+ let total = 0;
113
+ let first;
114
+ let paid = listed;
115
+ 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.`);
117
+ const at = (k + 0.5) * gap;
118
+ paid = price;
119
+ const steps = part.fall && every && at > since ? Math.floor((at - since) / every) : 0;
120
+ if (steps && price > floor) paid = Math.max(floor, price - steps * num(part.fall.step));
121
+ // the next countdown starts at this sale, or on a shared clock at the last whole step
122
+ since = shared ? since + steps * every : Math.max(since, at);
123
+ first ??= paid;
124
+ total += paid;
125
+ price = Math.min(PRICE_CAP, paid + paid * growth + step);
126
+ // the next sale pays this price again, and so does every one after: no fall left, and a rise that adds nothing
127
+ if (price === paid && (!falls || price <= floor)) {
128
+ total += paid * (count - k - 1);
129
+ break;
130
+ }
131
+ }
132
+ return { count, average: total / count, first, last: paid };
133
+ }
134
+
135
+ export function simulate(economy, { now = 0 } = {}) {
136
+ const m = model(economy, { now });
137
+ const game = m.currencyKey;
138
+ const days = m.scenario.days ?? 30;
139
+ /** The days a one-off (an auction's sale, a lottery that draws once) is spread over: the scenario's, or one when it has none. */
140
+ const scenarioDays = days > 0 ? days : 1;
141
+ const budget = walkBudget();
142
+ const perDay = (kind, key) => num(m.scenario.perDay?.[`${kind}:${key}`] ?? DEFAULT_PER_DAY);
143
+ const symbol = (key) => m.tokens.get(key)?.symbol ?? key;
144
+
145
+ // 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 });
149
+ const flowIn = (pool, by, amount) => amount && pools.get(pool)?.in.push({ by, perDay: amount });
150
+ 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 } : {}) });
152
+
153
+ const players = { pay: {}, get: {}, fees: 0, network: 0, rent: 0, oracle: 0 };
154
+ const creator = { treasury: {}, fees: 0, network: 0, oracle: 0 };
155
+ const burned = {};
156
+ const accounts = {};
157
+ const platform = { player: 0, project: 0 };
158
+ const notes = [];
159
+ /** Prizes that are a share of a pool: counted for players once each pool's level is known. */
160
+ const sharePrizes = [];
161
+ /** Shares of a pool that are burned (a schedule, a decision): counted once each pool's level is known. */
162
+ const shareBurns = [];
163
+ /** 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
+ const stashTakes = [];
165
+ 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));
168
+ /**
169
+ * A route gives prize line `line` `times` a day (prize.rs), `payer` ({ rent, oracle }) paying what it opens: a
170
+ * collectible's rent, the winner's token account (the bound, not the average), a roll's order (back to the winner at
171
+ * settle) and its oracle fee. Returns the SOL one giving costs its payer.
172
+ */
173
+ const givePrize = (line, times, by, payer) => {
174
+ if (!line || !times) return 0;
175
+ let each = 0;
176
+ if (line.gives === 'item') {
177
+ // A compressed one opens no account.
178
+ each = line.compressed ? 0 : COLLECTIBLE_RENT;
179
+ const worth = m.item.get(line.item)?.value;
180
+ if (worth !== undefined) add(players.get, game, times * num(worth));
181
+ } else if (line.gives === 'tokens') {
182
+ each = TOKEN_ACCOUNT_RENT;
183
+ const token = prizePool(line) ? m.pool.get(prizePool(line))?.currency : line.currency ?? game;
184
+ if (prizePool(line)) flowOut(prizePool(line), by, times * num(line.amount));
185
+ 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
+ } else if (line.gives === 'nft') {
191
+ stashTakes.push({ stash: line.stash, perDay: times, by });
192
+ } else if (line.gives === 'roll') {
193
+ each = orderHeld(line.box);
194
+ prizeTakes.push({ box: line.box, perDay: times, by });
195
+ payer.oracle = (payer.oracle ?? 0) + times * ORACLE;
196
+ }
197
+ payer.rent = (payer.rent ?? 0) + times * each;
198
+ return each;
199
+ };
200
+ /** 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);
202
+ /** What a rising or falling price really comes to, in words. */
203
+ const priceNote = (part, sold) => {
204
+ if (!part.rise && !part.fall) return;
205
+ 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)';
211
+ 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);
213
+ };
214
+
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. */
216
+ const sendTo = (d, token, amount, by) => {
217
+ if (d.kind === 'pool') flowIn(d.key, by, amount);
218
+ else if (d.kind === 'lottery') flowIn(`lottery:${d.key}`, by, amount);
219
+ else if (d.kind === 'burn') add(burned, token, amount);
220
+ 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);
222
+ else add(accounts, `${d.kind}:${d.key}`, amount);
223
+ };
224
+ /** Where `amount` of `token` goes under `split`, per day: its shares (`sendTo`), and the rest to the treasury (or into `restTo`, a pool). */
225
+ const carryOut = (split, token, amount, by, restTo = null) => {
226
+ let left = amount;
227
+ for (const s of sharesOf(split)) {
228
+ const part = (amount * (s.bps ?? 0)) / 10_000;
229
+ left -= part;
230
+ sendTo(s, token, part, by);
231
+ }
232
+ if (restTo) flowIn(restTo, by, left);
233
+ else add(creator.treasury, token, left);
234
+ };
235
+ const playerAction = (count, signatures = 1) => {
236
+ players.fees += count * PLAYER_FEE;
237
+ platform.player += count * PLAYER_FEE;
238
+ players.network += count * signatures * NETWORK_FEE;
239
+ };
240
+
241
+ // ---- boxes
242
+ const boxes = m.boxes.map((b) => {
243
+ const token = b.currency ?? game;
244
+ const opens = perDay('box', b.key);
245
+ // A price that rises or falls is counted at what the scenario's opens really pay, on average.
246
+ const sold = salePrices(b, opens, days, fallStart(b), budget);
247
+ const price = sold.average;
248
+ const w = weights(b);
249
+ const total = w.reduce((a, x) => a + x, 0) || 1;
250
+ let value = 0;
251
+ let burnChance = 0;
252
+ const unpriced = [];
253
+ const other = {};
254
+ // One part of what an outcome gives (model `outcomeParts`), landing with chance `p`: its row, and its flows.
255
+ const partRow = (o, p) => {
256
+ const row = { gives: o.gives, chance: round(p * 100, 6) };
257
+ if (o.gives === 'pay') {
258
+ const prizeToken = m.pool.get(o.pool)?.currency;
259
+ Object.assign(row, { pool: o.pool, amount: num(o.amount), token: prizeToken });
260
+ if (prizeToken === token) value += p * num(o.amount);
261
+ else add(other, prizeToken, p * num(o.amount));
262
+ flowOut(o.pool, `box:${b.key}`, opens * p * num(o.amount));
263
+ } else if (o.gives === 'mint') {
264
+ // Tokens the project mints for the winner: new supply, from no pool.
265
+ const prizeToken = o.currency ?? game;
266
+ Object.assign(row, { amount: num(o.amount), token: prizeToken, minted: true });
267
+ if (prizeToken === token) value += p * num(o.amount);
268
+ 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
+ } else if (o.gives === 'item') {
277
+ const it = m.item.get(o.item);
278
+ const worth = it?.value !== undefined ? num(it.value) : num(it?.price) > 0 ? num(it.price) : null;
279
+ Object.assign(row, { item: o.item, worth });
280
+ if (worth === null) unpriced.push(o.item);
281
+ else if ((b.currency ?? game) === game) value += p * worth;
282
+ else add(other, game, p * worth);
283
+ } else if (o.gives === 'stash' || o.gives === 'nft') {
284
+ // A stash's NFT has whatever worth its collection has: the simulation counts how fast it is given away.
285
+ 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}”`);
287
+ // A box switched off is opened by nobody: only the prizes that roll it take from the stash.
288
+ if (b.active !== false) stashTakes.push({ stash: o.stash, perDay: opens * p, by: `box:${b.key}` });
289
+ } else if (o.gives === 'roll') {
290
+ // A roll won is made when the order settles: whoever settles pays its order, and someone asks the oracle.
291
+ Object.assign(row, { box: o.box, worth: null });
292
+ unpriced.push(`a roll of “${m.box.get(o.box)?.name ?? o.box}”`);
293
+ if (b.active !== false) prizeTakes.push({ box: o.box, perDay: opens * p, by: `box:${b.key}` });
294
+ }
295
+ return row;
296
+ };
297
+ const outcomes = (b.outcomes ?? []).map((o, i) => {
298
+ 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 } : {}) };
302
+ return parts[0] ?? { gives: o.gives, chance: round(p * 100, 6) };
303
+ });
304
+ // 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);
307
+ // 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);
310
+ const mean = rows.reduce((n, o) => n + (o.chance / 100) * o.amount, 0);
311
+ const square = rows.reduce((n, o) => n + (o.chance / 100) * o.amount ** 2, 0);
312
+ if (pools.has(key)) pools.get(key).variance += opens * (square - mean ** 2);
313
+ }
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);
317
+ add(players.pay, token, opens * price);
318
+ add(players.get, token, opens * value);
319
+ 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);
325
+ const signatures = bought ? 1 : 2;
326
+ 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);
329
+ players.rent += opens * collectibles * COLLECTIBLE_RENT;
330
+ const known = !unpriced.length && !shares.length && !Object.keys(other).length;
331
+ // What one open really takes from the player's wallet: the price, the platform's fee, the oracle's and the network's.
332
+ 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 });
337
+ const extrasSol = realCost.extrasSol;
338
+ priceNote(b, sold);
339
+ return {
340
+ key: b.key,
341
+ name: b.name,
342
+ price: num(b.price),
343
+ ...(price !== num(b.price) ? { averagePrice: round(price) } : {}),
344
+ token,
345
+ opensPerDay: opens,
346
+ outcomes,
347
+ expectedBack: round(value),
348
+ returnPercent: price && (value > 0 || !Object.keys(other).length) ? round((value / price) * 100, 4) : null,
349
+ houseEdgePercent: known && price ? round((1 - value / price) * 100, 4) : null,
350
+ alsoPays: other,
351
+ unpriced,
352
+ playerFee: PLAYER_FEE,
353
+ oracleFee: oracle,
354
+ realCost,
355
+ ...(isSol && known && price ? { returnOfRealCostPercent: round((value / (price + extrasSol)) * 100, 4) } : {}),
356
+ };
357
+ });
358
+
359
+ // ---- items sold in the shop
360
+ const items = m.shop
361
+ .map((b) => ({ ...m.item.get(b.shopOf), shopBox: b.key }))
362
+ .map((it) => {
363
+ const sales = perDay('item', it.key);
364
+ const sold = salePrices(it, sales, days, fallStart(it), budget);
365
+ const price = sold.average;
366
+ priceNote(it, sold);
367
+ carryOut(it.split, game, sales * price, `item:${it.key}`);
368
+ add(players.pay, game, sales * price);
369
+ // A shop item is a box of one entry, bought in one step (`buy`): the price split and the item given at once (a
370
+ // collectible minted to them; the project's backend delivers a normal item from the receipt).
371
+ const collectible = (it.kind ?? 'collectible') === 'collectible';
372
+ playerAction(sales, 1);
373
+ if (collectible) players.rent += sales * COLLECTIBLE_RENT;
374
+ const listed = num(it.price);
375
+ 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 };
378
+ });
379
+
380
+ // ---- payments
381
+ const payments = m.payments.map((p) => {
382
+ const token = p.currency ?? game;
383
+ const count = perDay('payment', p.key);
384
+ carryOut(p.split, token, count * num(p.amount), `payment:${p.key}`);
385
+ add(players.pay, token, count * num(p.amount));
386
+ 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 };
389
+ });
390
+
391
+ // ---- recipes
392
+ const recipes = m.recipes.map((r) => {
393
+ const crafts = perDay('recipe', r.key);
394
+ const takes = [];
395
+ const gives = [];
396
+ for (const line of r.in ?? []) {
397
+ if (line.takes === 'burn') players.rent -= crafts * (line.count ?? 1) * BURN_REFUND;
398
+ 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)'}`);
402
+ continue;
403
+ }
404
+ const token = line.currency ?? game;
405
+ takes.push(`${num(line.amount)} ${symbol(token)}`);
406
+ add(players.pay, token, crafts * num(line.amount));
407
+ sendTo(destination(line.to), token, crafts * num(line.amount), `recipe:${r.key}`);
408
+ }
409
+ 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)`);
413
+ }
414
+ for (const line of r.out ?? []) {
415
+ if (line.gives === 'item') {
416
+ gives.push(line.item);
417
+ if (!line.compressed) players.rent += crafts * COLLECTIBLE_RENT;
418
+ const worth = m.item.get(line.item)?.value;
419
+ if (worth !== undefined) add(players.get, game, crafts * num(worth));
420
+ } else if (line.gives === 'tokens') {
421
+ // In its pool's token, or the one it mints.
422
+ const token = prizeToken(line, m)?.key ?? line.currency ?? game;
423
+ gives.push(`${num(line.amount)} ${symbol(token)}`);
424
+ add(players.get, token, crafts * num(line.amount));
425
+ if (line.from?.startsWith('pool:')) flowOut(line.from.slice(5), `recipe:${r.key}`, crafts * num(line.amount));
426
+ } else if (line.gives === 'roll') {
427
+ gives.push(`a roll of “${line.box}”${line.on !== undefined ? ` on collectible ${line.on}` : ''}`);
428
+ } else if (line.gives === 'nft') {
429
+ gives.push(prizeWords(line, m));
430
+ 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
+ }
436
+ }
437
+ // The player's is the only signature: what a craft makes is at its item's and serial's address.
438
+ playerAction(crafts, 1);
439
+ return { key: r.key, name: r.name, craftsPerDay: crafts, takes, gives };
440
+ });
441
+
442
+ // ---- schedules
443
+ const schedules = m.schedules
444
+ .filter((s) => s.active !== false)
445
+ .map((s) => {
446
+ const every = toSeconds(s.every) || DAY;
447
+ const runs = DAY / every;
448
+ creator.network += runs * NETWORK_FEE; // run_schedule is free of the program's fee; whoever sends it pays the network
449
+ if (s.does === 'issue') {
450
+ 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)` : '';
453
+ // Halving every h runs: h runs at the amount, h at half, … (2 × h × amount in all, with no end); with
454
+ // maxRuns, the full halvings it reaches and the runs after the last.
455
+ const halvings = s.halveEvery && s.maxRuns ? Math.floor(s.maxRuns / s.halveEvery) : 0;
456
+ const totalEver = s.halveEvery
457
+ ? s.maxRuns
458
+ ? amount * s.halveEvery * (2 - 2 ** (1 - halvings)) + (s.maxRuns % s.halveEvery) * amount * 2 ** -halvings
459
+ : amount * s.halveEvery * 2
460
+ : s.maxRuns
461
+ ? amount * s.maxRuns
462
+ : 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 };
464
+ }
465
+ if (s.sealed) {
466
+ // A sealed schedule pays from its own vault: what it was funded with, run by run, the last run the rest.
467
+ const t = s.currency ?? m.currencyKey;
468
+ const each = s.percent === undefined ? `${num(s.amount)} ${symbol(t)}` : `${s.percent}% of what its vault holds`;
469
+ 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` };
471
+ }
472
+ const from = s.does === 'burn' ? s.pool : s.from;
473
+ const sym = symbol(m.pool.get(from)?.currency);
474
+ if (s.percent === undefined) {
475
+ // A fixed amount each run.
476
+ const amount = num(s.amount);
477
+ flowOut(from, `schedule:${s.key}`, runs * amount);
478
+ if (s.does === 'move') flowIn(s.to, `schedule:${s.key}`, runs * amount);
479
+ else if (s.does === 'burn') add(burned, m.pool.get(from)?.currency, runs * amount);
480
+ else sendTo(destination(s.to), m.pool.get(from)?.currency, runs * amount, `schedule:${s.key}`);
481
+ 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` };
483
+ }
484
+ const share = (toBps(s.percent) ?? 0) / 10_000;
485
+ if (s.does === 'burn') {
486
+ rateOut(s.pool, `schedule:${s.key}`, runs * share);
487
+ shareBurns.push({ pool: s.pool, rate: runs * share });
488
+ } else if (s.does === 'move' || destination(s.to).kind === 'lottery') {
489
+ // into another pool, or a lottery's pot (a pool named "lottery:<key>"): it enters that pot at the source's level
490
+ rateOut(s.from, `schedule:${s.key}`, runs * share, s.to);
491
+ } else {
492
+ rateOut(s.from, `schedule:${s.key}`, runs * share);
493
+ sharePrizes.push({ pool: s.from, rate: runs * share, account: s.to, by: `schedule:${s.key}` });
494
+ }
495
+ 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` };
497
+ });
498
+
499
+ // ---- stakes: everything that arrives in the rewards pool goes to stakers
500
+ // ---- lotteries: in steady state each round pays out what came in (what the places leave rolls over). One that draws
501
+ // once (no `every`) has one round, from going live to its first draw (the scenario's end at the latest): its tickets,
502
+ // its draw and its pot happen once, spread over the scenario's days like an auction's sale.
503
+ /** Per lottery: whether it draws once, and the days its money a day is spread over. */
504
+ const lotteryRun = new Map();
505
+ const lotteries = m.lotteries.map((l) => {
506
+ const once = !toSeconds(l.every);
507
+ const untilDraw = l.firstDraw ? toTime(l.firstDraw, now) - now : 0;
508
+ const every = once ? Math.min(untilDraw > 0 ? untilDraw : Infinity, scenarioDays * DAY) : toSeconds(l.every);
509
+ const perRound = (perDay('lottery', l.key) * every) / DAY;
510
+ // tickets a day: as the scenario says, or for one round, its tickets spread over the scenario
511
+ const tickets = once ? perRound / scenarioDays : perDay('lottery', l.key);
512
+ const drawsPerDay = once ? 1 / scenarioDays : DAY / every;
513
+ lotteryRun.set(l.key, { once, spreadDays: once ? scenarioDays : every / DAY, seed: num(l.seed) });
514
+ const potToken = l.currency ?? game;
515
+ const ticketToken = l.ticketCurrency ?? potToken;
516
+ const price = num(l.price);
517
+ const split = lotterySplit(l);
518
+ const cut = split.reduce((n, s) => n + (toBps(s.percent) ?? 0), 0) / 10_000;
519
+ add(players.pay, ticketToken, tickets * price);
520
+ // No loss: every ticket comes back to its buyer after the draw; the pot is only what is paid into it.
521
+ if (l.noLoss) add(players.get, ticketToken, tickets * price);
522
+ else if (l.ticketCurrency) add(burned, ticketToken, tickets * price);
523
+ else {
524
+ // The split takes its shares as it does a sale's; the rest goes into the pot (not the treasury).
525
+ carryOut(split, potToken, tickets * price, `lottery:${l.key}`, `lottery:${l.key}`);
526
+ }
527
+ playerAction(tickets);
528
+ players.rent += tickets * TICKET_LINE;
529
+ const placed = (l.places ?? [100]).map(placeOf);
530
+ const places = placed.map((p) => Number(p.percent));
531
+ creator.network += (1 + places.length) * NETWORK_FEE * drawsPerDay; // the draw and each place's payment
532
+ // 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);
534
+ creator.oracle += ORACLE * drawsPerDay; // the draw asks the oracle; whoever sends it (the game's keeper) pays
535
+ // 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
+ // 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;
540
+ return null;
541
+ };
542
+ const prized = placed.filter((p) => prizeLine(p.prize)).map((p) => worthOf(prizeLine(p.prize)));
543
+ // One ticket bought on its own: the fee and the record are per purchase, so a bundle of tickets shares them. The
544
+ // 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 };
547
+ });
548
+
549
+ // ---- decisions with a known rate
550
+ for (const d of m.decisions) {
551
+ const rate = num(d.perDay);
552
+ if (!rate) continue;
553
+ creator.fees += rate * PROJECT_FEE;
554
+ platform.project += rate * PROJECT_FEE;
555
+ creator.network += rate * NETWORK_FEE;
556
+ if (d.does === 'roll') creator.oracle += rate * ORACLE;
557
+ if ((d.does === 'payout' || d.does === 'burn') && d.pool) {
558
+ if (d.percent !== undefined) {
559
+ rateOut(d.pool, `decision:${d.key}`, rate * ((toBps(d.percent) ?? 0) / 10_000));
560
+ if (d.does === 'payout') sharePrizes.push({ pool: d.pool, rate: rate * ((toBps(d.percent) ?? 0) / 10_000) });
561
+ 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));
564
+ const token = m.pool.get(d.pool)?.currency;
565
+ if (d.does === 'payout' && d.amount !== undefined) add(players.get, token, rate * num(d.amount));
566
+ if (d.does === 'burn' && d.amount !== undefined) add(burned, token, rate * num(d.amount));
567
+ }
568
+ }
569
+ const contests = m.contests.map((c) => {
570
+ const rake = sharesOf(c.split).reduce((n, s) => n + (s.bps ?? 0), 0) / 100;
571
+ const opened = c.every ? DAY / toSeconds(c.every) : 0;
572
+ creator.fees += opened * 2 * PROJECT_FEE; // opened and decided
573
+ 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}` };
576
+ });
577
+ // Auctions: each bid tops the last by the least raise, so a run of N bids ends near reserve × (1 + raise)^(N − 1).
578
+ // 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) => {
580
+ 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)));
582
+ const reserve = num(a.reserve);
583
+ const raise = (a.minRaise ?? 5) / 100;
584
+ const price = bids ? reserve * (1 + raise) ** (bids - 1) : 0;
585
+ const kept = sharesOf(a.split).reduce((n, s) => n + (s.bps ?? 0), 0) / 100;
586
+ playerAction(bids / Math.max(runDays, 1));
587
+ creator.fees += PROJECT_FEE / Math.max(runDays, 1);
588
+ platform.project += PROJECT_FEE / Math.max(runDays, 1);
589
+ const token = a.currency ?? game;
590
+ // The sale happens once over the scenario: spread over its days, like everything else per day. Bids of NFTs move
591
+ // no money: the winning bundle goes to the seller (or the treasury), and the rest back to their bidders.
592
+ if (price && !a.bidNfts) {
593
+ add(players.pay, token, price / scenarioDays);
594
+ carryOut(a.split, token, price / scenarioDays, `auction:${a.key}`);
595
+ }
596
+ if (a.bidNfts) {
597
+ return {
598
+ key: a.key,
599
+ name: a.name,
600
+ bidNfts: true,
601
+ runDays: round(runDays),
602
+ bids,
603
+ reserve: num(a.reserve ?? 1),
604
+ 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`,
606
+ };
607
+ }
608
+ return {
609
+ key: a.key,
610
+ name: a.name,
611
+ token,
612
+ runDays: round(runDays),
613
+ bids,
614
+ reserve,
615
+ expectedPrice: round(bids ? price : 0),
616
+ says: !bids
617
+ ? 'no bids: the lot goes home'
618
+ : `about ${bids} bids over ${round(runDays)} days at a ${round(raise * 100)}% least raise ${reserve ? `end near ${round(price)} ${symbol(token)} (from a reserve of ${reserve})` : 'end at whatever players make it (no reserve)'}${kept ? `, of which the split takes ${kept}%` : ''}; every outbid bidder has their bid back at once`,
619
+ };
620
+ });
621
+ 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) => {
624
+ const claims = num(m.scenario.perDay?.[`grant:${g.key}`]);
625
+ playerAction(claims);
626
+ creator.rent = (creator.rent ?? 0) + claims * RECEIPT_RENT;
627
+ // Its prize, paid for by the grant's funds; a roll's order goes back to the winner at settle.
628
+ const line = prizeLine(g.prize ?? g.gives);
629
+ 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)) } : {}) };
631
+ });
632
+ for (const h of m.holds) playerAction(perDay('hold', h.key));
633
+ for (const c of m.contests) playerAction(perDay('contest', c.key));
634
+
635
+ // ---- the pools over time
636
+ const settle = (p) => {
637
+ const fixedIn = p.in.reduce((n, f) => n + f.perDay, 0);
638
+ const fixedOut = p.out.reduce((n, f) => n + f.perDay, 0);
639
+ const rate = p.rate.reduce((n, f) => n + f.perDay, 0);
640
+ return { fixedIn, fixedOut, rate };
641
+ };
642
+ // A move schedule's share enters its target pool: three passes, so a target sees its source's level (and a chain of
643
+ // moves two deep its source's source's).
644
+ const levels = new Map();
645
+ for (let pass = 0; pass < 3; pass += 1)
646
+ 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)));
648
+ const { fixedIn, fixedOut, rate } = settle(p);
649
+ const inflow = fixedIn + moved.reduce((a, b) => a + b, 0);
650
+ const net = inflow - fixedOut;
651
+ const steady = rate > 0 ? net / rate : null;
652
+ levels.set(p.key, steady !== null && steady > 0 ? steady : p.seed);
653
+ p.inflow = inflow;
654
+ }
655
+
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);
662
+ const parts = [
663
+ ...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)',
668
+ ].filter(Boolean);
669
+ return parts.length ? parts.join('; ') : null;
670
+ };
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);
675
+ const staked = num(m.scenario.staked?.[f.key]);
676
+ const perUnit = f.rate ? num(f.rate.perDay) : null;
677
+ const wanted = f.rate ? perUnit * staked : Infinity;
678
+ 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);
680
+ 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) } : {}) }));
683
+ const unit = f.stakes === 'collectibles' ? 'collectible' : symbol(f.stakes);
684
+ // Growth: the days a collectible at x1 needs for each level.
685
+ const growth = listOf(f.growth).map((g) => {
686
+ 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) };
688
+ });
689
+ return {
690
+ key: f.key,
691
+ name: f.name,
692
+ stakes: f.stakes,
693
+ rewardsToken: pool?.currency,
694
+ rewardsPerDay: round(rewards),
695
+ ...(f.rate ? { rate: { perDay: perUnit, minted: Boolean(f.rate.minted) } } : {}),
696
+ tiers,
697
+ ...(staked ? { staked, perUnitPerDay: round(f.rate ? perUnit : rewards / staked), unit } : f.rate ? { perUnitPerDay: perUnit, unit } : {}),
698
+ ...(staked && sameToken ? { yearlyPercent: round(((rewards * 365) / staked) * 100, 4) } : {}),
699
+ ...(growth.length ? { growth } : {}),
700
+ ...(f.runs ? { runs: f.runs } : {}),
701
+ ...(farmEndWords(f) ? { end: farmEndWords(f) } : {}),
702
+ ...(f.lockAttribute ? { lockAttribute: f.lockAttribute } : {}),
703
+ ...(f.upkeepAttribute ? { upkeepAttribute: f.upkeepAttribute } : {}),
704
+ ...(farmExtras(f) ? { extras: farmExtras(f) } : {}),
705
+ 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'}`,
708
+ };
709
+ });
710
+ for (const row of farmRows) add(players.get, row.rewardsToken, row.rewardsPerDay);
711
+ // 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.`);
714
+
715
+ // Lotteries pay what came in, once the pot is in its steady state; one that draws once pays its seed and all that
716
+ // came in, at its one draw.
717
+ for (const l of lotteries) {
718
+ const pool = pools.get(`lottery:${l.key}`);
719
+ const inflow = pool ? pool.inflow : 0;
720
+ const run = lotteryRun.get(l.key);
721
+ const placesTotal = l.places.reduce((a, b) => a + b, 0) / 100;
722
+ 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;
725
+ l.potBeforeDraw = round(potBeforeDraw);
726
+ l.paidPerRound = round(paid);
727
+ l.prizes = l.places.map((pct) => round((potBeforeDraw * pct) / 100));
728
+ const perTicket = l.ticketsPerRound ? (paid + l.prizeWorthPerRound) / l.ticketsPerRound : null;
729
+ l.expectedPerTicket = perTicket === null ? null : round(perTicket);
730
+ // 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;
732
+ add(players.get, l.potToken, run.once ? paid / run.spreadDays : inflow);
733
+ }
734
+
735
+ const poolRows = [...pools.values()].map((p) => {
736
+ const { fixedOut, rate } = settle(p);
737
+ const inflow = p.inflow ?? 0;
738
+ const net = inflow - fixedOut;
739
+ const row = {
740
+ key: p.key,
741
+ ...(p.pot ? { pot: p.pot } : {}),
742
+ token: p.currency,
743
+ seed: p.seed,
744
+ inPerDay: round(inflow),
745
+ 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) }))],
748
+ };
749
+ if (rate > 0 && Math.abs(net) < 1e-12) {
750
+ // Only a share goes out: it shrinks by that share for ever, and never quite empties.
751
+ row.after = round(p.seed * Math.exp(-rate * days));
752
+ row.halfLifeDays = round(Math.log(2) / rate, 2);
753
+ row.lasts = 'shrinks';
754
+ } else if (rate > 0) {
755
+ const steady = net / rate;
756
+ if (steady > 0) {
757
+ row.settlesAt = round(steady);
758
+ row.after = round(steady + (p.seed - steady) * Math.exp(-rate * days));
759
+ row.lasts = 'settles';
760
+ } else {
761
+ // B(t) = B* + (B0 − B*)e^(−rt) reaches 0 at t = ln((B0 − B*) / −B*) / r
762
+ const t = p.seed > 0 ? Math.log((p.seed - steady) / -steady) / rate : 0;
763
+ row.lastsDays = round(t, 2);
764
+ row.after = 0;
765
+ row.lasts = 'runs dry';
766
+ }
767
+ } else if (net < -1e-12) {
768
+ row.lastsDays = round(p.seed / -net, 2);
769
+ row.after = round(Math.max(0, p.seed + net * days));
770
+ row.lasts = 'runs dry';
771
+ } else {
772
+ row.after = round(p.seed + net * days);
773
+ row.lasts = net > 1e-12 ? 'grows' : 'holds';
774
+ }
775
+ if (p.variance > 0) {
776
+ row.swingPerDay = round(Math.sqrt(p.variance), 6);
777
+ const weekly = Math.sqrt(p.variance * 7);
778
+ 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.`);
780
+ }
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)}.`);
782
+ return row;
783
+ });
784
+
785
+ for (const prize of sharePrizes) {
786
+ const row = poolRows.find((r) => r.key === prize.pool);
787
+ const level = row ? (row.settlesAt ?? row.seed) : 0;
788
+ if (prize.account) sendTo(destination(prize.account), row?.token, prize.rate * level, prize.by);
789
+ 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
+ }
793
+ // A share burned is burned at the pool's level, as a share prize is paid.
794
+ for (const b of shareBurns) {
795
+ const row = poolRows.find((r) => r.key === b.pool);
796
+ add(burned, row?.token, b.rate * (row ? (row.settlesAt ?? row.seed) : 0));
797
+ }
798
+ // Stashes: each roll that lands on one takes an NFT out; how long what it starts with lasts.
799
+ for (const r of prizeTakes) {
800
+ const b = m.box.get(r.box);
801
+ const w = b ? weights(b) : [];
802
+ 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 })));
804
+ }
805
+ // 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
+ const stashFills = [];
807
+ for (const b of m.boxes.filter((b) => b.rollsOn && b.active !== false)) {
808
+ const w = weights(b);
809
+ 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}` }));
811
+ }
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);
817
+ const perDay = takes.reduce((n, t) => n + t.perDay, 0);
818
+ const inPerDay = fills.reduce((n, t) => n + t.perDay, 0);
819
+ const holds = (s.nfts ?? []).length;
820
+ 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) } : {}) };
825
+ });
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.`);
828
+ 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.`);
830
+
831
+ const totals = (map) => Object.fromEntries(Object.entries(map).map(([k, v]) => [k, round(v)]));
832
+ return {
833
+ scenario: { days, perDay: m.scenario.perDay ?? {}, defaultPerDay: DEFAULT_PER_DAY, defaultBidsPerDay: DEFAULT_BIDS, defaultClaimsPerDay: 0 },
834
+ boxes,
835
+ items,
836
+ payments,
837
+ recipes,
838
+ schedules,
839
+ lotteries,
840
+ farms: farmRows,
841
+ grants,
842
+ contests,
843
+ auctions,
844
+ stashes,
845
+ pools: poolRows,
846
+ 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) },
849
+ oracle: { totalSol: round(players.oracle + creator.oracle) },
850
+ platform: { playerFeesSol: round(platform.player), projectFeesSol: round(platform.project), totalSol: round(platform.player + platform.project) },
851
+ burned: totals(burned),
852
+ accounts: totals(accounts),
853
+ },
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 },
855
+ notes,
856
+ };
857
+ }
858
+
859
+ /** What the scenario doesn't name and doesn't count at its default, in words: ["grant claims none", "auction bids 10 a day"]. */
860
+ export function unnamedExceptions(sim) {
861
+ const named = (k) => Object.hasOwn(sim.scenario.perDay ?? {}, k);
862
+ 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`,
865
+ ].filter(Boolean);
866
+ }
867
+ /** What the scenario assumes for what it doesn't name, in words: "100 a day (grant claims none, auction bids 10 a day)". */
868
+ export function unnamedWords(sim) {
869
+ const except = unnamedExceptions(sim);
870
+ return `${sim.scenario.defaultPerDay} a day${except.length ? ` (${except.join(', ')})` : ''}`;
871
+ }
872
+
873
+ /** What becomes of a pool (a row of `simulate`'s pools) over the scenario's `days`, in words: "settles at about 40". */
874
+ export function poolFate(p, days) {
875
+ const n = (x) => String(round(x, 6));
876
+ if (p.lasts === 'runs dry') return `runs dry in about ${n(p.lastsDays)} days`;
877
+ 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)`;
879
+ if (p.lasts === 'grows') return `grows to about ${n(p.after)} after ${days} days`;
880
+ return 'holds steady';
881
+ }
882
+
883
+ /**
884
+ * A player's real cost of one action in words: the one number to quote. "about 0.0071 SOL in all (the price, the
885
+ * player fee, …); the wallet needs 0.0151 SOL when it signs, and the rest comes back".
886
+ */
887
+ export function inAll(c) {
888
+ 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;
893
+ }
894
+
895
+ /** The simulation in plain words. */
896
+ export function explain(sim, economy) {
897
+ const m = model(economy);
898
+ const sym = (key) => m.tokens.get(key)?.symbol ?? key;
899
+ 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.`);
902
+ for (const b of sim.boxes) {
903
+ const edge = b.houseEdgePercent !== null ? `; the house keeps ${n(b.houseEdgePercent)}% of each price` : '';
904
+ 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`;
906
+ const all = `; ${inAll(b.realCost)}`;
907
+ 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)}`);
910
+ if (b.unpriced.length) lines.push(` Give ${b.unpriced.join(', ')} a "value" to count them in the return.`);
911
+ }
912
+ 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)}.`);
914
+ 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.`);
918
+ }
919
+ 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}.`);
921
+ 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).`);
927
+ }
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}.`);
932
+ for (const c of sim.contests) lines.push(`Contest “${c.name}”: ${c.says}.`);
933
+ 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)}.`);
935
+ 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.`);
938
+ if (d.creator.oracleFeesSol) {
939
+ // 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
+ const what = [
941
+ 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',
945
+ ].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).`);
948
+ }
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.`);
950
+ for (const note of sim.notes) lines.push(note);
951
+ return lines.join('\n');
952
+ }