@xgamesfun/solana 0.20.1 → 0.24.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 (70) hide show
  1. package/README.md +10 -10
  2. package/examples/coin-flip-arena.json +2 -7
  3. package/examples/elements-set.json +2 -9
  4. package/examples/expedition-guild.json +1 -8
  5. package/examples/guild-missions.json +3 -10
  6. package/examples/halving-seasons.json +4 -9
  7. package/examples/hero-trials.json +1 -4
  8. package/examples/merge-2048.json +5 -5
  9. package/examples/paper-hands-farm.json +4 -16
  10. package/examples/partner-vault.json +1 -4
  11. package/examples/stakers-draw.json +1 -8
  12. package/examples/star-fleet.json +1 -8
  13. package/examples/starforce.json +6 -6
  14. package/js/approve/details.mjs +8 -7
  15. package/js/approve/server.mjs +20 -5
  16. package/js/chain/borsh.js +9 -0
  17. package/js/chain/core.js +14 -2
  18. package/js/chain/lookup.js +8 -2
  19. package/js/chain/rpc.js +4 -2
  20. package/js/client/accounts.js +448 -116
  21. package/js/client/grant.js +125 -66
  22. package/js/client/idl/loot.js +1 -1
  23. package/js/client/idl/loot.json +2360 -2218
  24. package/js/client/index.js +1 -0
  25. package/js/client/ix.js +1204 -473
  26. package/js/client/pda.js +20 -16
  27. package/js/client/policy.js +44 -23
  28. package/js/client/random.js +70 -0
  29. package/js/client/sizes.js +255 -12
  30. package/js/economy/check.mjs +455 -210
  31. package/js/economy/cost.mjs +45 -30
  32. package/js/economy/examples.mjs +2 -2
  33. package/js/economy/explain.mjs +24 -20
  34. package/js/economy/guide.mjs +31 -30
  35. package/js/economy/live.mjs +30 -7
  36. package/js/economy/model.mjs +171 -56
  37. package/js/economy/plan.mjs +380 -112
  38. package/js/economy/schema.json +291 -143
  39. package/js/economy/simulate.mjs +154 -87
  40. package/js/mcp/tools.mjs +14 -8
  41. package/js/play/index.js +1121 -375
  42. package/js/play/keys.js +167 -0
  43. package/package.json +1 -1
  44. package/release.json +5 -5
  45. package/skills/xsolana-economy/SKILL.md +15 -11
  46. package/skills/xsolana-economy/references/after-launch.md +37 -27
  47. package/skills/xsolana-economy/references/apps.md +55 -19
  48. package/skills/xsolana-economy/references/sentence-to-spec.md +30 -22
  49. package/skills/xsolana-economy/references/telling-the-person.md +52 -11
  50. package/types/chain/core.d.ts +3 -1
  51. package/types/chain/lookup.d.ts +5 -2
  52. package/types/chain/rpc.d.ts +3 -1
  53. package/types/client/accounts.d.ts +195 -55
  54. package/types/client/grant.d.ts +72 -27
  55. package/types/client/idl/loot.d.ts +590 -559
  56. package/types/client/index.d.ts +1 -0
  57. package/types/client/ix.d.ts +3862 -234
  58. package/types/client/pda.d.ts +19 -15
  59. package/types/client/policy.d.ts +39 -14
  60. package/types/client/random.d.ts +26 -0
  61. package/types/client/sizes.d.ts +93 -5
  62. package/types/economy/check.d.mts +5 -10
  63. package/types/economy/cost.d.mts +2 -1
  64. package/types/economy/live.d.mts +16 -1
  65. package/types/economy/model.d.mts +105 -37
  66. package/types/economy/plan.d.mts +193 -192
  67. package/types/economy/simulate.d.mts +21 -13
  68. package/types/mcp/tools.d.mts +21 -13
  69. package/types/play/index.d.ts +450 -39
  70. package/types/play/keys.d.ts +51 -0
@@ -4,10 +4,14 @@
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 { boxBuys, COLLECTIBLE_RENT as COLLECTIBLE_RENT_LAMPORTS, drawsOf, settleUnits, holdsRent, outcomeParts, SETTLE_RENT_UNIT, FEES, LAMPORTS_PER_SOL, ORACLE_FEE, RENT_PER_BYTE, SIGNATURE_FEE, destination, listOf, lotterySplit, model, placeOf, prizeLine, prizePool, prizeToken, farmEndWords, lateRunWords, prizeWords, releaseWords, sharesOf, toBps, toSeconds, toTime, weights, whenOutWords } from './model.mjs';
11
15
 
12
16
  const DAY = 86_400;
13
17
  const DEFAULT_PER_DAY = 100;
@@ -19,19 +23,21 @@ const PROJECT_FEE = sol(FEES.project);
19
23
  const NETWORK_FEE = sol(SIGNATURE_FEE);
20
24
  /** MagicBlock's oracle, per random number: a box's open (the player), a lottery's draw, a `roll` (model.mjs ORACLE_FEE). */
21
25
  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);
26
+ /** A lottery purchase's entry in its round: the bytes the buyer adds (not refunded: they go to the treasury when the
27
+ * round closes), with a wallet kept for each share the buyer's own entry names (lottery.rs `entry_width`). */
28
+ const ticketLine = (l) => sol(BigInt(entryWidth(lotterySplit(l).some((x) => String(x.to).startsWith('chosen')), Boolean(l.ticketNft))) * RENT_PER_BYTE);
24
29
  /** A collectible's Core account, paid from the SOL the order holds: its rent and Core's creation fee (model.mjs). */
25
30
  const COLLECTIBLE_RENT = sol(COLLECTIBLE_RENT_LAMPORTS);
26
31
  /** What a collectible a recipe burns gives back: its rent less one byte's (Core leaves a 1-byte account that keeps
27
32
  * the creation fee). 189 bytes × 5,080 lamports, measured by the full simulation. */
28
33
  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);
34
+ /** What a grant's claim adds to its lists: the wallet's limit record (its first claim) and a record of the round paid
35
+ * (none for an open grant's claim). Back to the owner when the shards close after the grant. */
36
+ const claimRecordsRent = (g) => sol(BigInt(LIMIT_RECORD_BYTES + ((g.vouchers ?? []).length || (g.keys ?? []).length || (g.list ?? []).length ? ROUND_RECORD_BYTES : 0)) * RENT_PER_BYTE);
31
37
  /** A wallet's token account, which a grant that pays tokens opens from its funds when the winner has none. */
32
38
  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);
39
+ /** An order of box `b`'s rent (`orderBytes`), returned at settle or refund: an open's (`opened`, sized as `open_box` sizes it), or a roll's made inside another call. */
40
+ const orderRent = (b, m, opened = false) => sol((BigInt(boxOrderBytes(b, m, 1, { opened })) + 128n) * RENT_PER_BYTE);
35
41
  const num = (v) => (v === undefined || v === null ? 0 : Number(v));
36
42
  export const round = (x, digits = 9) => (Number.isFinite(x) ? Number(x.toFixed(digits)) : x);
37
43
 
@@ -146,6 +152,8 @@ export function simulate(economy, { now = 0 } = {}) {
146
152
  const pools = new Map(m.pools.map((p) => [p.key, { key: p.key, currency: p.currency, seed: num(p.seed), in: [], out: [], rate: [], variance: 0 }]));
147
153
  // Each lottery's pot is an account of its own, counted here as a pool named "lottery:<key>".
148
154
  for (const l of m.lotteries) pools.set(`lottery:${l.key}`, { key: `lottery:${l.key}`, pot: l.name, currency: l.currency ?? game, seed: num(l.seed), in: [], out: [], rate: [], variance: 0 });
155
+ // Each farm's rewards vault is an account of its own too, counted as a pool named "farm:<key>".
156
+ for (const f of m.farms) pools.set(`farm:${f.key}`, { key: `farm:${f.key}`, farm: f.name, currency: f.rewardsToken, seed: num(f.seed), in: [], out: [], rate: [], variance: 0 });
149
157
  const flowIn = (pool, by, amount) => amount && pools.get(pool)?.in.push({ by, perDay: amount });
150
158
  const flowOut = (pool, by, amount) => amount && pools.get(pool)?.out.push({ by, perDay: amount });
151
159
  const rateOut = (pool, by, perDayShare, to) => perDayShare && pools.get(pool)?.rate.push({ by, perDay: perDayShare, ...(to ? { to } : {}) });
@@ -156,15 +164,16 @@ export function simulate(economy, { now = 0 } = {}) {
156
164
  const accounts = {};
157
165
  const platform = { player: 0, project: 0 };
158
166
  const notes = [];
159
- /** Prizes that are a share of a pool: counted for players once each pool's level is known. */
167
+ /** 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
168
  const sharePrizes = [];
161
169
  /** Shares of a pool that are burned (a schedule, a decision): counted once each pool's level is known. */
162
170
  const shareBurns = [];
163
171
  /** 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
172
  const stashTakes = [];
165
173
  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));
174
+ /** The SOL a prize's order holds while it waits (its rent), back to the winner at settle. Whoever settles it pays a
175
+ * collectible's rent. */
176
+ const orderHeld = (box) => orderRent(m.box.get(box), m);
168
177
  /**
169
178
  * A route gives prize line `line` `times` a day (prize.rs), `payer` ({ rent, oracle }) paying what it opens: a
170
179
  * collectible's rent, the winner's token account (the bound, not the average), a roll's order (back to the winner at
@@ -183,10 +192,6 @@ export function simulate(economy, { now = 0 } = {}) {
183
192
  const token = prizePool(line) ? m.pool.get(prizePool(line))?.currency : line.currency ?? game;
184
193
  if (prizePool(line)) flowOut(prizePool(line), by, times * num(line.amount));
185
194
  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
195
  } else if (line.gives === 'nft') {
191
196
  stashTakes.push({ stash: line.stash, perDay: times, by });
192
197
  } else if (line.gives === 'roll') {
@@ -212,10 +217,11 @@ export function simulate(economy, { now = 0 } = {}) {
212
217
  notes.push(sold.last >= 10 * num(part.price) ? `${said} It ends ${sold.last >= PRICE_CAP ? 'beyond any price' : `${round(sold.last / num(part.price), 1)} times`} where it started: players stop buying long before that. A smaller growth keeps it in reach.` : said);
213
218
  };
214
219
 
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. */
220
+ /** 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
221
  const sendTo = (d, token, amount, by) => {
217
222
  if (d.kind === 'pool') flowIn(d.key, by, amount);
218
223
  else if (d.kind === 'lottery') flowIn(`lottery:${d.key}`, by, amount);
224
+ else if (d.kind === 'farm') flowIn(`farm:${d.key}`, by, amount);
219
225
  else if (d.kind === 'burn') add(burned, token, amount);
220
226
  else if (d.kind === 'treasury') add(creator.treasury, token, amount);
221
227
  else if (d.kind === 'chosen') add(accounts, d.key ? `the holder of the ${m.item.get(d.key)?.name ?? d.key} the buyer names` : `the wallet the ${String(by).startsWith('payment:') ? 'payer' : 'buyer'} names`, amount);
@@ -238,6 +244,76 @@ export function simulate(economy, { now = 0 } = {}) {
238
244
  players.network += count * signatures * NETWORK_FEE;
239
245
  };
240
246
 
247
+ /**
248
+ * What box `b`'s items with a supply, stashes and named NFTs come to over the scenario, with `opens` opens a day and
249
+ * its own opens alone taking from them (pools are walked with their own rows): when an outcome runs out, a box that
250
+ * stops selling stops (`stopsSellingInDays`), and one that draws among what is left goes on with the rest's chances
251
+ * rising (`runsOut`: each outcome's day, and every chance from then), until nothing in it is left. An item whose odds
252
+ * follow its stock (`stockOdds`) never runs out here: its chance shrinks instead. Null when nothing it gives runs out.
253
+ */
254
+ const stockWalk = (b, opens) => {
255
+ const w = weights(b);
256
+ const draws = drawsOf(b);
257
+ // What each stock holds at the start, and what one draw of each outcome takes from it.
258
+ const left = new Map();
259
+ const stockOf = (x) => {
260
+ const supply = Number(m.item.get(x.item)?.supply ?? 0);
261
+ if (x.gives === 'item' && supply > 0 && !b.stockOdds) return [`item:${x.item}`, supply];
262
+ if (x.gives === 'stash' && m.stash.get(x.stash)) return [`stash:${x.stash}`, (m.stash.get(x.stash).nfts ?? []).length];
263
+ if (x.gives === 'nft') return [`nft:${x.nft}`, 1];
264
+ return null;
265
+ };
266
+ const needs = (b.outcomes ?? []).map((o) => {
267
+ const need = new Map();
268
+ for (const [key, holds] of outcomeParts(o).map(stockOf).filter(Boolean)) {
269
+ left.set(key, holds);
270
+ need.set(key, (need.get(key) ?? 0) + 1);
271
+ }
272
+ return need;
273
+ });
274
+ if (!left.size || !(opens > 0)) return null;
275
+ const stops = b.whenOut !== 'drawLeft';
276
+ const there = (i) => [...needs[i]].every(([k, n]) => left.get(k) >= n);
277
+ const chances = (alive) => {
278
+ const sum = alive.reduce((n, i) => n + w[i], 0);
279
+ return w.map((x, i) => (sum && alive.includes(i) ? round((x / sum) * 100, 6) : 0));
280
+ };
281
+ let alive = w.map((_, i) => i).filter(there);
282
+ let day = 0;
283
+ const runsOut = [];
284
+ const out = (i) => ({ outcome: i, gives: (b.outcomes[i].gives === 'bundle' ? (b.outcomes[i].prizes ?? []).map((x) => x.item ?? x.stash ?? x.gives) : [b.outcomes[i].item ?? b.outcomes[i].stash ?? b.outcomes[i].gives]).join(' + ') });
285
+ const gone = w.map((_, i) => i).filter((i) => !alive.includes(i));
286
+ if (gone.length) {
287
+ if (stops) return { whenOut: 'stopSales', stopsSellingInDays: 0, by: gone.map(out) };
288
+ runsOut.push({ inDays: 0, outcomes: gone.map(out), chancesAfter: chances(alive) });
289
+ }
290
+ while (alive.length) {
291
+ const sum = alive.reduce((n, i) => n + w[i], 0);
292
+ // Each stock's take a day, by the outcomes still there.
293
+ const rate = new Map();
294
+ for (const i of alive) for (const [k, n] of needs[i]) rate.set(k, (rate.get(k) ?? 0) + (opens * draws * w[i] * n) / sum);
295
+ const next = [...rate].map(([k, r]) => ({ k, t: left.get(k) / r })).sort((a, z) => a.t - z.t)[0];
296
+ if (!next || day + next.t > days) break;
297
+ day += next.t;
298
+ for (const [k, r] of rate) left.set(k, Math.max(0, left.get(k) - r * next.t));
299
+ left.set(next.k, 0);
300
+ const ended = alive.filter((i) => !there(i));
301
+ alive = alive.filter(there);
302
+ if (stops) return { whenOut: 'stopSales', stopsSellingInDays: round(day, 4), by: ended.map(out) };
303
+ runsOut.push({ inDays: round(day, 4), outcomes: ended.map(out), chancesAfter: chances(alive) });
304
+ }
305
+ if (stops) return null;
306
+ return runsOut.length ? { whenOut: 'drawLeft', runsOut, ...(alive.length ? {} : { stopsSellingInDays: runsOut.at(-1).inDays }) } : null;
307
+ };
308
+ /** The walk of a box's stock in words (`stockWalk`). */
309
+ const stockNote = (b, walk) => {
310
+ const named = (list) => list.map((x) => `outcome ${x.outcome} (${x.gives})`).join(', ');
311
+ if (walk.whenOut === 'stopSales')
312
+ return `“${b.name}” stops selling ${walk.stopsSellingInDays ? `in about ${walk.stopsSellingInDays} days` : 'from the start'}, when ${named(walk.by)} runs out (its own opens alone): it isn’t sold while any outcome couldn’t cover a purchase and those still waiting for the oracle ("whenOut": "stopSales"), so every roll is on the odds as written. Restock it, or edit the box, to sell again; "drawLeft" would keep selling, the rest’s chances rising.`;
313
+ const steps = walk.runsOut.map((r) => `${named(r.outcomes)} ${r.inDays ? `in about ${r.inDays} days` : 'from the start'}${r.chancesAfter.some((c) => c > 0) ? `, then ${r.chancesAfter.map((c, i) => (c > 0 ? `outcome ${i} ${round(c, 2)}%` : null)).filter(Boolean).join(', ')}` : ''}`);
314
+ return `“${b.name}” keeps selling as its outcomes run out ("whenOut": "drawLeft"; its own opens alone): ${steps.join('; ')}${walk.stopsSellingInDays !== undefined ? `; with nothing left, it stops selling` : ''}. Each roll draws among what is left by the written weights, so a player can empty a common outcome to make a rare one likelier; "stopSales" keeps the odds as written.`;
315
+ };
316
+
241
317
  // ---- boxes
242
318
  const boxes = m.boxes.map((b) => {
243
319
  const token = b.currency ?? game;
@@ -248,7 +324,6 @@ export function simulate(economy, { now = 0 } = {}) {
248
324
  const w = weights(b);
249
325
  const total = w.reduce((a, x) => a + x, 0) || 1;
250
326
  let value = 0;
251
- let burnChance = 0;
252
327
  const unpriced = [];
253
328
  const other = {};
254
329
  // One part of what an outcome gives (model `outcomeParts`), landing with chance `p`: its row, and its flows.
@@ -266,13 +341,6 @@ export function simulate(economy, { now = 0 } = {}) {
266
341
  Object.assign(row, { amount: num(o.amount), token: prizeToken, minted: true });
267
342
  if (prizeToken === token) value += p * num(o.amount);
268
343
  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
344
  } else if (o.gives === 'item') {
277
345
  const it = m.item.get(o.item);
278
346
  const worth = it?.value !== undefined ? num(it.value) : num(it?.price) > 0 ? num(it.price) : null;
@@ -297,13 +365,11 @@ export function simulate(economy, { now = 0 } = {}) {
297
365
  const outcomes = (b.outcomes ?? []).map((o, i) => {
298
366
  const p = w[i] / total;
299
367
  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 } : {}) };
368
+ if (o.gives === 'bundle') return { gives: 'bundle', chance: round(p * 100, 6), prizes: parts };
302
369
  return parts[0] ?? { gives: o.gives, chance: round(p * 100, 6) };
303
370
  });
304
371
  // Every part of every outcome, each with its outcome's chance.
305
372
  const partsOf = outcomes.flatMap((o) => (o.gives === 'bundle' ? o.prizes : [o]));
306
- const shares = partsOf.filter((o) => o.shareOfPool);
307
373
  // How much the prizes swing from day to day, per pool: opens × (E[prize²] − E[prize]²).
308
374
  for (const key of new Set(partsOf.filter((o) => o.gives === 'pay').map((o) => o.pool))) {
309
375
  const rows = partsOf.filter((o) => o.gives === 'pay' && o.pool === key);
@@ -311,31 +377,30 @@ export function simulate(economy, { now = 0 } = {}) {
311
377
  const square = rows.reduce((n, o) => n + (o.chance / 100) * o.amount ** 2, 0);
312
378
  if (pools.has(key)) pools.get(key).variance += opens * (square - mean ** 2);
313
379
  }
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);
380
+ // Money: the payment is split (a share may burn it).
381
+ carryOut(b.split, token, opens * price, `box:${b.key}`);
317
382
  add(players.pay, token, opens * price);
318
383
  add(players.get, token, opens * value);
319
384
  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);
385
+ // Two signatures: the open, then the settle that carries the outcome out (a throwaway key the open funds sends it).
386
+ // A box that needs no roll is bought in one (`buy`), with no order; every open asks the oracle.
387
+ const bought = boxBuys(b, m);
325
388
  const signatures = bought ? 1 : 2;
326
389
  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);
390
+ if (!bought) players.oracle += opens * ORACLE;
391
+ const collectibles = drawsOf(b) * partsOf.filter((o) => holdsRent(o, m) && m.item.get(o.item)?.kind !== 'normal').reduce((n, o) => n + o.chance / 100, 0);
329
392
  players.rent += opens * collectibles * COLLECTIBLE_RENT;
330
- const known = !unpriced.length && !shares.length && !Object.keys(other).length;
393
+ const known = !unpriced.length && !Object.keys(other).length;
331
394
  // What one open really takes from the player's wallet: the price, the platform's fee, the oracle's and the network's.
332
395
  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 });
396
+ const oracle = bought ? 0 : ORACLE;
397
+ // A collectible's rent comes out of the SOL the open lends its settle key: on average, the chance of one.
398
+ const realCost = realCostOf({ price, token, isSol, oracle, signatures, rent: collectibles * COLLECTIBLE_RENT, rentIs: collectibles < 1 ? 'the collectible’s rent, when it gives one' : 'the collectible’s rent', held: bought ? 0 : sol(SETTLE_RENT_UNIT) * settleUnits(b, m) + orderRent(b, m, true) });
337
399
  const extrasSol = realCost.extrasSol;
338
400
  priceNote(b, sold);
401
+ // What it gives that runs out, as the box says (`whenOut`): it stops selling, or draws among what is left.
402
+ const walk = !bought && b.active !== false ? stockWalk(b, opens) : null;
403
+ if (walk) notes.push(stockNote(b, walk));
339
404
  return {
340
405
  key: b.key,
341
406
  name: b.name,
@@ -352,6 +417,10 @@ export function simulate(economy, { now = 0 } = {}) {
352
417
  playerFee: PLAYER_FEE,
353
418
  oracleFee: oracle,
354
419
  realCost,
420
+ // What it gives can run out, and what the box does then (`whenOut`, `whenOutWords`).
421
+ ...(!bought && outcomes.flatMap((o) => (o.gives === 'bundle' ? o.prizes : [o])).some((o) => o.gives === 'pay' || o.gives === 'stash' || o.gives === 'nft' || (o.gives === 'item' && Number(m.item.get(o.item)?.supply ?? 0) > 0)) ? { limitedStock: true } : {}),
422
+ ...(!bought ? { whenOut: b.whenOut === 'drawLeft' ? 'drawLeft' : 'stopSales' } : {}),
423
+ ...(walk ? { stock: walk } : {}),
355
424
  ...(isSol && known && price ? { returnOfRealCostPercent: round((value / (price + extrasSol)) * 100, 4) } : {}),
356
425
  };
357
426
  });
@@ -428,10 +497,6 @@ export function simulate(economy, { now = 0 } = {}) {
428
497
  } else if (line.gives === 'nft') {
429
498
  gives.push(prizeWords(line, m));
430
499
  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
500
  }
436
501
  }
437
502
  // The player's is the only signature: what a craft makes is at its item's and serial's address.
@@ -448,7 +513,7 @@ export function simulate(economy, { now = 0 } = {}) {
448
513
  creator.network += runs * NETWORK_FEE; // run_schedule is free of the program's fee; whoever sends it pays the network
449
514
  if (s.does === 'issue') {
450
515
  const amount = num(s.amount);
451
- flowIn(s.pool, `schedule:${s.key}`, runs * amount);
516
+ flowIn(destination(s.to ?? '').kind === 'farm' ? s.to : s.pool, `schedule:${s.key}`, runs * amount);
452
517
  const halving = s.halveEvery ? `, halving every ${s.halveEvery} runs (every ${round((s.halveEvery * every) / DAY, 2)} days)` : '';
453
518
  // Halving every h runs: h runs at the amount, h at half, … (2 × h × amount in all, with no end); with
454
519
  // maxRuns, the full halvings it reaches and the runs after the last.
@@ -496,7 +561,7 @@ export function simulate(economy, { now = 0 } = {}) {
496
561
  return { key: s.key, does: s.does, percentPerDay: round(runs * s.percent, 6), says: `${where} ${s.percent}% of ${from} every ${round(every / 3600, 3)} h` };
497
562
  });
498
563
 
499
- // ---- stakes: everything that arrives in the rewards pool goes to stakers
564
+ // ---- stakes: everything that arrives in a farm’s rewards vault goes to its stakers
500
565
  // ---- lotteries: in steady state each round pays out what came in (what the places leave rolls over). One that draws
501
566
  // once (no `every`) has one round, from going live to its first draw (the scenario's end at the latest): its tickets,
502
567
  // its draw and its pot happen once, spread over the scenario's days like an auction's sale.
@@ -525,7 +590,9 @@ export function simulate(economy, { now = 0 } = {}) {
525
590
  carryOut(split, potToken, tickets * price, `lottery:${l.key}`, `lottery:${l.key}`);
526
591
  }
527
592
  playerAction(tickets);
528
- players.rent += tickets * TICKET_LINE;
593
+ // A lottery that holds SOL for its rounds (`roundRent`, on by default) pays each entry's bytes: the buyer pays none.
594
+ const entryRent = roundRentOf(l, m) > 0n ? 0 : ticketLine(l);
595
+ players.rent += tickets * entryRent;
529
596
  const placed = (l.places ?? [100]).map(placeOf);
530
597
  const places = placed.map((p) => Number(p.percent));
531
598
  creator.network += (1 + places.length) * NETWORK_FEE * drawsPerDay; // the draw and each place's payment
@@ -542,8 +609,8 @@ export function simulate(economy, { now = 0 } = {}) {
542
609
  const prized = placed.filter((p) => prizeLine(p.prize)).map((p) => worthOf(prizeLine(p.prize)));
543
610
  // One ticket bought on its own: the fee and the record are per purchase, so a bundle of tickets shares them. The
544
611
  // 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 };
612
+ const realCost = realCostOf({ price, token: ticketToken, isSol: Boolean(m.tokens.get(ticketToken)?.wsol), rent: entryRent, rentIs: 'the purchase’s entry in the round', ...(l.noLoss ? { heldIs: 'the ticket’s price' } : {}), priceBack: Boolean(l.noLoss) });
613
+ return { key: l.key, name: l.name, price, token: ticketToken, potToken, ...(l.minSold ? { minSold: l.minSold } : {}), ticketsPerDay: round(tickets, 6), ticketsPerRound: round(perRound, 4), roundDays: round(every / DAY, 4), places, placePrizes: placed.map((p) => (p.prize ? prizeWords(p.prize, m) : null)), prizeWorthPerRound: round(prized.reduce((n, w) => n + (w ?? 0), 0)), ...(prized.includes(null) ? { unpricedPrizes: true } : {}), cutPercent: num(l.cutPercent), splitPercent: round(cut * 100, 4), drawsOnce: once, roundRentSol: round(Number(roundRentOf(l, m)) / 1e9), ...(l.ticketNft ? { ticketNft: true } : {}), ...(l.noLoss ? { noLoss: true } : {}), realCost };
547
614
  });
548
615
 
549
616
  // ---- decisions with a known rate
@@ -619,15 +686,15 @@ export function simulate(economy, { now = 0 } = {}) {
619
686
  };
620
687
  });
621
688
  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).
689
+ // 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).
623
690
  const grants = m.grants.map((g) => {
624
691
  const claims = num(m.scenario.perDay?.[`grant:${g.key}`]);
625
692
  playerAction(claims);
626
- creator.rent = (creator.rent ?? 0) + claims * RECEIPT_RENT;
693
+ creator.rent = (creator.rent ?? 0) + claims * claimRecordsRent(g);
627
694
  // Its prize, paid for by the grant's funds; a roll's order goes back to the winner at settle.
628
695
  const line = prizeLine(g.prize ?? g.gives);
629
696
  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)) } : {}) };
697
+ return { key: g.key, name: g.name, claimsPerDay: claims, gives: prizeWords(line, m), fundsPerClaimSol: round(claimRecordsRent(g) + each), ...(line?.gives === 'roll' ? { orderHeldSol: round(orderHeld(line.box)) } : {}) };
631
698
  });
632
699
  for (const h of m.holds) playerAction(perDay('hold', h.key));
633
700
  for (const c of m.contests) playerAction(perDay('contest', c.key));
@@ -653,48 +720,48 @@ export function simulate(economy, { now = 0 } = {}) {
653
720
  p.inflow = inflow;
654
721
  }
655
722
 
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. */
723
+ // Farms: everything that arrives in a farm's rewards vault is released to the seats over the farm's release span (its
724
+ // longest lock, a day at least), by weight (stake × tier × boosts) as it is released; or a fixed rate for each seat,
725
+ // or with `shared` for the whole farm (minted, or from the vault while it lasts). In a steady state the release pays
726
+ // what arrives, a day at a time.
727
+ /** A farm's ways out, in words (farm.rs): an early-exit fee, compounding; or null. */
660
728
  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);
729
+ const exits = (f.tiers ?? []).map((t, i) => (t.exitFee ? `tier ${i + 1} may leave before its ${t.lock} lock by leaving ${t.exitFee}% of what it takes out (${f.exitFeeTo === 'burn' ? 'burned' : f.exitFeeTo === 'rewards' ? 'into its own rewards vault' : `to ${String(f.exitFeeTo).replace(/^pool:/, '')}`}), keeping the rewards it was credited` : null)).filter(Boolean);
662
730
  const parts = [
663
731
  ...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)',
732
+ f.stakes !== 'collectibles' && f.rewardsToken === f.stakes && 'its rewards can be compounded into the stake without restarting the lock',
733
+ (f.tiers ?? []).some((t) => t.earnsFor) && `a seat earns only for its tier’s term (${(f.tiers ?? []).map((t) => t.earnsFor ?? 'no end').join(', ')}) after its lock started`,
668
734
  ].filter(Boolean);
669
735
  return parts.length ? parts.join('; ') : null;
670
736
  };
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(', ');
737
+ /** What a seat of `f` can give up (farm.rs `Farm::give`): a forfeit, a watched NFT gone. */
738
+ const giveBacks = (f) => ['a forfeit', f.custody === 'watch' && 'a watched NFT sold'].filter(Boolean).join(', ');
673
739
  const farmRows = m.farms.map((f) => {
674
- const pool = pools.get(f.rewards);
740
+ const pool = pools.get(`farm:${f.key}`);
675
741
  const staked = num(m.scenario.staked?.[f.key]);
676
742
  const perUnit = f.rate ? num(f.rate.perDay) : null;
677
- const wanted = f.rate ? perUnit * staked : Infinity;
743
+ const wanted = !f.rate ? Infinity : f.rate.shared ? (staked ? perUnit : 0) : perUnit * staked;
678
744
  const free = pool ? Math.max(0, pool.inflow - pool.out.reduce((n, x) => n + x.perDay, 0)) : 0;
679
745
  const rewards = f.rate?.minted ? wanted : Math.min(wanted, f.rate ? free + (pool?.seed ?? 0) / Math.max(1, m.scenario.days ?? 30) : free);
680
746
  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) } : {}) }));
747
+ const sameToken = f.rewardsToken === f.stakes;
748
+ const tiers = (f.tiers ?? [{ lock: 0, multiplier: 1 }]).map((t) => ({ lock: t.lock, multiplier: t.multiplier ?? 1, ...(t.earnsFor ? { earnsFor: t.earnsFor } : {}), ...(prizeLine(t.prize) ? { prize: prizeWords(t.prize, m) } : {}) }));
683
749
  const unit = f.stakes === 'collectibles' ? 'collectible' : symbol(f.stakes);
684
750
  // Growth: the days a collectible at x1 needs for each level.
685
751
  const growth = listOf(f.growth).map((g) => {
686
752
  const levels = (g.levels ?? []).map((points, i) => ({ level: i + 1, points, days: round(points / g.perDay, 2) }));
687
753
  return { attribute: g.attribute ?? 'xp', perDay: g.perDay, ...(levels.length ? { level: g.level ?? 'level', levels } : {}), streak: Boolean(g.streak) };
688
754
  });
755
+ const perSeat = !f.rate ? null : f.rate.shared ? (staked ? perUnit / staked : null) : perUnit;
689
756
  return {
690
757
  key: f.key,
691
758
  name: f.name,
692
759
  stakes: f.stakes,
693
- rewardsToken: pool?.currency,
760
+ rewardsToken: f.rewardsToken,
694
761
  rewardsPerDay: round(rewards),
695
- ...(f.rate ? { rate: { perDay: perUnit, minted: Boolean(f.rate.minted) } } : {}),
762
+ ...(f.rate ? { rate: { perDay: perUnit, minted: Boolean(f.rate.minted), ...(f.rate.shared ? { shared: true } : {}), ...(f.rate.halveEvery ? { halveEvery: f.rate.halveEvery } : {}) } } : {}),
696
763
  tiers,
697
- ...(staked ? { staked, perUnitPerDay: round(f.rate ? perUnit : rewards / staked), unit } : f.rate ? { perUnitPerDay: perUnit, unit } : {}),
764
+ ...(staked ? { staked, perUnitPerDay: round(f.rate ? perSeat : rewards / staked), unit } : f.rate && !f.rate.shared ? { perUnitPerDay: perUnit, unit } : {}),
698
765
  ...(staked && sameToken ? { yearlyPercent: round(((rewards * 365) / staked) * 100, 4) } : {}),
699
766
  ...(growth.length ? { growth } : {}),
700
767
  ...(f.runs ? { runs: f.runs } : {}),
@@ -703,8 +770,8 @@ export function simulate(economy, { now = 0 } = {}) {
703
770
  ...(f.upkeepAttribute ? { upkeepAttribute: f.upkeepAttribute } : {}),
704
771
  ...(farmExtras(f) ? { extras: farmExtras(f) } : {}),
705
772
  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'}`,
773
+ ? `${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` : ''}`
774
+ : `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
775
  };
709
776
  });
710
777
  for (const row of farmRows) add(players.get, row.rewardsToken, row.rewardsPerDay);
@@ -739,6 +806,7 @@ export function simulate(economy, { now = 0 } = {}) {
739
806
  const row = {
740
807
  key: p.key,
741
808
  ...(p.pot ? { pot: p.pot } : {}),
809
+ ...(p.farm ? { farm: p.farm } : {}),
742
810
  token: p.currency,
743
811
  seed: p.seed,
744
812
  inPerDay: round(inflow),
@@ -776,7 +844,7 @@ export function simulate(economy, { now = 0 } = {}) {
776
844
  row.swingPerDay = round(Math.sqrt(p.variance), 6);
777
845
  const weekly = Math.sqrt(p.variance * 7);
778
846
  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.`);
847
+ notes.push(`The pool “${p.key}” ${row.lasts === 'grows' ? 'takes in more than it pays on average' : row.lasts === 'settles' ? 'settles on average' : 'pays even on average'}, but luck moves it by about ±${round(row.swingPerDay, 4)} ${symbol(p.currency)} a day (±${round(weekly, 4)} a week), against a seed of ${p.seed}. While it can’t cover a fixed prize, a box that stops selling when out (the default) sells nothing, and one that draws among what is left never lands on that prize, so a bad week stalls sales or takes the big wins away until it refills. A seed of about ${round(3 * weekly, 2)} (three times a week’s swing) rides out almost any week.`);
780
848
  }
781
849
  if (row.lasts === 'runs dry') notes.push(`The pool “${p.key}” pays out more than comes in: it lasts about ${row.lastsDays} days from its seed of ${p.seed} ${symbol(p.currency)}.`);
782
850
  return row;
@@ -787,10 +855,8 @@ export function simulate(economy, { now = 0 } = {}) {
787
855
  const level = row ? (row.settlesAt ?? row.seed) : 0;
788
856
  if (prize.account) sendTo(destination(prize.account), row?.token, prize.rate * level, prize.by);
789
857
  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
858
  }
793
- // A share burned is burned at the pool's level, as a share prize is paid.
859
+ // A share burned is burned at the pool's level, as a share paid out is.
794
860
  for (const b of shareBurns) {
795
861
  const row = poolRows.find((r) => r.key === b.pool);
796
862
  add(burned, row?.token, b.rate * (row ? (row.settlesAt ?? row.seed) : 0));
@@ -819,8 +885,8 @@ export function simulate(economy, { now = 0 } = {}) {
819
885
  const holds = (s.nfts ?? []).length;
820
886
  const net = perDay - inPerDay;
821
887
  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)'}.`);
888
+ if (perDay > 0 && net > 0) notes.push(`The stash “${s.name}” starts with ${holds} NFT${holds === 1 ? '' : 's'} and gives about ${round(perDay, 4)} a day (${[...new Set(takes.map((t) => t.by))].join(', ')})${filled}: ${holds ? `empty in about ${round(holds / net, 2)} days` : 'empty from the start'}, after which a draw that lands on its outcome is not made, and gives back that draw’s worth, until more are put in.`);
889
+ else if (perDay > 0) notes.push(`The stash “${s.name}” starts with ${holds} NFT${holds === 1 ? '' : 's'}, gives about ${round(perDay, 4)} a day (${[...new Set(takes.map((t) => t.by))].join(', ')})${filled}: it keeps up${holds ? '' : ', once the first come in (until then a draw that lands on its outcome is not made)'}.`);
824
890
  return { key: s.key, name: s.name, holds, givenPerDay: round(perDay, 6), ...(inPerDay > 0 ? { takenInPerDay: round(inPerDay, 6) } : {}), ...(net > 0 ? { lastsDays: round(holds / net, 4) } : {}) };
825
891
  });
826
892
  const contestRolls = m.contests.filter((c) => prizeLine(c.prize)?.gives === 'roll');
@@ -851,7 +917,7 @@ export function simulate(economy, { now = 0 } = {}) {
851
917
  burned: totals(burned),
852
918
  accounts: totals(accounts),
853
919
  },
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 },
920
+ fees: { playerSol: PLAYER_FEE, projectSol: PROJECT_FEE, oracleSol: ORACLE, networkPerSignatureSol: NETWORK_FEE, settleRentUnitSol: sol(SETTLE_RENT_UNIT), ticketLineRentSol: round(ticketLine({})), collectibleRentSol: COLLECTIBLE_RENT, burnRefundSol: BURN_REFUND },
855
921
  notes,
856
922
  };
857
923
  }
@@ -905,9 +971,10 @@ export function explain(sim, economy) {
905
971
  const back = b.returnPercent !== null ? `${n(b.expectedBack)} ${sym(b.token)} on average (${n(b.returnPercent)}%)${also.length ? ` and ${also.join(', ')}` : ''}` : `${also.join(', ') || 'nothing'} on average`;
906
972
  const all = `; ${inAll(b.realCost)}`;
907
973
  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)}`);
974
+ const part = (o) => (o.gives === 'pay' ? `${n(o.amount)} ${sym(o.token)} from ${o.pool}` : o.gives === 'mint' ? `${n(o.amount)} ${sym(o.token)}, minted` : o.gives === 'stash' ? `an NFT of the stash “${o.stash}”, at random` : o.gives === 'nft' ? `the NFT ${String(o.nft).slice(0, 4)}… of “${o.stash}”` : o.gives === 'roll' ? `a roll of “${o.box}”` : o.gives === 'item' ? `${o.item}${o.worth !== null ? ` (worth ${n(o.worth)})` : ' (no value given)'}` : o.gives);
975
+ for (const o of b.outcomes) lines.push(` ${n(o.chance)}%: ${o.gives === 'bundle' ? o.prizes.map(part).join(' + ') : part(o)}`);
910
976
  if (b.unpriced.length) lines.push(` Give ${b.unpriced.join(', ')} a "value" to count them in the return.`);
977
+ if (b.limitedStock) lines.push(` ${whenOutWords(m.box.get(b.key))}.`);
911
978
  }
912
979
  for (const it of sim.items) lines.push(`Shop “${it.name}”: ${n(it.price)} ${sym(it.token)}; ${inAll(it.realCost)}.`);
913
980
  for (const p of sim.payments) lines.push(`Payment “${p.name}”: ${p.atLeast ? 'at least ' : ''}${n(p.amount)} ${sym(p.token)}; ${inAll(p.realCost)}.`);
@@ -928,10 +995,10 @@ export function explain(sim, economy) {
928
995
  for (const g of sim.grants) lines.push(`Grant “${g.name}”: ${n(g.claimsPerDay)} claims a day, each giving ${g.gives}; each claim costs its funds about ${n(g.fundsPerClaimSol)} SOL${g.orderHeldSol ? ` (${n(g.orderHeldSol)} of it the roll’s order, which goes to the winner when it is settled; whoever asks for the roll pays the oracle)` : ''}.`);
929
996
  for (const s of sim.stashes ?? []) lines.push(`Stash “${s.name}”: ${s.holds} NFT${s.holds === 1 ? '' : 's'} to start${s.takenInPerDay ? `, about ${n(s.takenInPerDay)} a day come in` : ''}${s.lastsDays !== undefined ? `, given at about ${n(s.givenPerDay)} a day: lasts about ${n(s.lastsDays)} days` : s.givenPerDay ? `, given at about ${n(s.givenPerDay)} a day` : ''}.`);
930
997
  for (const r of sim.recipes) lines.push(`Recipe “${r.name}”: ${r.takes.join(' + ') || 'nothing'} → ${r.gives.join(' + ') || 'nothing'}, ${n(r.craftsPerDay)} a day.`);
931
- for (const s of sim.schedules) lines.push(`Schedule “${s.key}”: ${s.says}.`);
998
+ for (const s of sim.schedules) lines.push(`Schedule “${s.key}”: ${s.says}; ${lateRunWords(m.schedules.find((x) => x.key === s.key) ?? {})}.`);
932
999
  for (const c of sim.contests) lines.push(`Contest “${c.name}”: ${c.says}.`);
933
1000
  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)}.`);
1001
+ for (const p of sim.pools) lines.push(`${p.pot ? `The pot of “${p.pot}”` : p.farm ? `The rewards vault of “${p.farm}”` : `Pool “${p.key}”`}: ${n(p.inPerDay)} ${sym(p.token)} in and ${n(p.outPerDay)} out a day from a seed of ${n(p.seed)}; it ${poolFate(p, sim.scenario.days)}.`);
935
1002
  const d = sim.perDay;
936
1003
  const list = (map) => Object.entries(map).map(([k, v]) => `${n(v)} ${sym(k)}`).join(', ') || 'nothing';
937
1004
  lines.push(`Each day players pay ${list(d.players.pay)} and get back ${list(d.players.getBack)} on average (items at the value you gave them), plus ${n(d.players.playerFeesSol)} SOL in player fees, ${n(d.players.oracleFeesSol)} SOL to MagicBlock’s oracle and ${n(d.players.networkFeesSol)} SOL in network fees.`);
package/js/mcp/tools.mjs CHANGED
@@ -169,7 +169,7 @@ export function carryIds(previous, next) {
169
169
  carry('pools', ['index']);
170
170
  for (const list of ['stashes', 'boxes', 'recipes', 'schedules', 'lotteries', 'grants']) carry(list, ['id']);
171
171
  // A farm that pays nothing keeps the number of the empty pool made for it; an auction keeps the seller its address names.
172
- carry('farms', ['id', 'rewardsIndex']);
172
+ carry('farms', ['id']);
173
173
  carry('auctions', ['id', 'seller']);
174
174
  return out;
175
175
  }
@@ -449,8 +449,10 @@ export const tools = [
449
449
  ' app.collectibles(), app.balances() what a player does. `listing`: the entry shop(), boxes(), lotteries() or recipes() showed the',
450
450
  ' player; its price (or version) is the most they pay: one that moved since is refused, never paid unseen',
451
451
  ' a prize (a grant, a lottery place, a contest win, a mission, a recipe) is given in the instruction that wins it,',
452
- ' its accounts from loot.prizeMetas({ project, collection, prize, winner, poolMints, items, nft }) (a roll a buy, a craft',
453
- ' or a grant claim makes: strict: true, boxes, rollPools, for the box’s prize mints, loot.rollMints); app.claims() finds',
452
+ ' its accounts from loot.prizeMetas({ project, collection, prize, winner, poolMints, items, nft }) (a roll: its box and',
453
+ ' its order; ask for it in the same transaction, loot.askRollIx, which fails the whole transaction, SoldOut, when the box',
454
+ ' has nothing in stock (only the order’s player asks then, a slot later, and that ends the roll with nothing); a write naming a',
455
+ ' box to roll passes loot.boxesAccounts after it, and the box is written first); app.claims() finds',
454
456
  ' what a player is owed. A roll (a prize of a roll, an auction lot, a box) is an order: app.claim / app.collect, or',
455
457
  ' loot.askRollIx({ payer, orderAddress, order, box }) then loot.settleIx(...) hands over what it rolled (a stash NFT too)',
456
458
  ' loot.stashIx({ signer, project, id, asset, out }) the owner or authority puts an NFT in a stash, or takes one out',
@@ -458,20 +460,24 @@ export const tools = [
458
460
  ' app.buyListing(asset, { listing }), app.rentListing(asset, { periods, listing }) (`listing`: what listing() or listings()',
459
461
  ' showed the player), app.unlist(asset), app.rentedTo(asset) (one account read) listings, sale and rent',
460
462
  ' app.raffle(id, nft, { price, drawsAt, minSold }), app.unraffle(id, nft) a raffle from the lottery’s own stash',
461
- ' app.compound(key), app.withdraw(key), app.runSchedule(key) compound, take out unbonded tokens, catch a schedule up',
462
- ' app.claims() also lists a raffle’s prize step (given before the money), unbonded tokens, vested runs, a rental over',
463
+ ' app.compound(key), app.runSchedule(key) compound, send a schedule that is due (a run missed is missed: only a sealed fixed amount pays every run due)',
464
+ ' app.farmWalk(key), app.walkFarm(key) a farm idle past many end times: how many it must walk, and walking them in parts (anyone, free; moves ask for the compute to walk a few themselves)',
465
+ ' app.claims() also lists a lottery’s parts to pull (a refund, a place, a raffle’s NFT), vested runs, a rental over',
463
466
  'Lower level, for what the player module does not cover:',
464
467
  ' import * as loot from "@xgamesfun/solana/client";',
465
468
  ' loot.pda.project(owner, number), loot.pda.box(project, id), … every address the program derives',
466
469
  ' loot.openBoxIx({ player, project, box, nonce }), loot.buyIx({ player, project, box, items }) … one builder per instruction; each returns { programId, keys, data }',
467
470
  ' loot.payNftIx({ player, project, asset, to }) pay with an NFT (burned, or given to `to`): the app admits by the receipt',
468
471
  ' loot.openListingIx / takeListingIx / closeListingIx, loot.listingTerms(listing), loot.pda.listing(project, asset), loot.renterOf(listing, now)',
469
- ' loot.lotteryStep(lottery, contribution) then loot.finishLotteryContributionIx(...) a drawn round’s prizes first, then its money',
470
- ' loot.withdrawable(seat, now), loot.runsDue(schedule, now, vaultBalance), loot.pda.vest(schedule), loot.pda.prizeVault(contest), loot.pda.lotteryStash(lottery)',
472
+ ' a take or a bid signs the record’s terms: loot.listingTerms / offerTerms / auctionTerms(record), the hash of its bytes as read, but what a take changes',
473
+ ' loot.decodeRound(data), loot.partsOwed(round, entry), loot.commonOwed(round), loot.pullIx(...) a round’s parts, each pulled in any order (a place’s roll: loot.pullRolls, asked in the same transaction)',
474
+ ' loot.decodeEnds(data), loot.endsToWalk(farm, ends, now), loot.farmNeedsWalk(farm, ends, now), loot.walkFarmParts({ project, id, farm, ends, now }), loot.pda.ends(farm) a farm’s record of when seats stop earning',
475
+ ' loot.orderRefundable(order, slot, now), loot.rollAskable(order, slot, now), loot.lotteryDrawStuck(round, now), loot.lotteryCancellable(round, now) deadlines, by the chain’s clock',
476
+ ' loot.runsDue(schedule, now, vaultBalance), loot.scheduleCatchesUp(schedule), loot.pda.vest(schedule), loot.pda.prizeVault(contest), loot.pda.lotteryStash(lottery), loot.pda.rewards(farm)',
471
477
  ' loot.decode("LootBox", data), loot.receipts(tx) accounts, and the receipts of a getTransaction result (none when meta.err is set)',
472
478
  'IDL: @xgamesfun/solana/idl.json (Anchor 1.x format).',
473
479
  'A page builds the instruction, puts it in a transaction, and asks the player’s wallet (Wallet Standard: Phantom, Solflare, Backpack) to sign and send it. The app never holds a key.',
474
- 'Reads: the project’s records are plain accounts (loot.decode); every move leaves a receipt in the transaction’s logs (loot.receipts(tx)). A backend delivers only from a finalized transaction whose meta.err is null, once per order or reference: a failed transaction keeps its logs, and they can read as all-success.',
480
+ 'Reads: the project’s records are plain accounts (loot.decode); every move leaves a receipt in the transaction’s logs (loot.receipts(tx)). A backend delivers only from a finalized transaction whose meta.err is null, once per order or reference, and only for a receipt whose data.project is its own (loot.receipts reads every project’s, and anyone can make a project): a failed transaction keeps its logs, and they can read as all-success.',
475
481
  ].join('\n');
476
482
  return { text: text + footer, ...PROGRAM_INFO };
477
483
  },