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