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

clock.gno

7.71 Kb · 207 lines
  1// Package clock is a deadline that actions push forward, with the two guards a
  2// naive "extend on every action" clock lacks.
  3//
  4// Any last-action-wins game has the same clock: every action buys more time, so
  5// the game ends only when nobody acts. Written naively it has two failure modes,
  6// and both have been observed on chain rather than reasoned about.
  7//
  8// The first is that it never ends. If the grant per action is constant, a
  9// contested pot is extended forever and the prize is always worth one more
 10// action. Life is the terminator: an absolute end, fixed when the clock opens,
 11// that no bump can pass. A game with real money in it needs one.
 12//
 13// The second is the opposite, and it shows up as soon as the grant decays to
 14// relieve the pressure of the first: a grant that is a share of the life left
 15// shrinks toward zero near the end, so the last stretch becomes too short for a
 16// transaction to land in, and whoever is holding when that happens simply wins.
 17// Floor is the anti-snipe: a bump always leaves at least that much on the
 18// clock, however little is left to share.
 19//
 20// Times are int64 and the unit is the caller's: block heights or unix seconds,
 21// as long as it is consistent. Nothing here reads the chain, so a realm can
 22// test its whole endgame without one.
 23//
 24// A game built on this package is at
 25// [r/moul/x/games/lastwords](/r/moul/x/games/lastwords/v0).
 26package clock
 27
 28import "errors"
 29
 30const maxInt64 = int64(9223372036854775807)
 31
 32var (
 33	// ErrBadWindow is returned when the opening grant is not positive.
 34	ErrBadWindow = errors.New("clock: window must be positive")
 35	// ErrBadFloor is returned when the floor is negative or exceeds the window.
 36	ErrBadFloor = errors.New("clock: floor must be between zero and the window")
 37	// ErrBadLife is returned when the life is negative, or positive but shorter
 38	// than the opening window, which would close the clock before it opened.
 39	ErrBadLife = errors.New("clock: life must be zero (unbounded) or at least the window")
 40	// ErrBadShare is returned when a share is outside 0..100.
 41	ErrBadShare = errors.New("clock: share must be a percentage between 0 and 100")
 42	// ErrUnbounded is returned when a share of the remaining life is asked of a
 43	// clock that has no hard end to measure against.
 44	ErrUnbounded = errors.New("clock: a share of the life needs a bounded clock")
 45	// ErrOverflow is returned when the requested times do not fit in an int64.
 46	ErrOverflow = errors.New("clock: times overflow int64")
 47)
 48
 49// Clock is a deadline plus the three bounds that make it terminate: the window
 50// a bump grants, the floor a bump always leaves, and the life it can never pass.
 51//
 52// The zero Clock is not usable; build one with New.
 53type Clock struct {
 54	start    int64
 55	deadline int64
 56	window   int64
 57	floor    int64
 58	life     int64 // 0 means unbounded
 59}
 60
 61// New opens a clock at now, due now+window.
 62//
 63// window is what a plain Bump grants. floor is the minimum a bump leaves on the
 64// clock, and must not exceed the window (a floor above the window would mean
 65// every bump granting more than the window, which is not a floor). life is the
 66// total lifetime from now, after which the deadline can no longer move; zero
 67// leaves the clock unbounded, which is the shape that never terminates, so pass
 68// it deliberately.
 69func New(now, window, floor, life int64) (*Clock, error) {
 70	if window <= 0 {
 71		return nil, ErrBadWindow
 72	}
 73	if floor < 0 || floor > window {
 74		return nil, ErrBadFloor
 75	}
 76	if life < 0 || (life > 0 && life < window) {
 77		return nil, ErrBadLife
 78	}
 79	if window > maxInt64-now || life > maxInt64-now {
 80		return nil, ErrOverflow
 81	}
 82	return &Clock{
 83		start:    now,
 84		deadline: now + window,
 85		window:   window,
 86		floor:    floor,
 87		life:     life,
 88	}, nil
 89}
 90
 91// Start returns when the clock opened.
 92func (c *Clock) Start() int64 { return c.start }
 93
 94// Deadline returns the time the clock currently expires at.
 95func (c *Clock) Deadline() int64 { return c.deadline }
 96
 97// Window returns the grant a plain Bump aims for.
 98func (c *Clock) Window() int64 { return c.window }
 99
100// Floor returns the minimum a bump leaves on the clock.
101func (c *Clock) Floor() int64 { return c.floor }
102
103// Life returns the configured lifetime, zero when unbounded.
104func (c *Clock) Life() int64 { return c.life }
105
106// HardEnd returns the time no bump can push the deadline past, or zero when the
107// clock is unbounded.
108func (c *Clock) HardEnd() int64 {
109	if c.life == 0 {
110		return 0
111	}
112	return c.start + c.life
113}
114
115// Expired reports whether the clock has run out at now. The deadline itself is
116// past it: a clock due at 100 is expired at 100, so an action and an expiry can
117// never both be valid at the same instant.
118func (c *Clock) Expired(now int64) bool { return now >= c.deadline }
119
120// Remaining returns how much time is left at now, never negative.
121func (c *Clock) Remaining(now int64) int64 {
122	if now >= c.deadline {
123		return 0
124	}
125	return c.deadline - now
126}
127
128// Elapsed returns how long the clock has been open at now, never negative.
129func (c *Clock) Elapsed(now int64) int64 {
130	if now <= c.start {
131		return 0
132	}
133	return now - c.start
134}
135
136// Final reports whether the deadline has reached the hard end, so no further
137// bump can move it. A game should say so on its page: it is the only moment at
138// which holding is worth more than acting.
139func (c *Clock) Final() bool { return c.life > 0 && c.deadline >= c.HardEnd() }
140
141// Bump extends the deadline by the window. See BumpBy for the guards.
142func (c *Clock) Bump(now int64) int64 { return c.BumpBy(now, c.window) }
143
144// BumpBy extends the deadline to now+grant and returns the new deadline.
145//
146// The caller owns the policy, so grant is whatever it wants: a constant, a share
147// of what is left, a function of the amount paid. The clock owns the four
148// invariants that policy keeps getting wrong:
149//
150//   - An expired clock never restarts. Once it has run out the deadline is
151//     frozen, so a late action cannot reopen a settled game.
152//   - The deadline never moves backwards. A small grant late in the game leaves
153//     the existing deadline alone rather than shortening it.
154//   - A bump always leaves at least the floor on the clock, so a decaying grant
155//     cannot be shaved below the time a transaction needs to land.
156//   - The deadline never passes the hard end.
157func (c *Clock) BumpBy(now, grant int64) int64 {
158	if c.Expired(now) {
159		return c.deadline
160	}
161	if grant < c.floor {
162		grant = c.floor
163	}
164	want := c.deadline
165	if grant <= maxInt64-now && now+grant > want {
166		want = now + grant
167	} else if grant > maxInt64-now {
168		want = maxInt64
169	}
170	if end := c.HardEnd(); c.life > 0 && want > end {
171		want = end
172	}
173	c.deadline = want
174	return c.deadline
175}
176
177// BumpShare grants pct percent of the time left until the hard end, and is the
178// decaying grant a converging game wants.
179//
180// A grant that is a share of what is left on the DEADLINE cannot extend
181// anything: now+(deadline-now)*pct/100 is always before the deadline itself. A
182// share of the distance to the hard end is the one that works. Each action
183// closes a fraction of the gap, so the deadline crawls toward the hard end and
184// the grants shrink as it does, which is exactly the "the pot is worth one more
185// action" pressure a fixed grant never relieves. The floor is what stops the
186// tail of that curve from becoming too short to act in.
187//
188// It requires a bounded clock: there is no distance to share without one.
189func (c *Clock) BumpShare(now, pct int64) (int64, error) {
190	if c.life == 0 {
191		return c.deadline, ErrUnbounded
192	}
193	if pct < 0 || pct > 100 {
194		return c.deadline, ErrBadShare
195	}
196	left := c.HardEnd() - now
197	if left < 0 {
198		left = 0
199	}
200	var grant int64
201	if left > maxInt64/100 {
202		grant = left / 100 * pct // lossy, but only where exactness is meaningless
203	} else {
204		grant = left * pct / 100 // multiply first, so a small gap still grants
205	}
206	return c.BumpBy(now, grant), nil
207}