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.

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}