connect4.gno
13.00 Kb · 506 lines
1// package connect4_v3 is a staked Connect 4 game. A creator posts an offer with
2// a ugnot stake; anyone (or a named opponent) accepts with the same stake;
3// the winner takes the pot minus a flat house fee.
4//
5// The first mover is picked by commit-reveal: the creator offers with
6// sha256(passphrase) and must Reveal the passphrase within 90s of Accept, or
7// forfeit. The draw also mixes in the block height of Accept, which the
8// creator cannot know when committing (so it cannot grind a passphrase for a
9// known opponent), while the acceptor cannot use it without the passphrase.
10// Randomness computed in Accept alone could be re-rolled by an acceptor who
11// bundles [Accept, Play] in one atomic tx and lets it revert.
12//
13// Calls signed by an account session key (Quick play) may only Play, Reveal,
14// ClaimTimeout and Cancel: a stolen session key must not resign games, move
15// stakes or act as the owner.
16//
17// Every move has a 90s clock measured in block time. Nothing on-chain fires
18// by itself, so an expired clock is settled by anyone calling ClaimTimeout:
19// an unrevealed game is won by the acceptor, a game whose first mover never
20// moved is void and both stakes are refunded, otherwise the player who ran
21// out of time forfeits.
22package connect4_v3
23
24import (
25 "chain"
26 "chain/banker"
27 "chain/runtime"
28 "chain/runtime/unsafe"
29 "crypto/sha256"
30 "encoding/hex"
31 "strconv"
32 "strings"
33 "time"
34
35 "gno.land/p/nt/avl/v0"
36 "gno.land/p/nt/ownable/v0"
37)
38
39const (
40 moveTimeout = 90 * time.Second
41 maxOfferTTL = 60 // minutes
42 minStake = 1_000_000 // ugnot
43 denom = "ugnot"
44)
45
46type Status int
47
48const (
49 Open Status = iota
50 Playing
51 Won
52 Draw
53 Void
54 Cancelled
55)
56
57func (s Status) String() string {
58 return [...]string{"open", "playing", "won", "draw", "void", "cancelled"}[s]
59}
60
61type Game struct {
62 ID int
63 Creator address
64 Opponent address // optional; if set, only this address may accept
65 Acceptor address
66 Stake int64 // ugnot, per player
67 Fee int64 // ugnot house fee, fixed at offer time
68 ExpiresAt time.Time
69 // AcceptHeight is the block height of Accept, mixed into the first-mover draw.
70 AcceptHeight int64
71 // Commitment is the lowercase hex sha256 of the creator's passphrase.
72 Commitment string
73
74 Board board
75 Turn byte // 0 = awaiting Reveal, 1 = creator, 2 = acceptor
76 Moves int
77 LastCol int // column and row of the last piece dropped, valid if Moves > 0
78 LastRow int
79 LastMove time.Time
80 Status Status
81 Winner address
82}
83
84type Stats struct {
85 Games, Wins, Draws int
86 GnotWon int64 // total ugnot received from wins
87}
88
89var (
90 games avl.Tree // key(id) -> *Game
91 active avl.Tree // key(id) -> *Game, only Open and Playing
92 stats avl.Tree // address -> *Stats
93 nextID int
94 fee int64 = 100_000 // ugnot per decisive game
95 feesCollected int64
96 owner *ownable.Ownable // the deployer; nil (no owner) if deployed some other way
97 // testSession makes tests see every call as session-signed (no test hook sets one).
98 testSession bool
99)
100
101// The deployer owns the realm: deploy from the multisig that should own it.
102func init(cur realm) {
103 if cur.Previous().IsUserCall() {
104 owner = ownable.NewWithAddress(cur.Previous().Address())
105 }
106}
107
108// Offer posts a game open for validFor minutes. The stake is the ugnot sent
109// with the call. A non-empty opponent makes the offer private. commitment is
110// the lowercase hex sha256 of a secret passphrase, revealed after Accept.
111func Offer(cur realm, opponent address, validFor int, commitment string) int {
112 noSession("Offer")
113 stake := received(cur)
114 caller := cur.Previous().Address()
115 if opponent == caller {
116 panic("cannot challenge yourself")
117 }
118 if errs := validateOffer(stake, validFor, opponent, commitment); len(errs) > 0 {
119 panic(errs[0])
120 }
121 nextID++
122 g := &Game{
123 ID: nextID,
124 Creator: caller,
125 Opponent: opponent,
126 Stake: stake,
127 Fee: fee,
128 ExpiresAt: time.Now().Add(time.Duration(validFor) * time.Minute),
129
130 Commitment: commitment,
131 }
132 games.Set(key(g.ID), g)
133 active.Set(key(g.ID), g)
134 return g.ID
135}
136
137// validateOffer returns every rule an offer breaks. Offer panics with the
138// first; the "new" page lists them all before anyone signs.
139func validateOffer(stake int64, validFor int, opponent address, commitment string) []string {
140 var errs []string
141 if stake < minStake {
142 errs = append(errs, "stake below minimum of "+gnot(minStake))
143 }
144 if stake <= fee {
145 errs = append(errs, "stake must exceed the house fee of "+gnot(fee))
146 }
147 if validFor < 1 || validFor > maxOfferTTL {
148 errs = append(errs, "validFor must be between 1 and 60 minutes")
149 }
150 if opponent != "" && !opponent.IsValid() {
151 errs = append(errs, "invalid opponent address")
152 }
153 if !isHexSHA256(commitment) {
154 errs = append(errs, "commitment must be 64 lowercase hex chars (sha256 of your passphrase)")
155 }
156 return errs
157}
158
159// Accept joins an open offer by sending exactly its stake. The creator then
160// has 90s to Reveal, which picks the first mover.
161func Accept(cur realm, id int) {
162 noSession("Accept")
163 stake := received(cur)
164 caller := cur.Previous().Address()
165 g := mustGet(id)
166 if g.Status != Open {
167 panic("game is not open")
168 }
169 if !time.Now().Before(g.ExpiresAt) {
170 panic("offer expired")
171 }
172 if caller == g.Creator {
173 panic("cannot accept your own offer")
174 }
175 if g.Opponent != "" && caller != g.Opponent {
176 panic("offer is reserved for another player")
177 }
178 if stake != g.Stake {
179 panic("must send exactly " + strconv.FormatInt(g.Stake, 10) + denom)
180 }
181 g.Acceptor = caller
182 g.AcceptHeight = runtime.ChainHeight()
183 g.Status = Playing
184 g.LastMove = time.Now() // Turn stays 0 until Reveal
185}
186
187// Reveal discloses the creator's passphrase, which picks the first mover.
188// It must come within 90s of Accept.
189func Reveal(cur realm, id int, passphrase string) {
190 noCoins()
191 g := mustGet(id)
192 if g.Status != Playing || g.Turn != 0 {
193 panic("game is not awaiting a reveal")
194 }
195 if cur.Previous().Address() != g.Creator {
196 panic("only the creator can reveal")
197 }
198 if time.Now().After(g.deadline()) {
199 panic("reveal clock ran out")
200 }
201 if sha256Hex(passphrase) != g.Commitment {
202 panic("passphrase does not match the commitment")
203 }
204 h := sha256.Sum256([]byte(strings.Join([]string{
205 passphrase, strconv.Itoa(g.ID), g.Creator.String(), g.Acceptor.String(), strconv.FormatInt(g.AcceptHeight, 10),
206 }, "|")))
207 g.Turn = 1 + h[0]&1
208 g.LastMove = time.Now()
209}
210
211// Play drops a piece in column (1-7). move is the game's move count the
212// player saw (Moves before this piece): a resent or delayed move that the game
213// has moved past is refused instead of being played on a later turn.
214func Play(cur realm, id, column, move int) {
215 noCoins()
216 g := mustGet(id)
217 if g.Status != Playing {
218 panic("game is not in progress")
219 }
220 if g.Turn == 0 {
221 panic("waiting for the creator to reveal")
222 }
223 if cur.Previous().Address() != g.player(g.Turn) {
224 panic("not your turn")
225 }
226 if time.Now().After(g.deadline()) {
227 panic("move clock ran out")
228 }
229 if move != g.Moves {
230 panic("stale move: the game is at move " + strconv.Itoa(g.Moves))
231 }
232 if column < 1 || column > cols {
233 panic("column must be between 1 and 7")
234 }
235 r := g.Board.drop(column-1, g.Turn)
236 if r < 0 {
237 panic("column is full")
238 }
239 g.Moves++
240 g.LastCol, g.LastRow = column-1, r
241 switch {
242 case g.Board.wins(column-1, r):
243 settleWin(cur, g, g.player(g.Turn))
244 case g.Moves == cols*rows:
245 settleDraw(cur, g)
246 default:
247 g.Turn = 3 - g.Turn
248 g.LastMove = time.Now()
249 }
250}
251
252// ClaimTimeout settles a game whose move clock ran out. Anyone may call it.
253func ClaimTimeout(cur realm, id int) {
254 noCoins()
255 g := mustGet(id)
256 if g.Status != Playing {
257 panic("game is not in progress")
258 }
259 if !time.Now().After(g.deadline()) {
260 panic("move clock has not run out")
261 }
262 if g.Turn == 0 { // creator never revealed: forfeit
263 settleWin(cur, g, g.Acceptor)
264 return
265 }
266 if g.Moves == 0 {
267 settleVoid(cur, g)
268 return
269 }
270 settleWin(cur, g, g.player(3-g.Turn))
271}
272
273// Resign concedes a game in progress.
274func Resign(cur realm, id int) {
275 noCoins()
276 noSession("Resign")
277 g := mustGet(id)
278 if g.Status != Playing {
279 panic("game is not in progress")
280 }
281 switch cur.Previous().Address() {
282 case g.Creator:
283 settleWin(cur, g, g.Acceptor)
284 case g.Acceptor:
285 settleWin(cur, g, g.Creator)
286 default:
287 panic("not a player in this game")
288 }
289}
290
291// Cancel withdraws an open offer and refunds the creator. The creator may
292// cancel at any time; anyone may once the offer has expired.
293func Cancel(cur realm, id int) {
294 noCoins()
295 g := mustGet(id)
296 if g.Status != Open {
297 panic("game is not open")
298 }
299 if cur.Previous().Address() != g.Creator && time.Now().Before(g.ExpiresAt) {
300 panic("only the creator can cancel an unexpired offer")
301 }
302 finish(g, Cancelled)
303 send(cur, g.Creator, g.Stake)
304}
305
306// SetFee changes the flat house fee, in ugnot, for new offers. Games already
307// offered keep the fee they were created with.
308func SetFee(cur realm, amount int64) {
309 noCoins()
310 noSession("SetFee")
311 owner.AssertOwnedBy(cur.Previous().Address())
312 if amount < 0 {
313 panic("fee must not be negative")
314 }
315 fee = amount
316}
317
318// WithdrawFees sends all collected fees to to.
319func WithdrawFees(cur realm, to address) {
320 noCoins()
321 noSession("WithdrawFees")
322 owner.AssertOwnedBy(cur.Previous().Address())
323 amount := feesCollected
324 feesCollected = 0
325 send(cur, to, amount)
326}
327
328func TransferOwnership(cur realm, newOwner address) {
329 noCoins()
330 noSession("TransferOwnership")
331 if err := owner.TransferOwnership(0, cur, newOwner); err != nil {
332 panic(err)
333 }
334}
335
336// received returns the ugnot sent with a direct user call. OriginSend only
337// describes a real payment to this realm when the caller is an EOA using
338// maketx call; see docs/resources/effective-gno.md.
339func received(cur realm) int64 {
340 if !cur.Previous().IsUserCall() {
341 panic("only user-call (maketx call) accepted")
342 }
343 sent := unsafe.OriginSend()
344 if len(sent) != 1 || sent[0].Denom != denom {
345 panic("send exactly one " + denom + " coin")
346 }
347 return sent[0].Amount
348}
349
350// noCoins guards calls that take no payment: coins sent with them would be
351// stuck in the realm.
352func noCoins() {
353 if len(unsafe.OriginSend()) != 0 {
354 panic("this function does not accept coins")
355 }
356}
357
358// noSession refuses calls signed by an account session key.
359func noSession(action string) {
360 if _, _, _, ok := runtime.GetSessionInfo(); ok || testSession {
361 panic(action + " needs the account's own key, not a session key")
362 }
363}
364
365func sha256Hex(s string) string {
366 h := sha256.Sum256([]byte(s))
367 return hex.EncodeToString(h[:])
368}
369
370func isHexSHA256(s string) bool {
371 if len(s) != 64 {
372 return false
373 }
374 for _, c := range s {
375 if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
376 return false
377 }
378 }
379 return true
380}
381
382func settleWin(cur realm, g *Game, winner address) {
383 loser := g.Creator
384 if winner == g.Creator {
385 loser = g.Acceptor
386 }
387 pot := 2 * g.Stake
388 finish(g, Won)
389 g.Winner = winner
390 feesCollected += g.Fee
391 w := statsOf(winner)
392 w.Games++
393 w.Wins++
394 w.GnotWon += pot - g.Fee
395 topWins = bump(topWins, winner, int64(w.Wins))
396 topGnot = bump(topGnot, winner, w.GnotWon)
397 statsOf(loser).Games++
398 send(cur, winner, pot-g.Fee)
399}
400
401func settleDraw(cur realm, g *Game) {
402 finish(g, Draw)
403 for _, p := range []address{g.Creator, g.Acceptor} {
404 s := statsOf(p)
405 s.Games++
406 s.Draws++
407 send(cur, p, g.Stake)
408 }
409}
410
411func settleVoid(cur realm, g *Game) {
412 finish(g, Void)
413 send(cur, g.Creator, g.Stake)
414 send(cur, g.Acceptor, g.Stake)
415}
416
417// leader is one leaderboard row. The boards are kept at settlement so Render
418// never walks every player's stats.
419type leader struct {
420 Addr address
421 Score int64
422}
423
424var topWins, topGnot []leader // highest first, at most topN
425
426const topN = 10
427
428// bump moves addr to its new score in board. Scores only grow, so a player
429// already listed can only move up; ties keep the earlier holder first.
430func bump(board []leader, addr address, score int64) []leader {
431 for i, l := range board {
432 if l.Addr == addr {
433 board = append(board[:i], board[i+1:]...)
434 break
435 }
436 }
437 i := len(board)
438 for i > 0 && board[i-1].Score < score {
439 i--
440 }
441 if i >= topN {
442 return board
443 }
444 board = append(board, leader{})
445 copy(board[i+1:], board[i:])
446 board[i] = leader{addr, score}
447 if len(board) > topN {
448 board = board[:topN]
449 }
450 return board
451}
452
453func finish(g *Game, s Status) {
454 g.Status = s
455 active.Remove(key(g.ID))
456}
457
458func send(cur realm, to address, amount int64) {
459 if amount == 0 {
460 return
461 }
462 banker.NewBanker(banker.BankerTypeRealmSend, cur).
463 SendCoins(cur.Address(), to, chain.Coins{{denom, amount}})
464}
465
466func (g *Game) player(turn byte) address {
467 if turn == 1 {
468 return g.Creator
469 }
470 return g.Acceptor
471}
472
473func (g *Game) deadline() time.Time { return g.LastMove.Add(moveTimeout) }
474
475func statsOf(addr address) *Stats {
476 if v := stats.Get(addr.String()); v != nil {
477 return v.(*Stats)
478 }
479 s := &Stats{}
480 stats.Set(addr.String(), s)
481 return s
482}
483
484// key zero-pads ids so avl iteration order matches creation order.
485func key(id int) string {
486 s := strconv.Itoa(id)
487 if len(s) < 10 {
488 s = strings.Repeat("0", 10-len(s)) + s
489 }
490 return s
491}
492
493func get(id int) *Game {
494 if v := games.Get(key(id)); v != nil {
495 return v.(*Game)
496 }
497 return nil
498}
499
500func mustGet(id int) *Game {
501 g := get(id)
502 if g == nil {
503 panic("game not found")
504 }
505 return g
506}