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

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