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

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