README.md
gno.land/p/moul/x/games/accrual/v0
Stock that fills at a rate while nobody is playing: New, Rate.Advance, Rate.At,
Rate.Full.
1import "gno.land/p/moul/x/games/accrual/v0"
2
3r, _ := accrual.New(1, 60, 120) // 1 ore a minute, the mine holds 120
4stock, anchor, _ := r.Advance(0, t, t+3599) // → 59, t+3540 (the anchor is NOT t+3599)
5stock, _ = r.At(stock, anchor, t+7200) // → 120, capped, and nothing was written
6full, _ := r.Full(0, t) // → t+7200, when production starts spilling
An idle game's resource field, a 4X's warehouse, a pet's hunger: all the same shape. Store what you had and when, compute the rest on demand, and a player who is away for a month costs the chain nothing. No cron, no keeper, no per-block hook.
The anchor does not advance to now, deliberately. It advances by the whole periods it
actually paid for, and the part-period in progress stays on the clock. That single decision
is the package:
Advance(s, a, c) == Advance(Advance(s, a, b), b, c)fora <= b <= c
How often you call must not change where you end up. Re-anchor to now instead and the
division truncates, so every call forfeits the fraction it landed in. Run backwards on a
decaying stat it stops being unfair and starts being free: r/moul/x/daily/tamagotchi
decayed by elapsed/2 and elapsed/3, so at elapsed == 1 it decayed by nothing, and
feeding once per block made the pet immortal while every other cadence died inside 240
blocks. Fixed in #221. The test here asserts the invariant over pseudo-random partitions,
because hand-picked spans are exactly the ones a wrong implementation already passes:
reverting the anchor fix turns four tests red.
Three more behaviours worth knowing before you use it:
AtisAdvancewith the anchor discarded. One implementation, two call sites. The same realm's second bug was a Render with its own copy of the decay, so the page showed a state the next call would not honour; two implementations of one rule means one of them is the stale one somebody acts on.- A capped rate never returns
ErrOverflow. However long the player was away, the answer is the cap, so a realm can size a warehouse without also bounding its own lifetime. Only an uncapped rate can run out ofint64, and it says so rather than wrapping into a negative stock. nowbefore the anchor is an error, not zero elapsed. Time running backwards means a stored anchor from another clock or a test that rewound. Swallowing it hides the bug for as long as the stock looks plausible.
Everything is O(1) in elapsed time, which is a requirement rather than an optimisation:
Render runs under a query gas limit and may not write, so the player who comes back after
a month is exactly the one whose page would time out if the projection were iterative.
Times are int64 in the caller's unit. Prefer a timestamp over a block height: a
height-denominated rate reprices itself every time the chain's block time moves, and
gno.land's has moved from about 4.1s to 3.405s inside a month.
Live demo: r/moul/x/games/idle.
Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.
🧪 Highly experimental — potentially vibe-coded. Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: DISCLAIMER.