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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}