Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

v0 source pure

Package accrual is stock that fills at a rate while nobody is playing: the resource field of an idle game, the wareho...

Readme View source

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) for a <= 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:

  • At is Advance with 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 of int64, and it says so rather than wrapping into a negative stock.
  • now before 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.

Overview

Package accrual is stock that fills at a rate while nobody is playing: the resource field of an idle game, the warehouse of a 4X, anything whose state is a function of how long it has been left alone.

The shape is always the same. Store what you had and when, and compute the rest on demand; a player who does nothing for a month costs nothing, and the realm needs no cron, no keeper and no per-block hook. That much is obvious. What is not obvious is that the obvious implementation is wrong in three ways, each of which has been observed in this repository rather than reasoned about.

1. Re-anchoring to now makes acting more often pay

Written naively, a claim advances the anchor to now and computes elapsed/Period whole periods of production. The division truncates, so every claim silently forfeits the part-period it lands in, and a player who claims twice forfeits twice. Run in reverse on a decaying stat it is worse than unfair, it is free: r/moul/x/daily/tamagotchi decayed by elapsed/2 and elapsed/3, so at elapsed == 1 it decayed by nothing at all, and feeding once per block made the pet immortal while every other cadence died inside 240 blocks. It was fixed in #221.

The invariant that rules it out is worth stating on its own, because it is the whole contract of this package:

Example
1Advance(s, a, c) == Advance(Advance(s, a, b), b, c)   for a <= b <= c

How often you call must not change where you end up. Rate.Advance gets there by advancing the anchor only by the WHOLE periods it paid for, leaving the remainder on the clock rather than throwing it away. TestSplitInvariant asserts it over pseudo-random partitions rather than over a handful of hand-picked spans, because the hand-picked spans are exactly the ones a wrong implementation already passes.

2. A view that does not share the write path's arithmetic drifts from it

The same realm had a second copy of the decay for its Render, so the page showed a pet the next call would not honour. Two implementations of one rule means one of them is the stale one somebody acts on. Here there is one function: Rate.At is Rate.Advance with the anchor discarded, so a view cannot disagree with a write even in principle.

3. A projection that is not closed form cannot be rendered at all

Render runs under a query gas limit and may not write, so whatever it computes has to be bounded however long the player was away. Anything iterative, stepping a simulation once per block, is fine in a transaction and unusable in a page: the player who comes back after a month is exactly the one whose page times out. Everything here is O(1) in elapsed time, which is the property that makes a live-updating page possible, not an optimisation.

Times are int64 and the unit is the caller's, block heights or unix seconds, as long as it is consistent. Prefer a timestamp: a block-height rate drifts in wall-clock terms every time the chain's block time moves, and gno.land's has moved from about 4.1s to 3.405s inside one month. Nothing here reads the chain, so a realm can test a year of its own economy without one.

A game built on this package is at r/moul/x/games/idle(/r/moul/x/games/idle/v0).

r/moul/x/daily/tamagotchi: /r/moul/x/daily/tamagotchi/v0

Variables 1

var ErrBadAmount, ErrBadPeriod, ErrBadCap, ErrNegativeStock, ErrBackwards, ErrOverflow

 1var (
 2	// ErrBadAmount is returned when the amount produced per period is not
 3	// positive. A zero rate is a bug in the caller, not a valid still life:
 4	// it makes Full unanswerable and every Advance a no-op.
 5	ErrBadAmount = errors.New("accrual: amount must be positive")
 6	// ErrBadPeriod is returned when the period is not positive.
 7	ErrBadPeriod = errors.New("accrual: period must be positive")
 8	// ErrBadCap is returned when the cap is negative. Zero is legal and
 9	// means unbounded.
10	ErrBadCap = errors.New("accrual: cap must not be negative")
11	// ErrNegativeStock is returned when the stock handed in is negative,
12	// which would let a caller mint by going through zero.
13	ErrNegativeStock = errors.New("accrual: stock must not be negative")
14	// ErrBackwards is returned when now falls before the anchor. Time moving
15	// backwards is a caller bug (a stored anchor from another clock, a test
16	// that rewound), and silently treating it as zero elapsed would hide it.
17	ErrBackwards = errors.New("accrual: now must not fall before the anchor")
18	// ErrOverflow is returned when the arithmetic would wrap int64.
19	ErrOverflow = errors.New("accrual: int64 overflow")
20)
source

Functions 1

func New

1func New(amount, period, capacity int64) (Rate, error)
source

New validates and returns a Rate. A zero cap means unbounded.

Types 1

type Rate

struct
1type Rate struct {
2	Amount int64 // produced each whole Period
3	Period int64 // time units in one period
4	Cap    int64 // ceiling on the stock; zero means unbounded
5}
source

Rate is production per unit of time, against a ceiling. Construct with New so the invariants are checked once instead of on every call.

Methods on Rate

func Advance

method on Rate
1func (r Rate) Advance(stock, anchor, now int64) (int64, int64, error)
source

Advance returns the stock and the new anchor at time now, given stock held since anchor.

The new anchor is NOT now. It is the anchor plus the whole periods actually paid out, so the part-period in progress stays on the clock and splitting a span into pieces yields exactly what advancing it in one go would:

Example
1Advance(s, a, c) == Advance(Advance(s, a, b), b, c)   for a <= b <= c

Production past the cap is lost, which is what a cap is for. Saturation is idempotent, so it does not break the equality above, and it means a CAPPED rate can never return ErrOverflow: the answer is the cap however long the player was away. Only an uncapped rate can run out of int64.

func At

method on Rate
1func (r Rate) At(stock, anchor, now int64) (int64, error)
source

At is Advance with the anchor discarded: the read-only view of the same arithmetic, for a Render that must not write. It is defined in terms of Advance on purpose, so a page can never show a number a write would not honour.

func Full

method on Rate
1func (r Rate) Full(stock, anchor int64) (int64, error)
source

Full returns the time at which the stock first reaches the cap, for a realm that wants to tell a player when their production starts being wasted.

An uncapped rate returns zero, meaning never. A stock already at or past the cap returns the anchor, meaning now.

Imports 1

  • errors stdlib

Source Files 4