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