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.

impl.gno

11.97 Kb · 357 lines
  1// Package impl is the first rules engine for gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/shots.
  2//
  3// It owns the game state machine and pot rules; the state itself, and the
  4// coins, stay in the shots realm, reached through shots.Store.
  5//
  6// Game:    open --join--> playing --player wins--> won (pot paid out, closed)
  7//
  8//	^                 |
  9//	+----player loses-+ (entry fee stays in the pot)
 10//	open --creator cancels, nobody played yet--> cancelled (pot refunded)
 11//
 12// Session: each round the player shoots, then keeps. Every move is played
 13// out in the transaction that makes it, from 32 bytes derived from that
 14// block's timestamp (see entropy).
 15//
 16// ENTROPY. The seed is SHA-256 of the block time (unix nanoseconds) and the
 17// kick's identity (game, session, kick number), so two kicks in one block
 18// still differ. The block time is not known when the player signs, but it is
 19// not secret either: the proposer chooses it within consensus bounds, and
 20// anything else running in the same transaction can read the result. In
 21// particular a transaction that makes a move and then, in a later message,
 22// reads the session and fails on a bad outcome reverts the whole kick, so a
 23// player can retry until they like the result (for the price of gas). Fine
 24// for play money; a commit-reveal or a VRF is needed before real stakes, and
 25// an upgraded implementation can switch without touching games or pots.
 26package impl
 27
 28import (
 29	"chain"
 30	"chain/runtime"
 31	"crypto/sha256"
 32	"strconv"
 33	"time"
 34
 35	"gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/penalty/physics/v0"
 36	"gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/penalty/v0"
 37	"gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/shootout/v0"
 38	"gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/shots"
 39)
 40
 41const (
 42	MinDeposit     = 1000000 // ugnot to seed a pot (1 GNOT)
 43	MinEntryFee    = 100000  // ugnot (0.1 GNOT)
 44	DefaultTimeout = 1000    // blocks a challenger may sit idle
 45	MinTimeout     = 30
 46	MaxTimeout     = 200000
 47)
 48
 49type engine struct{}
 50
 51var instance = &engine{}
 52
 53// Instance returns the implementation, for a caller that registers it
 54// explicitly rather than relying on init.
 55func Instance() shots.Impl { return instance }
 56
 57func init(cur realm) {
 58	shots.Register(cross(cur), instance)
 59}
 60
 61func (e *engine) Describe() string {
 62	return "impl/v1: Spot Kick physics (penalty/physics/v0, bit-identical to the browser engine), seed = SHA-256 of the block time"
 63}
 64
 65func (e *engine) SimulateShot(shot penalty.ShotParams, plan penalty.KeeperPlan, cfg penalty.Config) penalty.Result {
 66	return physics.SimulateShot(shot, plan, cfg)
 67}
 68
 69// ---------------------------------------------------------------- games
 70
 71func (e *engine) CreateGame(_ int, rlm realm, creator address, deposit, entryFee, timeoutBlocks int64, config string) uint64 {
 72	cfg, err := penalty.ParseConfig(config)
 73	if err != nil {
 74		panic(err)
 75	}
 76	if deposit < MinDeposit {
 77		panic("shots: send at least " + itoa(MinDeposit) + "ugnot to seed the pot")
 78	}
 79	if entryFee < MinEntryFee {
 80		panic("shots: the entry fee must be at least " + itoa(MinEntryFee) + "ugnot")
 81	}
 82	if timeoutBlocks == 0 {
 83		timeoutBlocks = DefaultTimeout
 84	}
 85	if timeoutBlocks < MinTimeout || timeoutBlocks > MaxTimeout {
 86		panic("shots: timeoutBlocks must be within " + itoa(MinTimeout) + ".." + itoa(MaxTimeout))
 87	}
 88	st := shots.Store(0, rlm)
 89	g := shootout.Game{
 90		ID:            st.NewGameID(),
 91		Creator:       creator,
 92		Config:        cfg,
 93		EntryFee:      entryFee,
 94		TimeoutBlocks: timeoutBlocks,
 95		Pot:           deposit,
 96		Status:        shootout.GameOpen,
 97		CreatedHeight: runtime.ChainHeight(),
 98	}
 99	st.PutGame(g)
100	chain.Emit("GameCreated", "game", u64(g.ID), "creator", string(creator), "pot", itoa(deposit),
101		"entryFee", itoa(entryFee), "config", cfg.String())
102	return g.ID
103}
104
105func (e *engine) TopUp(_ int, rlm realm, from address, gameID uint64, paid int64) {
106	st := shots.Store(0, rlm)
107	g := mustGame(st, gameID)
108	if g.Status != shootout.GameOpen && g.Status != shootout.GamePlaying {
109		panic("shots: game " + u64(gameID) + " is closed")
110	}
111	if paid <= 0 {
112		panic("shots: send ugnot to top up the pot")
113	}
114	g.Pot += paid
115	st.PutGame(g)
116	chain.Emit("PotToppedUp", "game", u64(gameID), "from", string(from), "amount", itoa(paid), "pot", itoa(g.Pot))
117}
118
119func (e *engine) CancelGame(_ int, rlm realm, caller address, gameID uint64) {
120	st := shots.Store(0, rlm)
121	g := mustGame(st, gameID)
122	if caller != g.Creator {
123		panic("shots: only the creator can cancel game " + u64(gameID))
124	}
125	if g.Status != shootout.GameOpen || g.Sessions > 0 {
126		panic("shots: a game can only be cancelled before anyone has played it")
127	}
128	refund := g.Pot
129	g.Pot = 0
130	g.Status = shootout.GameCancelled
131	g.ClosedHeight = runtime.ChainHeight()
132	st.PutGame(g)
133	st.Pay(g.Creator, refund)
134	chain.Emit("GameCancelled", "game", u64(gameID), "refund", itoa(refund))
135}
136
137// ---------------------------------------------------------------- sessions
138
139func (e *engine) JoinGame(_ int, rlm realm, player address, gameID uint64, paid int64) {
140	st := shots.Store(0, rlm)
141	g := mustGame(st, gameID)
142	if g.Status != shootout.GameOpen {
143		panic("shots: game " + u64(gameID) + " is not open (" + g.Status.String() + ")")
144	}
145	if paid != g.EntryFee {
146		panic("shots: send exactly the entry fee, " + itoa(g.EntryFee) + "ugnot")
147	}
148	h := runtime.ChainHeight()
149	g.Pot += paid
150	g.Sessions++
151	g.Status = shootout.GamePlaying
152	st.PutGame(g)
153	st.PutSession(shootout.Session{
154		GameID:           gameID,
155		No:               g.Sessions,
156		Player:           player,
157		Paid:             paid,
158		Status:           shootout.SessionPlaying,
159		JoinHeight:       h,
160		LastActionHeight: h,
161	})
162	chain.Emit("GameJoined", "game", u64(gameID), "session", strconv.Itoa(g.Sessions), "player", string(player),
163		"pot", itoa(g.Pot))
164}
165
166func (e *engine) TakeShot(_ int, rlm realm, player address, gameID uint64, shot penalty.ShotInput) shootout.Kick {
167	st := shots.Store(0, rlm)
168	g, s := mustPlayersSession(st, gameID, player)
169	if err := shot.Validate(g.Config); err != nil {
170		panic(err)
171	}
172	return e.kick(st, g, s, penalty.SidePlayer, shot, penalty.KeeperPlan{})
173}
174
175func (e *engine) SubmitKeeperPlan(_ int, rlm realm, player address, gameID uint64, plan penalty.KeeperPlan) shootout.Kick {
176	st := shots.Store(0, rlm)
177	g, s := mustPlayersSession(st, gameID, player)
178	return e.kick(st, g, s, penalty.SideChain, penalty.ShotInput{}, plan)
179}
180
181// kick plays the player's move against the chain's, both completed by this
182// block's seed, records it, and ends the session if the shootout is decided.
183func (e *engine) kick(st *shots.StateRef, g shootout.Game, s shootout.Session, kicker penalty.Side,
184	shot penalty.ShotInput, plan penalty.KeeperPlan) shootout.Kick {
185	if want := s.Score.Next(); want != kicker {
186		if want == penalty.SidePlayer {
187			panic("shots: it is your turn to shoot (TakeShot)")
188		}
189		panic("shots: it is your turn to keep (SubmitKeeperPlan)")
190	}
191	h := runtime.ChainHeight()
192	now := time.Now().UnixNano()
193	seed := entropy(g, s, now)
194	k := shootout.Kick{
195		Round:  s.Score.Round(),
196		Kicker: kicker,
197		Seed:   seed,
198		Height: h,
199		Time:   now,
200	}
201	if kicker == penalty.SidePlayer {
202		// The player shoots; the seed is the chain's keeper and the mishit.
203		k.Shot = shot.WithMishit(seed.Mishit())
204		k.Plan = seed.KeeperPlan()
205	} else {
206		// The player keeps; the seed is the chain's kick and its mishit.
207		k.Shot = seed.ChainShot(g.Config).WithMishit(seed.Mishit())
208		k.Plan = plan
209	}
210	k.Result = e.SimulateShot(k.Shot, k.Plan, g.Config)
211	s.Kicks = append(s.Kicks, k)
212	s.Score.Record(kicker, k.Result.Outcome.Scored())
213	s.LastActionHeight = h
214	chain.Emit("Kick", "game", u64(g.ID), "session", strconv.Itoa(s.No), "round", strconv.Itoa(k.Round),
215		"kicker", kicker.String(), "outcome", k.Result.Outcome.String(), "seed", seed.Hex())
216
217	switch s.Score.Winner() {
218	case penalty.SidePlayer:
219		prize := g.Pot
220		s.Status = shootout.SessionWon
221		s.EndHeight = h
222		g.Pot = 0
223		g.Status = shootout.GameWon
224		g.Winner = s.Player
225		g.ClosedHeight = h
226		st.PutGame(g)
227		st.PutSession(s)
228		st.Pay(s.Player, prize)
229		chain.Emit("PotWon", "game", u64(g.ID), "session", strconv.Itoa(s.No), "player", string(s.Player),
230			"prize", itoa(prize))
231		return k
232	case penalty.SideChain:
233		s.Status = shootout.SessionLost
234		s.EndHeight = h
235		g.Status = shootout.GameOpen
236		chain.Emit("SessionLost", "game", u64(g.ID), "session", strconv.Itoa(s.No), "player", string(s.Player),
237			"pot", itoa(g.Pot))
238	}
239	st.PutGame(g)
240	st.PutSession(s)
241	return k
242}
243
244func (e *engine) Forfeit(_ int, rlm realm, caller address, gameID uint64) {
245	st := shots.Store(0, rlm)
246	g := mustGame(st, gameID)
247	if g.Status != shootout.GamePlaying {
248		panic("shots: nobody is playing game " + u64(gameID))
249	}
250	s, _ := session(st, gameID, g.Sessions)
251	h := runtime.ChainHeight()
252	if caller != s.Player && h-s.LastActionHeight <= g.TimeoutBlocks {
253		panic("shots: the player has " + itoa(g.TimeoutBlocks-(h-s.LastActionHeight)+1) +
254			" more blocks before they can be forfeited")
255	}
256	s.Status = shootout.SessionForfeited
257	s.EndHeight = h
258	g.Status = shootout.GameOpen
259	st.PutGame(g)
260	st.PutSession(s)
261	chain.Emit("SessionForfeited", "game", u64(gameID), "session", strconv.Itoa(s.No),
262		"player", string(s.Player), "by", string(caller), "pot", itoa(g.Pot))
263}
264
265// entropy is the kick's 32-byte seed: SHA-256 of the block time and the
266// kick's identity (see the package comment for what that does and does not
267// guarantee).
268func entropy(g shootout.Game, s shootout.Session, blockTime int64) penalty.Seed {
269	var b [8]byte
270	for i := 0; i < 8; i++ {
271		b[i] = byte(uint64(blockTime) >> (56 - 8*uint(i)))
272	}
273	id := "|" + u64(g.ID) + "|" + strconv.Itoa(s.No) + "|" + strconv.Itoa(len(s.Kicks))
274	return penalty.Seed(sha256.Sum256(append(b[:], []byte(id)...)))
275}
276
277// ---------------------------------------------------------------- helpers
278
279func mustGame(st *shots.StateRef, id uint64) shootout.Game {
280	g, ok := st.Game(id)
281	if !ok {
282		panic("shots: no game " + u64(id))
283	}
284	return ownGame(g)
285}
286
287// session reads the live session of a game as this realm's own copy.
288func session(st *shots.StateRef, id uint64, no int) (shootout.Session, bool) {
289	s, ok := st.Session(id, no)
290	if !ok {
291		return s, false
292	}
293	return ownSession(s), true
294}
295
296// ownGame and ownSession rebuild values read through shots.Store in this
297// realm. What Store hands back was allocated by the shots realm, and the VM
298// refuses writes to another realm's objects (a pointer-method update such as
299// Shootout.Record trips its cross-realm check), so the engine only ever
300// mutates copies it built itself; PutGame/PutSession copy them back.
301func ownGame(g shootout.Game) shootout.Game {
302	return shootout.Game{
303		ID:            g.ID,
304		Creator:       g.Creator,
305		Config:        g.Config,
306		EntryFee:      g.EntryFee,
307		TimeoutBlocks: g.TimeoutBlocks,
308		Pot:           g.Pot,
309		Status:        g.Status,
310		Sessions:      g.Sessions,
311		Winner:        g.Winner,
312		CreatedHeight: g.CreatedHeight,
313		ClosedHeight:  g.ClosedHeight,
314	}
315}
316
317func ownSession(s shootout.Session) shootout.Session {
318	kicks := make([]shootout.Kick, len(s.Kicks))
319	for i := range s.Kicks {
320		kicks[i] = s.Kicks[i]
321	}
322	return shootout.Session{
323		GameID: s.GameID,
324		No:     s.No,
325		Player: s.Player,
326		Paid:   s.Paid,
327		Score: penalty.Shootout{
328			PlayerKicks: s.Score.PlayerKicks,
329			PlayerGoals: s.Score.PlayerGoals,
330			ChainKicks:  s.Score.ChainKicks,
331			ChainGoals:  s.Score.ChainGoals,
332		},
333		Kicks:            kicks,
334		Status:           s.Status,
335		JoinHeight:       s.JoinHeight,
336		LastActionHeight: s.LastActionHeight,
337		EndHeight:        s.EndHeight,
338	}
339}
340
341func mustPlayersSession(st *shots.StateRef, gameID uint64, player address) (shootout.Game, shootout.Session) {
342	g := mustGame(st, gameID)
343	if g.Status != shootout.GamePlaying {
344		panic("shots: nobody is playing game " + u64(gameID) + "; join it first")
345	}
346	s, ok := session(st, gameID, g.Sessions)
347	if !ok || s.Player != player {
348		panic("shots: you are not the challenger in game " + u64(gameID))
349	}
350	if s.Status != shootout.SessionPlaying {
351		panic("shots: your shootout in game " + u64(gameID) + " is over")
352	}
353	return g, s
354}
355
356func itoa(n int64) string { return strconv.FormatInt(n, 10) }
357func u64(n uint64) string { return strconv.FormatUint(n, 10) }