@xgamesfun/solana 0.25.3 → 0.26.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 (41) hide show
  1. package/README.md +145 -228
  2. package/js/approve/details.mjs +52 -2
  3. package/js/approve/live-check.mjs +0 -15
  4. package/js/approve/server.mjs +5 -4
  5. package/js/chain/token2022.js +3 -2
  6. package/js/client/accounts.js +67 -25
  7. package/js/client/grant.js +7 -2
  8. package/js/client/idl/loot.js +1 -1
  9. package/js/client/idl/loot.json +714 -182
  10. package/js/client/ix.js +617 -113
  11. package/js/client/sizes.js +35 -3
  12. package/js/economy/check.mjs +655 -242
  13. package/js/economy/cost.mjs +2 -4
  14. package/js/economy/explain.mjs +16 -2
  15. package/js/economy/guide.mjs +12 -12
  16. package/js/economy/model.mjs +133 -14
  17. package/js/economy/plan.mjs +36 -11
  18. package/js/economy/review.mjs +50 -8
  19. package/js/economy/schema.json +73 -29
  20. package/js/economy/simulate.mjs +116 -13
  21. package/js/mcp/tools.mjs +2 -2
  22. package/js/play/index.js +847 -296
  23. package/package.json +1 -1
  24. package/release.json +5 -5
  25. package/skills/xsolana-economy/SKILL.md +12 -4
  26. package/skills/xsolana-economy/references/after-launch.md +1 -1
  27. package/skills/xsolana-economy/references/apps.md +11 -2
  28. package/skills/xsolana-economy/references/sentence-to-spec.md +3 -3
  29. package/skills/xsolana-economy/references/telling-the-person.md +21 -4
  30. package/skills/xsolana-economy/references/trust-and-cheating.md +15 -5
  31. package/types/chain/token2022.d.ts +3 -2
  32. package/types/client/accounts.d.ts +34 -11
  33. package/types/client/idl/loot.d.ts +25 -4
  34. package/types/client/ix.d.ts +217 -45
  35. package/types/economy/check.d.mts +5 -0
  36. package/types/economy/model.d.mts +75 -12
  37. package/types/economy/plan.d.mts +22 -1
  38. package/types/economy/simulate.d.mts +12 -1
  39. package/types/mcp/server.d.mts +12 -1
  40. package/types/mcp/tools.d.mts +12 -1
  41. package/types/play/index.d.ts +210 -144
@@ -384,7 +384,7 @@ export function review(economy, { now = Math.floor(Date.now() / 1000) } = {}) {
384
384
  const free = price === 0;
385
385
  const ceiling = free
386
386
  ? `${b.perWallet ? `${b.perWallet} per wallet, and wallets are free: ` : ''}${it?.supply ? `all ${it.supply}` : 'as many as they like'}, for only the fees.`
387
- : `What they pay for, at the price: ${rolls ? 'nobody, the project included, can steer the roll' : 'nothing free'}.${b.whenOut === 'drawLeft' && rolls ? ' With "whenOut": "drawLeft", someone watching the stock can empty a common outcome, then open for the rare at better odds.' : ''}`;
387
+ : `What they pay for, at the price: ${rolls ? 'nobody, the project included, can steer the roll' : 'nothing free'}.${b.whenOut === 'drawLeft' && rolls ? ' With "whenOut": "drawLeft", someone watching the stock can empty a common outcome, then open for the rare at better odds.' : ''}${b.whenOut === 'giveNothing' && rolls ? ' With "whenOut": "giveNothing", a draw on an outcome that is out gives nothing and is not refunded.' : ''}`;
388
388
  routes.push({
389
389
  kind,
390
390
  key,
@@ -526,15 +526,21 @@ export function review(economy, { now = Math.floor(Date.now() / 1000) } = {}) {
526
526
  ].filter(Boolean),
527
527
  trusts: ['MagicBlock’s oracle (the draw)'],
528
528
  trust: null,
529
- ceiling: `A place by chance: each ticket is one chance, and "perWallet" limits a wallet, not a person. Tickets are not weighted by time, so one buyer may take most of a round’s tickets just before the draw.`,
529
+ ceiling: l.timeWeighted
530
+ ? `A place by chance, by weight: each purchase counts its tickets times the seconds left until sales close, so a ticket held longer has a better chance; "perWallet" limits a wallet, not a person.`
531
+ : `A place by chance: each ticket is one chance, and "perWallet" limits a wallet, not a person. Tickets are not weighted by time, so one buyer may take most of a round’s tickets just before ${l.closesBefore ? 'sales close' : 'the draw'}.`,
530
532
  feeds: [...new Set((l.places ?? []).flatMap(p => feedsOf(p.prize)))],
531
533
  });
532
- find(
533
- 'R-LOTTERY-LATE',
534
- 'info',
535
- `lottery:${l.key}`,
536
- `Anyone may buy most of ${label(l)}’s tickets right before the draw, risking only the price and fees; the draw itself is the oracle’s, which nobody steers.`,
537
- );
534
+ if (!l.timeWeighted)
535
+ find(
536
+ 'R-LOTTERY-LATE',
537
+ l.noLoss && !l.closesBefore ? 'low' : 'info',
538
+ `lottery:${l.key}`,
539
+ `Anyone may buy most of ${label(l)}’s tickets right before ${l.closesBefore ? `sales close (${l.closesBefore} before the draw)` : 'the draw'}, risking only ${l.noLoss ? 'the fees: every ticket’s price comes back' : 'the price and fees'}; the draw itself is the oracle’s, which nobody steers.`,
540
+ l.noLoss
541
+ ? 'Weigh tickets by the time they are held ("timeWeighted": true), or close sales a while before the draw ("closesBefore").'
542
+ : undefined,
543
+ );
538
544
  }
539
545
  for (const c of m.contests) {
540
546
  const deciders = c.deciders ?? [];
@@ -670,6 +676,42 @@ export function review(economy, { now = Math.floor(Date.now() / 1000) } = {}) {
670
676
  const key = /^economy\.tokens\.([^.]+)/.exec(w.where)?.[1];
671
677
  find('R-MINT-POWERS', 'low', key ? `token:${key}` : 'economy', w.message, w.fix);
672
678
  }
679
+ // What the program takes and a player, a partner or a typo can turn against the project (loot 0.26): each warning,
680
+ // as a finding on its route.
681
+ const routeOf = where => {
682
+ const [, list, key] = /^economy\.([a-z]+)\.([^.[]+)/.exec(where) ?? [];
683
+ const kinds = {
684
+ boxes: 'box',
685
+ lotteries: 'lottery',
686
+ auctions: 'auction',
687
+ contests: 'contest',
688
+ farms: 'farm',
689
+ schedules: 'schedule',
690
+ recipes: 'recipe',
691
+ grants: 'grant',
692
+ holds: 'hold',
693
+ stashes: 'stash',
694
+ tokens: 'token',
695
+ };
696
+ return kinds[list] ? `${kinds[list]}:${key}` : 'economy';
697
+ };
698
+ for (const [code, id, severity] of [
699
+ ['W-FARM-AUTHORITY', 'R-FARM-AUTHORITY', 'high'],
700
+ ['W-SCHEDULE-PAYEE', 'R-SCHEDULE-PAYEE', 'medium'],
701
+ ['W-FAR-TIME', 'R-FAR-TIME', 'medium'],
702
+ ['W-HOLD-UP', 'R-HOLD-UP', 'medium'],
703
+ ['W-AUCTION-OUTSIDE', 'R-AUCTION-OUTSIDE', 'medium'],
704
+ ['W-OUTSIDE-NFT', 'R-OUTSIDE-NFT', 'medium'],
705
+ ['W-COLLECTION-PARTNER', 'R-COLLECTION-PARTNER', 'medium'],
706
+ ['W-FARM-MISSION', 'R-FARM-MISSION', 'medium'],
707
+ ['W-CONTEST-PRIZE', 'R-CONTEST-PRIZE', 'medium'],
708
+ ['W-SHARED-SOURCE', 'R-SHARED-SOURCE', 'low'],
709
+ ['W-LOTTERY-POOL', 'R-LOTTERY-POOL', 'low'],
710
+ ['W-LOTTERY-WEIGHT', 'R-LOTTERY-WEIGHT', 'info'],
711
+ ['W-ZERO', 'R-ZERO', 'low'],
712
+ ['W-ANSWER', 'R-ANSWER', 'low'],
713
+ ])
714
+ for (const w of warned(code)) find(id, severity, routeOf(w.where), w.message, w.fix);
673
715
 
674
716
  findings.sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity));
675
717
  return {
@@ -278,7 +278,7 @@
278
278
  },
279
279
  "destination": {
280
280
  "type": "string",
281
- "description": "Where a share goes: \"burn\", \"treasury\" (the game's), \"pool:<key>\", \"wallet:<address>\" (a partner, a creator, a charity: the program pays their own account for the currency, and opens it when it is not there), \"account:<token account address>\" (an existing token account of that currency), \"holder:<collectible address>\" (whoever holds that collectible at the time), \"lottery:<key>\" (that lottery's pot), \"farm:<key>\" (that farm's rewards vault, shared by its stakers), or, in a box's or a lottery's split, \"chosen\" (a wallet the buyer names when they buy: a referrer, a streamer) or \"chosen:<item key>\" (whoever holds the collectible of that item the buyer names: the plot they build on; the buyer must name one a player holds, or the purchase is refused); in a payment's split, \"chosen\" only (a wallet the payer names each time they pay, never their own). A share that can't be paid (its wallet has no account, nobody holds the collectible) goes to the treasury instead; a sale never fails over it.",
281
+ "description": "Where a share goes: \"burn\", \"treasury\" (the game's), \"pool:<key>\", \"wallet:<address>\" (a partner, a creator, a charity: the program pays their own account for the currency, and opens it when it is not there), \"account:<token account address>\" (an existing token account of that currency), \"holder:<collectible address>\" (whoever holds that collectible at the time), \"lottery:<key>\" (that lottery's pot), \"farm:<key>\" (that farm's rewards vault, shared by its stakers), or, in a box's or a lottery's split, \"chosen\" (a wallet the buyer names when they buy: a referrer, a streamer) or \"chosen:<item key>\" (whoever holds the collectible of that item the buyer names: the plot they build on; the buyer must name one a player holds, or the purchase is refused); in a payment's split, \"chosen\" only (a wallet the payer names each time they pay, their own too). A chosen share can come back to the buyer: it is a rebate they control, not a referral the program can prove. A share that can't be paid (its wallet has no account, nobody holds the collectible) goes to the treasury instead; a sale never fails over it.",
282
282
  "pattern": "^(burn|treasury|chosen|chosen:[a-z][a-z0-9-]{0,31}|pool:[a-z][a-z0-9-]{0,31}|lottery:[a-z][a-z0-9-]{0,31}|farm:[a-z][a-z0-9-]{0,31}|wallet:[1-9A-HJ-NP-Za-km-z]{32,44}|account:[1-9A-HJ-NP-Za-km-z]{32,44}|holder:[1-9A-HJ-NP-Za-km-z]{32,44})$"
283
283
  },
284
284
  "share": {
@@ -427,6 +427,14 @@
427
427
  "transferFeePercent": {
428
428
  "$ref": "#/$defs/percent"
429
429
  },
430
+ "feeChangeable": {
431
+ "type": "boolean",
432
+ "description": "Its issuer can still change its transfer fee (Token-2022's transfer fee config has an authority): raised to 100%, it takes every stake or payment as it leaves."
433
+ },
434
+ "closeAuthority": {
435
+ "type": "boolean",
436
+ "description": "Its mint has a close authority, who could close it and make it again with other powers."
437
+ },
430
438
  "pausable": {
431
439
  "type": "boolean"
432
440
  },
@@ -435,14 +443,14 @@
435
443
  },
436
444
  "defaultState": {
437
445
  "type": "boolean",
438
- "description": "The mint carries Token-2022's DefaultAccountState, whatever it is set to. With a freeze authority it can be switched to frozen: auctions, contests, lotteries and sealed schedules refuse it."
446
+ "description": "The mint carries Token-2022's DefaultAccountState, whatever it is set to. With a freeze authority it can be switched to frozen: the checker warns what that does to an auction, a loan, a lottery or a contest in it."
439
447
  },
440
448
  "nonTransferable": {
441
449
  "type": "boolean"
442
450
  },
443
451
  "confidentialTransfers": {
444
452
  "type": "boolean",
445
- "description": "Its holders can turn plain transfers away (Token-2022 confidential transfers): lotteries, contests and auctions refuse it."
453
+ "description": "Its holders can turn plain transfers away (Token-2022 confidential transfers): the checker warns what that does to an auction, a loan, a lottery or a contest in it."
446
454
  }
447
455
  }
448
456
  }
@@ -875,9 +883,9 @@
875
883
  "description": "Each item's chance shrinks with what is left of its supply."
876
884
  },
877
885
  "whenOut": {
878
- "enum": ["stopSales", "drawLeft"],
886
+ "enum": ["stopSales", "drawLeft", "giveNothing"],
879
887
  "default": "stopSales",
880
- "description": "What a box that rolls does once an outcome can't be given (an item sold out, a pool short of its prize, a stash empty). \"stopSales\" (the default): it isn't sold while any outcome couldn't cover this purchase's draws and those of purchases still waiting for their answer, so every roll is on the odds written, and the project restocks or edits the box to sell again. \"drawLeft\": it keeps selling, and a roll draws among the outcomes still there by their written weights, so the others' chances rise as one runs out: a player can empty a common outcome to make a rare one likelier. A box selling a rare prize keeps \"stopSales\"."
888
+ "description": "What a box that rolls does once an outcome can't be given (an item sold out, a pool short of its prize, a stash empty). \"stopSales\" (the default): it isn't sold while any outcome couldn't cover this purchase's draws and those of purchases still waiting for their answer, so every roll is on the odds written, a paid miss is refunded, and the project restocks or edits the box to sell again; a free roll (another box's, a recipe's, a grant's, a mission's, a lottery place's) is still given after it stops, and is asked again until it hits. \"drawLeft\": it keeps selling, and a roll draws among the outcomes still there by their written weights, so the others' chances rise as one runs out: a player can empty a common outcome to make a rare one likelier. \"giveNothing\": it keeps selling, a roll stays on the weights written, and a draw that lands on an outcome that is out gives nothing: a paid draw is not refunded, a free roll is over. A box selling a rare prize keeps \"stopSales\"."
881
889
  },
882
890
  "active": {
883
891
  "type": "boolean",
@@ -1389,7 +1397,7 @@
1389
1397
  "type": "integer",
1390
1398
  "minimum": 0,
1391
1399
  "maximum": 15,
1392
- "description": "Pays its places from a pot it keeps from round to round, this one of its 16 (0 unless it moves to a fresh one): each place takes its percent of what the pot holds at the draw with the round's ticket money, so the seed, gifts and shares sent to it (\"lottery:<key>\"), yield, and what earlier rounds' places left all count, and the next round always starts with what the round before left. Write exactly one of \"pot\" or \"rest\". A no-loss lottery needs a pot. Changing the number between rounds starts a fresh pot; every pot it opened is emptied to the owner when it closes."
1400
+ "description": "Pays its places from a pot it keeps from round to round, this one of its 16 (0 unless it moves to a fresh one): each place takes its percent of what the pot holds at the draw with the round's ticket money, so the seed, gifts and shares sent to it (\"lottery:<key>\"), yield, and what earlier rounds' places left all count, and the next round always starts with what the round before left. Write exactly one of \"pot\" or \"rest\". Changing the number between rounds starts a fresh pot; every pot it opened is emptied to the owner when it closes."
1393
1401
  },
1394
1402
  "rest": {
1395
1403
  "$ref": "#/$defs/destination",
@@ -1422,7 +1430,12 @@
1422
1430
  "type": "integer",
1423
1431
  "minimum": 1,
1424
1432
  "maximum": 40960,
1425
- "description": "How many places in a row are alike (a run): \"places 2 to 201 each take 1%\" is one { \"percent\": 1, \"times\": 200 }. Not for a named NFT or a roll, which one place gives. Default 1."
1433
+ "description": "How many places in a row are alike (a run): \"places 2 to 201 each take 1%\" is one { \"percent\": 1, \"times\": 200 }. Not for a named NFT, which one place gives; a run of rolls gives each place its own roll. Default 1."
1434
+ },
1435
+ "whenShort": {
1436
+ "enum": ["inOrder", "inProportion", "nothing"],
1437
+ "default": "inOrder",
1438
+ "description": "For a prize of tokens from a pool: what each place gets when the pool holds less than the places with a winner win, read once when the draw is asked. \"inOrder\" (the default): whole prizes, first place first, while the pool covers one; \"inProportion\": every place the same share of its prize; \"nothing\": no place gets anything, and the pool keeps it. Prizes of 1,000, 500 and 500 from a pool holding 1,700 give 1,000, 500 and 0 in order, 850, 425 and 425 in proportion, or nothing. Every place paid from one pool says the same."
1426
1439
  }
1427
1440
  }
1428
1441
  }
@@ -1437,6 +1450,14 @@
1437
1450
  "firstDraw": {
1438
1451
  "$ref": "#/$defs/time"
1439
1452
  },
1453
+ "closesBefore": {
1454
+ "$ref": "#/$defs/duration",
1455
+ "description": "Sales close this long before each draw (default 0: at the draw); for a lottery that draws again, less than \"every\". A no-loss lottery wants one, so tickets bought just before the draw don't share the pot with tickets held all period."
1456
+ },
1457
+ "timeWeighted": {
1458
+ "type": "boolean",
1459
+ "description": "No-loss lotteries only: each purchase weighs its tickets times the seconds from it to the close of sales, and a place is won by weight, so a ticket held longer has a better chance. Set a sales close you mean (\"closesBefore\"); \"perWallet\" and \"minSold\" still count tickets. Off by default: every ticket counts one."
1460
+ },
1440
1461
  "gate": {
1441
1462
  "$ref": "#/$defs/gate"
1442
1463
  },
@@ -1485,7 +1506,7 @@
1485
1506
  },
1486
1507
  "noLoss": {
1487
1508
  "type": "boolean",
1488
- "description": "A no-loss lottery: every ticket goes back to its buyer once the round is drawn, and the winners take only the pot, what is paid into it (`seed`, a sponsor, a share of other sales: \"lottery:<key>\"). It needs \"pot\". No split, no cut and no `ticketCurrency`; NFT tickets come back too."
1509
+ "description": "A no-loss lottery: every ticket goes back to its buyer once the round is drawn, and the winners take only the pot, what is paid into it (`seed`, a sponsor, a share of other sales: \"lottery:<key>\"), or with \"rest\" only its places' prizes. No split, no cut and no `ticketCurrency`; NFT tickets come back too. Set \"closesBefore\", or \"timeWeighted\", so a late buyer doesn't share the pot with tickets held all period."
1489
1510
  },
1490
1511
  "minSold": {
1491
1512
  "type": "integer",
@@ -1619,8 +1640,8 @@
1619
1640
  },
1620
1641
  "losersEach": {
1621
1642
  "type": "integer",
1622
- "minimum": 1,
1623
- "description": "In a contest of NFTs (`stakeNft`), the most losers' NFTs one winner takes; what the winners leave goes to whoever opened it. Default: no cap, the winners share every loser's NFT (the first to enter on the winning side take one more when they don't divide evenly), each picking which."
1643
+ "minimum": 0,
1644
+ "description": "In a contest of NFTs (`stakeNft`), the most losers' NFTs one winner takes; what the winners leave goes to whoever opened it (0: every loser's NFT goes to the opener). Default: no cap, the winners share every loser's NFT (the first to enter on the winning side take one more when they don't divide evenly), each picking which."
1624
1645
  }
1625
1646
  }
1626
1647
  },
@@ -2062,16 +2083,14 @@
2062
2083
  "oneOf": {
2063
2084
  "type": "array",
2064
2085
  "minItems": 1,
2065
- "maxItems": 16,
2066
2086
  "items": { "type": "string" },
2067
- "description": "The attribute is one of these texts (at most 16): a tier of two, one species of three."
2087
+ "description": "The attribute is one of these texts: a tier of two, one species of forty. As many as the record holds; a lottery's NFT ticket takes at most 170, and a box's gate about 340 (what one answer of the oracle's reads)."
2068
2088
  },
2069
2089
  "items": {
2070
2090
  "type": "array",
2071
2091
  "minItems": 1,
2072
- "maxItems": 16,
2073
2092
  "items": { "$ref": "#/$defs/key" },
2074
- "description": "Any of these items the game mints (at most 16), in its own collection: a farm for three tree species, a gate for holders of any of five plots. Read by the program as the collection's `item_id` being one of theirs; not with `item`, `collection` or `attribute`."
2093
+ "description": "Any of these items the game mints, in its own collection: a farm for three tree species, a gate for holders of any of five plots. Read by the program as the collection's `item_id` being one of theirs; not with `item`, `collection` or `attribute`."
2075
2094
  },
2076
2095
  "time": {
2077
2096
  "enum": ["ahead", "passed"],
@@ -2082,8 +2101,32 @@
2082
2101
  "boost": {
2083
2102
  "type": "number",
2084
2103
  "minimum": 0,
2085
- "maximum": 10000,
2086
- "description": "A percent added to a weight: 100 doubles it. At most 10,000 (101 times): enough for a stat that scales a rate, such as +100% a point of power up to 100."
2104
+ "maximum": 100000,
2105
+ "description": "A percent a farm's boost adds to a weight: 100 doubles it. At most 100,000 (1,001 times), alone and all of a farm's boosts together: enough for a stat that scales a rate, such as +100% a point of power up to 1,000."
2106
+ },
2107
+ "oddsBoost": {
2108
+ "type": "number",
2109
+ "minimum": 0,
2110
+ "maximum": 42949672.95,
2111
+ "description": "A percent a box's odds rule adds to an outcome's weight: 100 doubles it, 99,900 makes it a thousand times likelier. Any amount: a boosted weight stops at the largest weight (4,294,967,295), and two outcomes boosted past it are equally likely."
2112
+ },
2113
+ "oddsEffect": {
2114
+ "type": "object",
2115
+ "additionalProperties": false,
2116
+ "description": "How much a collectible that passes the rule adds to the outcome's weight: a flat percent, or a percent per point of its attribute (up to `upTo` points).",
2117
+ "properties": {
2118
+ "addPercent": {
2119
+ "$ref": "#/$defs/oddsBoost"
2120
+ },
2121
+ "perPointPercent": {
2122
+ "$ref": "#/$defs/oddsBoost"
2123
+ },
2124
+ "upTo": {
2125
+ "type": "integer",
2126
+ "minimum": 1,
2127
+ "maximum": 4294967295
2128
+ }
2129
+ }
2087
2130
  },
2088
2131
  "effect": {
2089
2132
  "type": "object",
@@ -2119,7 +2162,7 @@
2119
2162
  "$ref": "#/$defs/rule"
2120
2163
  },
2121
2164
  "add": {
2122
- "$ref": "#/$defs/effect"
2165
+ "$ref": "#/$defs/oddsEffect"
2123
2166
  }
2124
2167
  }
2125
2168
  },
@@ -2135,9 +2178,9 @@
2135
2178
  "multiplier": {
2136
2179
  "type": "number",
2137
2180
  "exclusiveMinimum": 0,
2138
- "maximum": 11,
2181
+ "maximum": 100,
2139
2182
  "default": 1,
2140
- "description": "Its weight: above 0 and up to 11 times what it stakes (below 1: a flexible tier that earns less)."
2183
+ "description": "Its weight: above 0 and up to 100 times what it stakes (below 1: a flexible tier that earns less)."
2141
2184
  },
2142
2185
  "prize": {
2143
2186
  "$ref": "#/$defs/prize",
@@ -2631,9 +2674,9 @@
2631
2674
  },
2632
2675
  "weight": {
2633
2676
  "type": "integer",
2634
- "minimum": 1,
2677
+ "minimum": 0,
2635
2678
  "maximum": 4294967295,
2636
- "description": "Its weight in the stash's draws (1 when left out): an NFT that weighs 10 is drawn ten times as often as one that weighs 1."
2679
+ "description": "Its weight in the stash's draws (1 when left out): an NFT that weighs 10 is drawn ten times as often as one that weighs 1. 0: never drawn, given only by name."
2637
2680
  }
2638
2681
  }
2639
2682
  },
@@ -2656,9 +2699,9 @@
2656
2699
  },
2657
2700
  "weight": {
2658
2701
  "type": "integer",
2659
- "minimum": 1,
2702
+ "minimum": 0,
2660
2703
  "maximum": 4294967295,
2661
- "description": "Its weight in the stash's draws (1 when left out): an NFT that weighs 10 is drawn ten times as often as one that weighs 1."
2704
+ "description": "Its weight in the stash's draws (1 when left out): an NFT that weighs 10 is drawn ten times as often as one that weighs 1. 0: never drawn, given only by name."
2662
2705
  }
2663
2706
  }
2664
2707
  },
@@ -2673,9 +2716,9 @@
2673
2716
  },
2674
2717
  "weight": {
2675
2718
  "type": "integer",
2676
- "minimum": 1,
2719
+ "minimum": 0,
2677
2720
  "maximum": 4294967295,
2678
- "description": "Its weight in the stash's draws (1 when left out): an NFT that weighs 10 is drawn ten times as often as one that weighs 1."
2721
+ "description": "Its weight in the stash's draws (1 when left out): an NFT that weighs 10 is drawn ten times as often as one that weighs 1. 0: never drawn, given only by name."
2679
2722
  }
2680
2723
  }
2681
2724
  }
@@ -2815,14 +2858,15 @@
2815
2858
  },
2816
2859
  "weight": {
2817
2860
  "type": "integer",
2818
- "minimum": 1,
2819
- "maximum": 4294967295
2861
+ "minimum": 0,
2862
+ "maximum": 4294967295,
2863
+ "description": "Its weight in the draws. 0: never drawn."
2820
2864
  },
2821
2865
  "stock": {
2822
2866
  "type": "integer",
2823
- "minimum": 1,
2867
+ "minimum": 0,
2824
2868
  "maximum": 4294967294,
2825
- "description": "How many units may draw it: left out, never runs out."
2869
+ "description": "How many units may draw it: left out, never runs out. 0: never drawn."
2826
2870
  }
2827
2871
  }
2828
2872
  }
@@ -10,8 +10,9 @@
10
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
11
  // the rest from then (`stockWalk`). It is a guide, not a promise: the full simulation (js/sim) runs the real program.
12
12
  import { entryWidth, LIMIT_RECORD_BYTES, ROUND_RECORD_BYTES } from '../client/index.js';
13
- import { boxOrderBytes, roundRentOf } from './plan.mjs';
13
+ import { boxOrderBytes, entryShape, roundRentOf } from './plan.mjs';
14
14
  import {
15
+ boostedWeights,
15
16
  boxBuys,
16
17
  COLLECTIBLE_RENT as COLLECTIBLE_RENT_LAMPORTS,
17
18
  drawsOf,
@@ -41,6 +42,11 @@ import {
41
42
  toBps,
42
43
  toSeconds,
43
44
  toTime,
45
+ purchaseWeight,
46
+ shortPays,
47
+ stashNfts,
48
+ stopsSales,
49
+ toUnits,
44
50
  weights,
45
51
  whenOutWords,
46
52
  } from './model.mjs';
@@ -59,15 +65,7 @@ const NETWORK_FEE = sol(SIGNATURE_FEE);
59
65
  const ORACLE = sol(ORACLE_FEE);
60
66
  /** A lottery purchase's entry in its round: the bytes the buyer adds (not refunded: they go to the treasury when the
61
67
  * round closes), with a wallet kept for each share the buyer's own entry names (lottery.rs `entry_width`). */
62
- const ticketLine = l =>
63
- sol(
64
- BigInt(
65
- entryWidth(
66
- lotterySplit(l).some(x => String(x.to).startsWith('chosen')),
67
- Boolean(l.ticketNft),
68
- ),
69
- ) * RENT_PER_BYTE,
70
- );
68
+ const ticketLine = l => sol(BigInt(entryWidth(...entryShape(l))) * RENT_PER_BYTE);
71
69
  /** A collectible's Core account, paid from the SOL the order holds: its rent and Core's creation fee (model.mjs). */
72
70
  const COLLECTIBLE_RENT = sol(COLLECTIBLE_RENT_LAMPORTS);
73
71
  /** What a collectible a recipe burns gives back: its rent less one byte's (Core leaves a 1-byte account that keeps
@@ -369,9 +367,13 @@ export function simulate(economy, { now = 0 } = {}) {
369
367
  const stockOf = x => {
370
368
  const supply = Number(m.item.get(x.item)?.supply ?? 0);
371
369
  if (x.gives === 'item' && supply > 0 && !b.stockOdds) return [`item:${x.item}`, supply];
370
+ // A stash with nothing that weighs is out of stock: an NFT of weight 0 is never drawn (stash.rs `drawable`).
372
371
  if (x.gives === 'stash' && m.stash.get(x.stash))
373
- return [`stash:${x.stash}`, (m.stash.get(x.stash).nfts ?? []).length];
372
+ return [`stash:${x.stash}`, stashNfts(m.stash.get(x.stash)).filter(n => n.weight > 0).length];
374
373
  if (x.gives === 'nft') return [`nft:${x.nft}`, 1];
374
+ // A minted prize is out of stock when the mint can't take it: a token the project can't mint (a fixed supply, or
375
+ // another's mint) mints nothing.
376
+ if (x.gives === 'mint' && !m.tokens.get(x.currency ?? game)?.gameMints) return [`mint:${x.currency ?? game}`, 0];
375
377
  return null;
376
378
  };
377
379
  const needs = (b.outcomes ?? []).map(o => {
@@ -383,7 +385,7 @@ export function simulate(economy, { now = 0 } = {}) {
383
385
  return need;
384
386
  });
385
387
  if (!left.size || !(opens > 0)) return null;
386
- const stops = b.whenOut !== 'drawLeft';
388
+ const stops = stopsSales(b);
387
389
  const out = i => ({
388
390
  outcome: i,
389
391
  gives: (b.outcomes[i].gives === 'bundle'
@@ -416,6 +418,32 @@ export function simulate(economy, { now = 0 } = {}) {
416
418
  : null;
417
419
  }
418
420
  const there = i => [...needs[i]].every(([k, n]) => left.get(k) >= n);
421
+ if (b.whenOut === 'giveNothing') {
422
+ // A draw stays on the weights as written: one on an outcome that is out is made and gives nothing, never refunded
423
+ // (order.rs `WhenOut::GiveNothing`). The box keeps selling; what misses grows as outcomes run out.
424
+ const all = w.reduce((n, x) => n + x, 0);
425
+ if (!all) return null;
426
+ let alive = w.map((_, i) => i).filter(there);
427
+ let day = 0;
428
+ const runsOut = [];
429
+ const miss = () => round((w.reduce((n, x, i) => n + (alive.includes(i) ? 0 : x), 0) / all) * 100, 6);
430
+ const gone = w.map((_, i) => i).filter(i => !alive.includes(i));
431
+ if (gone.length) runsOut.push({ inDays: 0, outcomes: gone.map(out), missPercent: miss() });
432
+ while (alive.length) {
433
+ const rate = new Map();
434
+ for (const i of alive)
435
+ for (const [k, n] of needs[i]) rate.set(k, (rate.get(k) ?? 0) + (opens * draws * w[i] * n) / all);
436
+ const next = [...rate].map(([k, r]) => ({ k, t: left.get(k) / r })).sort((a, z) => a.t - z.t)[0];
437
+ if (!next || day + next.t > days) break;
438
+ day += next.t;
439
+ for (const [k, r] of rate) left.set(k, Math.max(0, left.get(k) - r * next.t));
440
+ left.set(next.k, 0);
441
+ const ended = alive.filter(i => !there(i));
442
+ alive = alive.filter(there);
443
+ runsOut.push({ inDays: round(day, 4), outcomes: ended.map(out), missPercent: miss() });
444
+ }
445
+ return runsOut.length ? { whenOut: 'giveNothing', runsOut } : null;
446
+ }
419
447
  const chances = alive => {
420
448
  const sum = alive.reduce((n, i) => n + w[i], 0);
421
449
  return w.map((x, i) => (sum && alive.includes(i) ? round((x / sum) * 100, 6) : 0));
@@ -449,6 +477,15 @@ export function simulate(economy, { now = 0 } = {}) {
449
477
  /** The walk of a box's stock in words (`stockWalk`). */
450
478
  const stockNote = (b, walk) => {
451
479
  const named = list => list.map(x => `outcome ${x.outcome} (${x.gives})`).join(', ');
480
+ if (walk.whenOut === 'giveNothing')
481
+ return `“${b.name}” keeps selling as its outcomes run out ("whenOut": "giveNothing"; its own opens alone): ${walk.runsOut
482
+ .map(
483
+ r =>
484
+ `${named(r.outcomes)} ${r.inDays ? `in about ${r.inDays} days` : 'from the start'}, then ${r.missPercent}% of draws give nothing`,
485
+ )
486
+ .join(
487
+ '; ',
488
+ )}. A draw on an outcome that is out is made and gives nothing: a paid one is not refunded, a free roll is over.`;
452
489
  if (walk.whenOut === 'stopSales')
453
490
  return `“${b.name}” stops selling ${walk.stopsSellingInDays ? `in about ${walk.stopsSellingInDays} days` : 'from the start'}, when ${named(walk.by)} runs out (its own opens alone): it isn’t sold while an item, a stash or a named NFT couldn’t cover, at the most any outcome takes of it in one draw, the draws of a purchase and of those still waiting for the oracle ("whenOut": "stopSales"), so every roll is on the odds as written. Restock it, or edit the box, to sell again; "drawLeft" would keep selling, the rest’s chances rising.`;
454
491
  const steps = walk.runsOut.map(
@@ -567,6 +604,16 @@ export function simulate(economy, { now = 0 } = {}) {
567
604
  // What it gives that runs out, as the box says (`whenOut`): it stops selling, or draws among what is left.
568
605
  const walk = !bought && b.active !== false ? stockWalk(b, opens) : null;
569
606
  if (walk) notes.push(stockNote(b, walk));
607
+ // Each outcome's chance for a player who passes every odds rule at its most (order.rs, rules.rs `boosted`): counted
608
+ // in BigInt, each weight stopping at the largest. The rules count on every route, read when a roll is first asked.
609
+ const raised = (b.odds ?? []).length ? boostedWeights(b) : null;
610
+ const raisedSum = raised ? raised.reduce((n, x) => n + x, 0n) : 0n;
611
+ const oddsBoosted =
612
+ raised && raisedSum > 0n ? raised.map(x => round(Number((x * 100_000_000n) / raisedSum) / 1_000_000, 6)) : null;
613
+ if (oddsBoosted)
614
+ notes.push(
615
+ `“${b.name}”’s odds rules raise its chances to ${oddsBoosted.map((c, i) => `outcome ${i} ${c}%`).join(', ')} for a player who passes them all at their most. They count on every route: on a purchase, and on a roll another route gives, read when the roll is first asked, not when it was won.`,
616
+ );
570
617
  return {
571
618
  key: b.key,
572
619
  name: b.name,
@@ -596,7 +643,8 @@ export function simulate(economy, { now = 0 } = {}) {
596
643
  )
597
644
  ? { limitedStock: true }
598
645
  : {}),
599
- ...(!bought ? { whenOut: b.whenOut === 'drawLeft' ? 'drawLeft' : 'stopSales' } : {}),
646
+ ...(!bought ? { whenOut: b.whenOut ?? 'stopSales' } : {}),
647
+ ...(oddsBoosted ? { oddsBoosted } : {}),
600
648
  ...(walk ? { stock: walk } : {}),
601
649
  ...(isSol && known && price ? { returnOfRealCostPercent: round((value / (price + extrasSol)) * 100, 4) } : {}),
602
650
  };
@@ -877,6 +925,58 @@ export function simulate(economy, { now = 0 } = {}) {
877
925
  return null;
878
926
  };
879
927
  const prized = placed.filter(p => prizeLine(p.prize)).map(p => worthOf(prizeLine(p.prize)));
928
+ // A weighted round (`timeWeighted`): each purchase weighs its tickets times the seconds from it to the close of
929
+ // sales, and a place is won by weight. With purchases spread evenly over the selling time, a ticket bought at the
930
+ // open counts twice the average, one bought halfway the average, one in the last hour next to nothing.
931
+ const closesBefore = toSeconds(l.closesBefore ?? 0);
932
+ const selling = Math.max(1, every - closesBefore);
933
+ const weighted = l.timeWeighted
934
+ ? {
935
+ sellsForDays: round(selling / DAY, 4),
936
+ atOpen: 2,
937
+ halfway: 1,
938
+ lastHour: round(Number(purchaseWeight(1, selling - Math.min(3_600, selling), selling)) / (selling / 2), 6),
939
+ }
940
+ : null;
941
+ if (weighted)
942
+ notes.push(
943
+ `“${l.name}” weighs each purchase by its tickets times the seconds left until sales close: a ticket bought when a round opens counts about twice the average, one bought halfway the average, one in the last hour about ${weighted.lastHour} of it. A buyer sees what their purchase weighs before they buy.`,
944
+ );
945
+ // Places of tokens from a pool read it once, when the draw is asked: one that starts short pays as "whenShort" says.
946
+ const shortPools = [];
947
+ const fromPools = new Map();
948
+ for (const p of placed) {
949
+ const line = prizeLine(p.prize);
950
+ const key = line?.gives === 'tokens' ? prizePool(line) : null;
951
+ if (key) fromPools.set(key, [...(fromPools.get(key) ?? []), p]);
952
+ }
953
+ for (const [key, runs] of fromPools) {
954
+ const decimals = m.tokens.get(m.pool.get(key)?.currency)?.decimals ?? 0;
955
+ const units = v => {
956
+ try {
957
+ return toUnits(v ?? 0, decimals);
958
+ } catch {
959
+ return 0n;
960
+ }
961
+ };
962
+ const holds = units(m.pool.get(key)?.seed);
963
+ const rows = runs.map(p => ({ each: units(prizeLine(p.prize).amount), places: p.times ?? 1 }));
964
+ const wins = rows.reduce((n, r) => n + r.each * BigInt(r.places), 0n);
965
+ if (holds >= wins) continue;
966
+ const whenShort = runs[0].whenShort ?? 'inOrder';
967
+ const pays = shortPays(rows, holds, whenShort);
968
+ const scale = 10 ** decimals;
969
+ shortPools.push({
970
+ pool: key,
971
+ holds: Number(holds) / scale,
972
+ wins: Number(wins) / scale,
973
+ whenShort,
974
+ each: pays.map(x => Number(x.each) / scale),
975
+ });
976
+ notes.push(
977
+ `“${l.name}”’s places paid from “${key}” win ${Number(wins) / scale} and the pool starts with ${Number(holds) / scale}: if it holds no more when a draw is asked, they get ${pays.map(x => Number(x.each) / scale).join(', ')} each ("whenShort": "${whenShort}").`,
978
+ );
979
+ }
880
980
  // One ticket bought on its own: the fee and the record are per purchase, so a bundle of tickets shares them. The
881
981
  // record's rent comes back once the round is done with it, and a no-loss ticket's price after the draw: both held.
882
982
  const realCost = realCostOf({
@@ -918,6 +1018,9 @@ export function simulate(economy, { now = 0 } = {}) {
918
1018
  roundRentSol: round(Number(roundRentOf(l, m)) / 1e9),
919
1019
  ...(l.ticketNft ? { ticketNft: true } : {}),
920
1020
  ...(l.noLoss ? { noLoss: true } : {}),
1021
+ ...(closesBefore ? { closesBeforeDays: round(closesBefore / DAY, 4) } : {}),
1022
+ ...(weighted ? { weighted } : {}),
1023
+ ...(shortPools.length ? { shortPools } : {}),
921
1024
  realCost,
922
1025
  };
923
1026
  });
package/js/mcp/tools.mjs CHANGED
@@ -354,7 +354,7 @@ export const tools = [
354
354
  name: 'xsolana_check',
355
355
  annotations: READ,
356
356
  description:
357
- 'Check an economy spec: refusals (each with a code, what it means and its fix) stop it from going live; warnings are what the person must hear (chance that pays money, revenue paid to stakers, an unfunded prize, a grant without limits, a mint’s powers). Run it after every change. Give the spec inline (`spec`), a file, or an example.',
357
+ 'Check an economy spec: refusals (each with a code, what it means and its fix) stop it from going live; warnings are what the person must hear (chance that pays money, revenue paid to stakers, an unfunded prize, a grant without limits, a mint’s powers, a time far off with its date in words, a token a player can hold a payment up in, a box longer than one answer of the oracle’s holds). The program refuses only what it must: much of what a project sets up is taken and warned of, never refused, so tell the person each warning in plain words. Run it after every change. Give the spec inline (`spec`), a file, or an example.',
358
358
  inputSchema: { type: 'object', properties: SOURCE, additionalProperties: false },
359
359
  async handler(input, ctx) {
360
360
  const { economy, from } = await load(input, ctx);
@@ -376,7 +376,7 @@ export const tools = [
376
376
  name: 'xsolana_simulate',
377
377
  annotations: READ,
378
378
  description:
379
- 'The quick simulation (expected values from the spec, instant): what each box, ticket and craft gives back against its price, the house edge, each pool’s money in and out per day and whether it runs dry, settles or grows, farm rewards per unit staked, what players, the treasury and the platform get each day. `scenario` overrides the spec’s own ({"perDay": {"box:flip": 1000}, "days": 30, "staked": {"<farm>": 1000}}). Quote its numbers; never your own arithmetic.',
379
+ 'The quick simulation (expected values from the spec, instant): what each box, ticket and craft gives back against its price, the house edge, each pool’s money in and out per day and whether it runs dry, settles or grows, farm rewards per unit staked, what players, the treasury and the platform get each day; a box’s chances with its odds rules at their most, what a box that gives nothing for a miss misses as its outcomes run out, what a ticket weighs in a lottery weighed by time, and what places get from a pool that starts short. `scenario` overrides the spec’s own ({"perDay": {"box:flip": 1000}, "days": 30, "staked": {"<farm>": 1000}}). Quote its numbers; never your own arithmetic.',
380
380
  inputSchema: {
381
381
  type: 'object',
382
382
  properties: {