Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}