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) }