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