Transaction

36BB933D6BA9D0…F86BC908D926

Block 658,483 · index 0 · indexed

Summary

Hash
36BB933D6BA9D0F5E6ABE22D6839E29091F3F6FBF899F2AEEBB2F86BC908D926
Block
658,483
Size
38587 bytes
Gas used
54,489,698 / 100,000,000
Fee
100000ugnot
Status
success

Messages

#1AddPackagegno.land/r/samcrew/connect415 arguments
Attached funds
14000000ugnot

Arguments · 15

  1. #1connect4
  2. #2address.gno
  3. #3package connect4 // The chain decodes four texts to the same address: the bech32 form it gives // a caller, that form in capitals, and the bech32m checksum in either case. // An address argument stored as text and later compared with a caller's // address must be that first form. canonical reads the text itself, which // costs far less gas than decoding and re-encoding it. It is the check of // gno.land/p/samcrew/launchpad/meta/v1 (ValidAddress), copied so this realm // does not depend on that package being published. // bech32Value holds, for each character of the bech32 alphabet // "qpzry9x8gf2tvdw0s3jn54khce6mua7l", its 5-bit value plus one. Zero marks // every other byte, capitals included. var bech32Value = [256]byte{ 'q': 1, 'p': 2, 'z': 3, 'r': 4, 'y': 5, '9': 6, 'x': 7, '8': 8, 'g': 9, 'f': 10, '2': 11, 't': 12, 'v': 13, 'd': 14, 'w': 15, '0': 16, 's': 17, '3': 18, 'j': 19, 'n': 20, '5': 21, '4': 22, 'k': 23, 'h': 24, 'c': 25, 'e': 26, '6': 27, 'm': 28, 'u': 29, 'a': 30, '7': 31, 'l': 32, } // bech32Gen[t] is the XOR of the BIP-173 generators selected by the five // bits of t: one table read per character instead of five tests. var bech32Gen = [32]uint32{ 0x00000000, 0x3b6a57b2, 0x26508e6d, 0x1d3ad9df, 0x1ea119fa, 0x25cb4e48, 0x38f19797, 0x039bc025, 0x3d4233dd, 0x0628646f, 0x1b12bdb0, 0x2078ea02, 0x23e32a27, 0x18897d95, 0x05b3a44a, 0x3ed9f3f8, 0x2a1462b3, 0x117e3501, 0x0c44ecde, 0x372ebb6c, 0x34b57b49, 0x0fdf2cfb, 0x12e5f524, 0x298fa296, 0x1756516e, 0x2c3c06dc, 0x3106df03, 0x0a6c88b1, 0x09f74894, 0x329d1f26, 0x2fa7c6f9, 0x14cd914b, } // The checksum state once the address prefix "g" has been absorbed, which // BIP-173 expands to the values 3, 0, 7. const bech32PrefixState uint32 = 0x8c07 // canonical reports whether a is "g1", then 38 lowercase bech32 characters // whose checksum is the original bech32 one: the form a caller's address has. func canonical(a address) bool { s := string(a) if len(s) != 40 || s[0] != 'g' || s[1] != '1' { return false } chk := bech32PrefixState for i := 2; i < 40; i++ { v := bech32Value[s[i]] if v == 0 { return false } chk = (chk&0x1ffffff)<<5 ^ uint32(v-1) ^ bech32Gen[chk>>25] } return chk == 1 // 1 is bech32; bech32m ends on 0x2bc830a3 }
  4. #4board.gno
  5. #5package connect4 const ( cols = 7 rows = 6 ) // board is indexed [column][row]; row 0 is the bottom. 0 = empty, 1/2 = player. type board [cols][rows]byte // drop places piece p in column c (0-based) and returns its row, or -1 if the column is full. func (b *board) drop(c int, p byte) int { for r := 0; r < rows; r++ { if b[c][r] == 0 { b[c][r] = p return r } } return -1 } // wins reports whether the piece at (c, r) is part of four in a row. func (b *board) wins(c, r int) bool { return b.winLine(c, r) != nil } // winLine returns the cells of the four-or-more line through (c, r), in order // along the line, or nil if the piece there completes none. func (b *board) winLine(c, r int) [][2]int { p := b[c][r] for _, d := range [4][2]int{{1, 0}, {0, 1}, {1, 1}, {1, -1}} { back := b.count(c, r, -d[0], -d[1], p) n := 1 + back + b.count(c, r, d[0], d[1], p) if n >= 4 { line := make([][2]int, n) for i := range line { k := i - back line[i] = [2]int{c + k*d[0], r + k*d[1]} } return line } } return nil } // count returns how many consecutive p pieces follow (c, r) in direction (dc, dr). func (b *board) count(c, r, dc, dr int, p byte) int { n := 0 for { c, r = c+dc, r+dr if c < 0 || c >= cols || r < 0 || r >= rows || b[c][r] != p { return n } n++ } }
  6. #6connect4.gno
  7. #7// Package connect4 is a staked Connect 4 game. A creator posts an offer with // a ugnot stake; anyone (or a named opponent) accepts with the same stake; // the winner takes the pot minus a flat house fee. // // The first mover is picked by a two-sided commit-reveal. The creator offers // with sha256(passphrase), the acceptor accepts with sha256(seed). Then the // creator reveals the passphrase (Reveal) and the acceptor the seed // (RevealSeed), each within 90s, and the first mover is drawn from both. When // a player commits, the other side's secret is still hidden, so neither can // grind for an outcome, and once its secret is revealed nobody can change it; // the only way out is to not reveal, which forfeits. Watching a pending Accept // tells the creator nothing either, so it cannot cancel unfavourable games. // Each address may use a commitment once: a reused secret is already public. // // Calls signed by an account session key (Quick play) may only Play, Reveal, // RevealSeed, ClaimTimeout and Cancel: a stolen session key must not resign // games, move stakes or act as the owner. // // Every move has a 90s clock measured in block time. Nothing on-chain fires // by itself, so an expired clock is settled by anyone calling ClaimTimeout: // whoever did not reveal in time forfeits, a game whose first mover never // moved is void and both stakes are refunded, otherwise the player who ran // out of time forfeits. package connect4 import ( "chain" "chain/banker" "chain/runtime" "chain/runtime/unsafe" "crypto/sha256" "encoding/hex" "strconv" "strings" "time" "gno.land/p/nt/avl/v0" "gno.land/p/nt/ownable/v0" ) const ( moveTimeout = 90 * time.Second maxOfferTTL = 60 // minutes feeCap = minStake / 2 minStake = 1_000_000 // ugnot denom = "ugnot" ) type Status int const ( Open Status = iota Playing Won Draw Void Cancelled ) func (s Status) String() string { return [...]string{"open", "playing", "won", "draw", "void", "cancelled"}[s] } type Game struct { ID int Creator address Opponent address // optional; if set, only this address may accept Acceptor address Stake int64 // ugnot, per player Fee int64 // ugnot house fee, fixed at offer time ExpiresAt time.Time // Commitment is the lowercase hex sha256 of the creator's passphrase; // SeedCommitment that of the acceptor's seed, given with Accept. Commitment string SeedCommitment string // Passphrase is set by Reveal; the draw waits for RevealSeed. Passphrase string Revealed bool Board board Turn byte // 0 = awaiting the reveals, 1 = creator, 2 = acceptor Moves int LastCol int // column and row of the last piece dropped, valid if Moves > 0 LastRow int LastMove time.Time Status Status Winner address } type Stats struct { Games, Wins, Draws int GnotWon int64 // total net ugnot won: the opponent's stake minus the fee } var ( games avl.Tree // key(id) -> *Game active avl.Tree // key(id) -> *Game, only Open and Playing stats avl.Tree // address -> *Stats nextID int fee int64 = 100_000 // ugnot per decisive game feesCollected int64 owner *ownable.Ownable // the deployer; nil (no owner) if deployed some other way pendingOwner address // set by TransferOwnership, made owner by AcceptOwnership used avl.Tree // address + ":" + commitment -> true, both kinds // testSession makes tests see every call as session-signed (no test hook sets one). testSession bool ) // The deployer owns the realm: deploy from the multisig that should own it. func init(cur realm) { if cur.Previous().IsUserCall() { owner = ownable.NewWithAddress(cur.Previous().Address()) } } // Offer posts a game open for validFor minutes. The stake is the ugnot sent // with the call. A non-empty opponent makes the offer private. commitment is // the lowercase hex sha256 of a secret passphrase, revealed after Accept. func Offer(cur realm, opponent address, validFor int, commitment string, maxFee int64) int { noSession("Offer") stake := received(cur) caller := cur.Previous().Address() if opponent == caller { panic("cannot challenge yourself") } if errs := validateOffer(stake, validFor, opponent, commitment); len(errs) > 0 { panic(errs[0]) } // The fee is snapshotted into the game: maxFee is the most the creator // agreed to, so a fee raised while the offer was in flight is refused. if fee > maxFee { panic("the house fee is " + gnot(fee) + ", above your maximum of " + gnot(maxFee)) } useCommitment(caller, commitment) nextID++ g := &Game{ ID: nextID, Creator: caller, Opponent: opponent, Stake: stake, Fee: fee, ExpiresAt: time.Now().Add(time.Duration(validFor) * time.Minute), Commitment: commitment, } games.Set(key(g.ID), g) active.Set(key(g.ID), g) return g.ID } // validateOffer returns every rule an offer breaks. Offer panics with the // first; the "new" page lists them all before anyone signs. func validateOffer(stake int64, validFor int, opponent address, commitment string) []string { var errs []string if stake < minStake { errs = append(errs, "stake below minimum of "+gnot(minStake)) } if stake <= fee { errs = append(errs, "stake must exceed the house fee of "+gnot(fee)) } if validFor < 1 || validFor > maxOfferTTL { errs = append(errs, "validFor must be between 1 and 60 minutes") } // IsValid also accepts other spellings of an address, which would never // match the canonical one Accept compares against. if opponent != "" && !canonical(opponent) { errs = append(errs, "invalid opponent address (use the lower-case g1… form)") } if !isHexSHA256(commitment) { errs = append(errs, "commitment must be 64 lowercase hex chars (sha256 of your passphrase)") } return errs } // Accept joins an open offer by sending exactly its stake. seedCommitment is // the lowercase hex sha256 of a secret seed, revealed after the creator's // passphrase. The creator then has 90s to Reveal. func Accept(cur realm, id int, seedCommitment string) { noSession("Accept") stake := received(cur) caller := cur.Previous().Address() g := mustGet(id) if g.Status != Open { panic("game is not open") } if !time.Now().Before(g.ExpiresAt) { panic("offer expired") } if caller == g.Creator { panic("cannot accept your own offer") } if g.Opponent != "" && caller != g.Opponent { panic("offer is reserved for another player") } if stake != g.Stake { panic("must send exactly " + strconv.FormatInt(g.Stake, 10) + denom) } if !isHexSHA256(seedCommitment) { panic("seedCommitment must be 64 lowercase hex chars (sha256 of your seed)") } useCommitment(caller, seedCommitment) g.Acceptor = caller g.SeedCommitment = seedCommitment g.Status = Playing g.LastMove = time.Now() // Turn stays 0 until Reveal } // Reveal discloses the creator's passphrase. It must come within 90s of // Accept; the acceptor then has 90s to RevealSeed. func Reveal(cur realm, id int, passphrase string) { noCoins() g := mustGet(id) if g.Status != Playing || g.Turn != 0 || g.Revealed { panic("game is not awaiting the creator's reveal") } if cur.Previous().Address() != g.Creator { panic("only the creator can reveal") } if time.Now().After(g.deadline()) { panic("reveal clock ran out") } if len(passphrase) > 128 { panic("passphrase longer than 128 bytes") } if sha256Hex(passphrase) != g.Commitment { panic("passphrase does not match the commitment") } g.Passphrase = passphrase g.Revealed = true g.LastMove = time.Now() } // RevealSeed discloses the acceptor's seed, which with the creator's // passphrase picks the first mover. It must come within 90s of Reveal. func RevealSeed(cur realm, id int, seed string) { noCoins() g := mustGet(id) if g.Status != Playing || g.Turn != 0 || !g.Revealed { panic("game is not awaiting the acceptor's seed") } if cur.Previous().Address() != g.Acceptor { panic("only the acceptor can reveal the seed") } if time.Now().After(g.deadline()) { panic("reveal clock ran out") } if sha256Hex(seed) != g.SeedCommitment { panic("seed does not match the commitment") } g.Turn = firstMover(g.Passphrase, seed, g) g.LastMove = time.Now() } // firstMover draws 1 (creator) or 2 (acceptor) from both secrets. func firstMover(passphrase, seed string, g *Game) byte { h := sha256.Sum256([]byte(strings.Join([]string{ passphrase, seed, strconv.Itoa(g.ID), g.Creator.String(), g.Acceptor.String(), }, "|"))) return 1 + h[0]&1 } // Play drops a piece in column (1-7). move is the game's move count the // player saw (Moves before this piece): a resent or delayed move that the game // has moved past is refused instead of being played on a later turn. func Play(cur realm, id, column, move int) { noCoins() g := mustGet(id) if g.Status != Playing { panic("game is not in progress") } if g.Turn == 0 { panic("waiting for the creator to reveal") } if cur.Previous().Address() != g.player(g.Turn) { panic("not your turn") } if time.Now().After(g.deadline()) { panic("move clock ran out") } if move != g.Moves { panic("stale move: the game is at move " + strconv.Itoa(g.Moves)) } if column < 1 || column > cols { panic("column must be between 1 and 7") } r := g.Board.drop(column-1, g.Turn) if r < 0 { panic("column is full") } g.Moves++ g.LastCol, g.LastRow = column-1, r switch { case g.Board.wins(column-1, r): settleWin(cur, g, g.player(g.Turn)) case g.Moves == cols*rows: settleDraw(cur, g) default: g.Turn = 3 - g.Turn g.LastMove = time.Now() } } // ClaimTimeout settles a game whose move clock ran out. Anyone may call it. func ClaimTimeout(cur realm, id int) { noCoins() g := mustGet(id) if g.Status != Playing { panic("game is not in progress") } if !time.Now().After(g.deadline()) { panic("move clock has not run out") } if g.Turn == 0 { // whoever did not reveal in time forfeits if g.Revealed { settleWin(cur, g, g.Creator) } else { settleWin(cur, g, g.Acceptor) } return } if g.Moves == 0 { settleVoid(cur, g) return } settleWin(cur, g, g.player(3-g.Turn)) } // Resign concedes a game in progress. func Resign(cur realm, id int) { noCoins() noSession("Resign") g := mustGet(id) if g.Status != Playing { panic("game is not in progress") } switch cur.Previous().Address() { case g.Creator: settleWin(cur, g, g.Acceptor) case g.Acceptor: settleWin(cur, g, g.Creator) default: panic("not a player in this game") } } // Cancel withdraws an open offer and refunds the creator. The creator may // cancel at any time; anyone may once the offer has expired. func Cancel(cur realm, id int) { noCoins() g := mustGet(id) if g.Status != Open { panic("game is not open") } if cur.Previous().Address() != g.Creator && time.Now().Before(g.ExpiresAt) { panic("only the creator can cancel an unexpired offer") } finish(g, Cancelled) send(cur, g.Creator, g.Stake) } // SetFee changes the flat house fee, in ugnot, for new offers. Games already // offered keep the fee they were created with. func SetFee(cur realm, amount int64) { noCoins() noSession("SetFee") owner.AssertOwnedBy(cur.Previous().Address()) // Capped so the owner can't raise the fee to take most of a stake by // changing it just before an offer lands. if amount < 0 || amount > feeCap { panic("fee must be between 0 and " + gnot(feeCap)) } fee = amount } // WithdrawFees sends all collected fees to to. func WithdrawFees(cur realm, to address) { noCoins() noSession("WithdrawFees") owner.AssertOwnedBy(cur.Previous().Address()) amount := feesCollected feesCollected = 0 send(cur, to, amount) } // TransferOwnership names the next owner, who becomes owner by calling // AcceptOwnership. Two steps, so a mistyped or non-canonical address can // never strand ownership (and the fees): only an address that signs can // accept, and it is recorded as it signs. func TransferOwnership(cur realm, newOwner address) { noCoins() noSession("TransferOwnership") owner.AssertOwnedBy(cur.Previous().Address()) if !canonical(newOwner) { panic("invalid address (use the lower-case g1… form)") } pendingOwner = newOwner } // AcceptOwnership completes a TransferOwnership; only the named address may call it. func AcceptOwnership(cur realm) { noCoins() noSession("AcceptOwnership") caller := cur.Previous().Address() if pendingOwner == "" || caller != pendingOwner { panic("no ownership transfer pending for this address") } owner = ownable.NewWithAddress(caller) pendingOwner = "" } // useCommitment records a commitment for addr, refusing one it already used: // a revealed secret is public, so reusing it hands the other side the draw. // Keyed by address, so nobody can block another player's commitment. func useCommitment(addr address, commitment string) { k := addr.String() + ":" + commitment if used.Has(k) { panic("commitment already used: generate a fresh secret") } used.Set(k, true) } // received returns the ugnot sent with a direct user call. OriginSend only // describes a real payment to this realm when the caller is an EOA using // maketx call; see docs/resources/effective-gno.md. func received(cur realm) int64 { if !cur.Previous().IsUserCall() { panic("only user-call (maketx call) accepted") } sent := unsafe.OriginSend() if len(sent) != 1 || sent[0].Denom != denom { panic("send exactly one " + denom + " coin") } return sent[0].Amount } // noCoins guards calls that take no payment: coins sent with them would be // stuck in the realm. func noCoins() { if len(unsafe.OriginSend()) != 0 { panic("this function does not accept coins") } } // noSession refuses calls signed by an account session key. func noSession(action string) { if _, _, _, ok := runtime.GetSessionInfo(); ok || testSession { panic(action + " needs the account's own key, not a session key") } } func sha256Hex(s string) string { h := sha256.Sum256([]byte(s)) return hex.EncodeToString(h[:]) } func isHexSHA256(s string) bool { if len(s) != 64 { return false } for _, c := range s { if (c < '0' || c > '9') && (c < 'a' || c > 'f') { return false } } return true } func settleWin(cur realm, g *Game, winner address) { loser := g.Creator if winner == g.Creator { loser = g.Acceptor } pot := 2 * g.Stake finish(g, Won) g.Winner = winner feesCollected += g.Fee w := statsOf(winner) w.Games++ w.Wins++ w.GnotWon += g.Stake - g.Fee topWins = bump(topWins, winner, int64(w.Wins)) topGnot = bump(topGnot, winner, w.GnotWon) statsOf(loser).Games++ send(cur, winner, pot-g.Fee) } func settleDraw(cur realm, g *Game) { finish(g, Draw) for _, p := range []address{g.Creator, g.Acceptor} { s := statsOf(p) s.Games++ s.Draws++ send(cur, p, g.Stake) } } func settleVoid(cur realm, g *Game) { finish(g, Void) send(cur, g.Creator, g.Stake) send(cur, g.Acceptor, g.Stake) } // leader is one leaderboard row. The boards are kept at settlement so Render // never walks every player's stats. type leader struct { Addr address Score int64 } var topWins, topGnot []leader // highest first, at most topN const topN = 10 // bump moves addr to its new score in board. Scores only grow, so a player // already listed can only move up; ties keep the earlier holder first. func bump(board []leader, addr address, score int64) []leader { for i, l := range board { if l.Addr == addr { board = append(board[:i], board[i+1:]...) break } } i := len(board) for i > 0 && board[i-1].Score < score { i-- } if i >= topN { return board } board = append(board, leader{}) copy(board[i+1:], board[i:]) board[i] = leader{addr, score} if len(board) > topN { board = board[:topN] } return board } func finish(g *Game, s Status) { g.Status = s active.Remove(key(g.ID)) } func send(cur realm, to address, amount int64) { if amount == 0 { return } banker.NewBanker(banker.BankerTypeRealmSend, cur). SendCoins(cur.Address(), to, chain.Coins{{denom, amount}}) } func (g *Game) player(turn byte) address { if turn == 1 { return g.Creator } return g.Acceptor } func (g *Game) deadline() time.Time { return g.LastMove.Add(moveTimeout) } func statsOf(addr address) *Stats { if v := stats.Get(addr.String()); v != nil { return v.(*Stats) } s := &Stats{} stats.Set(addr.String(), s) return s } // key zero-pads ids so avl iteration order matches creation order. func key(id int) string { s := strconv.Itoa(id) if len(s) < 10 { s = strings.Repeat("0", 10-len(s)) + s } return s } func get(id int) *Game { if v := games.Get(key(id)); v != nil { return v.(*Game) } return nil } func mustGet(id int) *Game { g := get(id) if g == nil { panic("game not found") } return g }
  8. #8gnomod.toml
  9. #9module = "gno.land/r/samcrew/connect4" gno = "0.9"
  10. #10json.gno
  11. #11package connect4 import ( "strconv" "strings" "time" ) // GameJSON returns {"now":<unix>,"game":<game>|null}. It is a read-only // accessor for clients querying over vm/qeval; now is the block time. func GameJSON(id int) string { var b strings.Builder writeNow(&b) b.WriteString(`,"game":`) if g := get(id); g != nil { writeGame(&b, g) } else { b.WriteString("null") } b.WriteString("}") return b.String() } // ActiveJSON returns {"now":<unix>,"fee":<ugnot>,"games":[...]}: the house fee // for new offers, then Open and Playing games in id order, skipping offset and // returning at most limit (clamped to 1-100). func ActiveJSON(offset, limit int) string { if offset < 0 { offset = 0 } if limit < 1 { limit = 1 } if limit > 100 { limit = 100 } var b strings.Builder writeNow(&b) writeNum(&b, "fee", fee) b.WriteString(`,"games":[`) n := 0 active.IterateByOffset(offset, limit, func(_ string, v any) bool { if n > 0 { b.WriteString(",") } writeGame(&b, v.(*Game)) n++ return false }) b.WriteString("]}") return b.String() } func writeNow(b *strings.Builder) { b.WriteString(`{"now":`) b.WriteString(strconv.FormatInt(time.Now().Unix(), 10)) } // writeGame emits one game. Every stored string is validated on write // (addresses by IsValid, the commitment as hex), so Quote is defence in depth. func writeGame(b *strings.Builder, g *Game) { turnPlayer := "" deadline := int64(0) if g.Status == Playing { deadline = g.deadline().Unix() if g.Turn != 0 { turnPlayer = g.player(g.Turn).String() } } b.WriteString(`{"id":`) b.WriteString(strconv.Itoa(g.ID)) writeStr(b, "creator", g.Creator.String()) writeStr(b, "opponent", g.Opponent.String()) writeStr(b, "acceptor", g.Acceptor.String()) writeNum(b, "stake", g.Stake) writeNum(b, "fee", g.Fee) writeNum(b, "expiresAt", g.ExpiresAt.Unix()) writeStr(b, "commitment", g.Commitment) writeStr(b, "seedCommitment", g.SeedCommitment) // revealed: the creator's passphrase is out; with turn 0 the game waits for RevealSeed. if g.Revealed { b.WriteString(`,"revealed":true`) } else { b.WriteString(`,"revealed":false`) } b.WriteString(`,"board":"`) for c := 0; c < cols; c++ { for r := 0; r < rows; r++ { b.WriteByte('0' + g.Board[c][r]) } } b.WriteString(`"`) writeNum(b, "turn", int64(g.Turn)) writeStr(b, "turnPlayer", turnPlayer) writeNum(b, "moves", int64(g.Moves)) writeNum(b, "lastCol", int64(g.LastCol)) writeNum(b, "lastRow", int64(g.LastRow)) writeNum(b, "deadline", deadline) writeStr(b, "status", g.Status.String()) writeStr(b, "winner", g.Winner.String()) b.WriteString("}") } // writeStr and writeNum append `,"k":v`; the caller writes the first field. func writeStr(b *strings.Builder, k, v string) { b.WriteString(`,"`) b.WriteString(k) b.WriteString(`":`) b.WriteString(strconv.Quote(v)) } func writeNum(b *strings.Builder, k string, v int64) { b.WriteString(`,"`) b.WriteString(k) b.WriteString(`":`) b.WriteString(strconv.FormatInt(v, 10)) } // LeadersJSON returns {"wins":[...],"gnot":[...]}: the top-10 boards (most // games won, most ugnot won) kept at settlement, highest first. Each entry is // {"addr":<address>,"score":<int>}. func LeadersJSON() string { var b strings.Builder b.WriteString(`{"wins":`) writeLeaders(&b, topWins) b.WriteString(`,"gnot":`) writeLeaders(&b, topGnot) b.WriteString("}") return b.String() } func writeLeaders(b *strings.Builder, board []leader) { b.WriteString("[") for i, l := range board { if i > 0 { b.WriteString(",") } b.WriteString(`{"addr":`) b.WriteString(strconv.Quote(l.Addr.String())) writeNum(b, "score", l.Score) b.WriteString("}") } b.WriteString("]") }
  12. #12render.gno
  13. #13package connect4 import ( "chain/runtime" "chain/runtime/unsafe" "net/url" "strconv" "strings" "time" "gno.land/p/moul/txlink/v0" "gno.land/p/nt/svg/v0" ) // lobbyRows caps the games the lobby lists, so Render stays within query gas // however many games are open; clients page through ActiveJSON instead. const lobbyRows = 50 // Board colours. gnoweb only embeds SVG as data-URI images, so each column is // its own image, wrapped in its Play link while the game is being played. const ( red = "#e53935" yellow = "#fdd835" hole = "#ffffff" boardBlue = "#1565c0" lastMoveStroke = "#212121" winStroke = "#00e676" cellPx = 48 headerPx = 20 ) var pieceColor = [...]string{hole, red, yellow} var pieceName = [...]string{"empty", "red", "yellow"} // Render serves "" (lobby and leaderboards), "new" (offer form) and // "game/<id>" (one game with every action its state allows). // Everything written comes from realm state or from query values that have // been validated and re-formatted; raw path input is never echoed. func Render(path string) string { p, query, _ := strings.Cut(path, "?") switch { case p == "": return renderLobby() case p == "new": return renderNew(query) } if s, ok := strings.CutPrefix(p, "game/"); ok { if id, err := strconv.Atoi(s); err == nil { if g := get(id); g != nil { return renderGame(g) } } } return "# Not found\n" } // pageURL links to one of this realm's Render pages. The realm path is read // at runtime, like txlink does, so links work wherever the realm is deployed. func pageURL(page string) string { base := strings.TrimPrefix(unsafe.CurrentRealm().PkgPath(), runtime.ChainDomain()) if page == "" { return base } return base + ":" + page } func gameLink(base string, id int) string { return "[#" + strconv.Itoa(id) + "](" + base + ":game/" + strconv.Itoa(id) + ")" } func renderLobby() string { now := time.Now() base := pageURL("") var open, live strings.Builder // Only rows actually written count toward lobbyRows: expired offers nobody // cancelled must not crowd out live games. shown := 0 active.Iterate("", "", func(_ string, v any) bool { g := v.(*Game) switch { case g.Status == Open && now.Before(g.ExpiresAt): open.WriteString("| " + gameLink(base, g.ID) + " | " + gnot(g.Stake) + " | " + gnot(g.Fee) + " | " + g.Creator.String() + " | " + openTo(g) + " | " + remaining(g.ExpiresAt.Sub(now)) + " | [Accept](" + acceptURL(g) + ") |\n") case g.Status == Playing: live.WriteString("| " + gameLink(base, g.ID) + " | " + gnot(2*g.Stake) + " | " + onTurn(g) + " | " + clockLeft(g, now) + " |\n") default: return false } shown++ return shown == lobbyRows }) var b strings.Builder b.WriteString("# Connect 4\n\n") b.WriteString("Both players stake the same amount; the winner takes the pot minus the house fee shown on each offer " + "(" + gnot(fee) + " for new offers). " + "90s per move: run out and you forfeit. If the first mover never moves, both stakes are refunded.\n\n") b.WriteString("**[Post an offer](" + base + ":new)** · open a game below to play it\n\n") b.WriteString("## Open offers\n\n") if open.Len() == 0 { b.WriteString("No open offers.\n\n") } else { b.WriteString("| Game | Stake | Fee | Creator | For | Expires in | |\n|---|---|---|---|---|---|---|\n" + open.String() + "\n") } b.WriteString("## Live games\n\n") if live.Len() == 0 { b.WriteString("No games in progress.\n\n") } else { b.WriteString("| Game | Pot | Turn | Time left |\n|---|---|---|---|\n" + live.String() + "\n") } if shown == lobbyRows { b.WriteString("Showing the first " + strconv.Itoa(lobbyRows) + " games.\n\n") } b.WriteString("## Most games won\n\n") b.WriteString(leaderboard(topWins, func(v int64) string { return strconv.FormatInt(v, 10) })) b.WriteString("## Most gnot won\n\n") b.WriteString(leaderboard(topGnot, gnot)) return b.String() } // renderNew shows the offer form and, once submitted, a single Offer link // with the stake and every argument prefilled (gnoweb cannot edit .send). func renderNew(query string) string { var b strings.Builder b.WriteString("# Post an offer\n\n[← Lobby](" + pageURL("") + ")\n\n") b.WriteString("Generate a fresh random passphrase for every offer and keep it: " + "once someone accepts you have 90s to reveal it, or you forfeit. " + "A guessable one lets an opponent work out who moves first. " + "Paste only its sha256 below, never the passphrase itself:\n\n" + " pass=$(openssl rand -hex 32); echo \"$pass\"\n" + " printf %s \"$pass\" | shasum -a 256\n\n") b.WriteString(`<gno-form path="new"> <gno-input name="stake" type="text" placeholder="Stake in GNOT, e.g. 10 (min 1)" /> <gno-input name="validFor" type="number" placeholder="Minutes the offer stays open, 1-60 (default 15)" /> <gno-input name="opponent" type="text" placeholder="Opponent address (optional, leave empty for anyone)" /> <gno-input name="commitment" type="text" placeholder="sha256 of your passphrase (64 hex chars)" /> </gno-form> `) if query == "" { return b.String() } q, err := url.ParseQuery(query) if err != nil { return b.String() + "Could not read the form.\n" } // Parse first; the offer rules themselves live in validateOffer so this // page and Offer cannot drift apart. var errs []string stake, ok := parseGNOT(q.Get("stake")) if !ok { errs = append(errs, "stake must be a GNOT amount with at most 6 decimals, e.g. 10 or 2.5") } validFor := 15 if v := q.Get("validFor"); v != "" { n, err := strconv.Atoi(v) if err != nil { errs = append(errs, "validFor must be a whole number of minutes") } validFor = n } opponent := address(q.Get("opponent")) commitment := q.Get("commitment") if len(errs) == 0 { errs = validateOffer(stake, validFor, opponent, commitment) } if len(errs) > 0 { b.WriteString("**Fix the following:**\n\n") for _, e := range errs { b.WriteString("- " + e + "\n") } return b.String() } b.WriteString("## Ready\n\n") who := "anyone" if opponent != "" { who = opponent.String() } b.WriteString("Stake " + gnot(stake) + ", open to " + who + " for " + strconv.Itoa(validFor) + " min, " + "house fee at most " + gnot(fee) + ", commitment `" + commitment + "`. " + "Check it is the sha256 of your own passphrase before signing.\n\n") b.WriteString("**[Post offer for " + gnot(stake) + "](" + txlink.NewLink("Offer"). AddArgs("opponent", opponent.String(), "validFor", strconv.Itoa(validFor), "commitment", commitment, "maxFee", strconv.FormatInt(fee, 10)). SetSend(strconv.FormatInt(stake, 10)+denom).URL() + ")**\n") return b.String() } func renderGame(g *Game) string { now := time.Now() var b strings.Builder id := strconv.Itoa(g.ID) b.WriteString("# Game #" + id + " — " + g.Status.String() + "\n\n[← Lobby](" + pageURL("") + ")\n\n") b.WriteString("🔴 " + g.Creator.String() + " \n🟡 " + orNone(g.Acceptor) + " \n") b.WriteString("Stake: " + gnot(g.Stake) + " each · fee " + gnot(g.Fee) + "\n\n") b.WriteString(boardView(g, now) + "\n\n") switch g.Status { case Open: cancel := "[Cancel offer](" + txlink.NewLink("Cancel").AddArgs("id", id).URL() + ")" if now.Before(g.ExpiresAt) { b.WriteString("Open offer for " + openTo(g) + ", expires in " + remaining(g.ExpiresAt.Sub(now)) + ".\n\n") b.WriteString("**[Accept for " + gnot(g.Stake) + "](" + acceptURL(g) + ")** with the sha256 of a fresh random seed " + "(`openssl rand -hex 32`). Keep the seed: you reveal it after the creator, within 90s, or forfeit.\n\n") b.WriteString(cancel + " (creator)\n") } else { b.WriteString("This offer expired. Anyone can " + cancel + " to refund the creator.\n") } case Playing: left := g.deadline().Sub(now) claim := "Clock ran out: **[Claim timeout](" + txlink.NewLink("ClaimTimeout").AddArgs("id", id).URL() + ")**\n\n" resign := "[Resign](" + txlink.NewLink("Resign").AddArgs("id", id).URL() + ")\n" if g.Turn == 0 { if !g.Revealed { b.WriteString("Waiting for the creator to reveal the passphrase.\n\n") } else { b.WriteString("Passphrase revealed. Waiting for the acceptor to reveal the seed.\n\n") } if left >= 0 { b.WriteString("Time left: " + remaining(left) + "\n\n") if !g.Revealed { b.WriteString("**[Reveal](" + txlink.NewLink("Reveal").AddArgs("id", id, "passphrase", "").URL() + ")** (creator)\n\n") } else { b.WriteString("**[Reveal seed](" + txlink.NewLink("RevealSeed").AddArgs("id", id, "seed", "").URL() + ")** (acceptor)\n\n") } } else { b.WriteString(claim) } b.WriteString(resign) break } b.WriteString("Turn: " + [...]string{"", "🔴", "🟡"}[g.Turn] + " " + g.player(g.Turn).String() + "\n\n") if left >= 0 { b.WriteString("Time left: " + remaining(left) + " · click a column to drop your piece\n\n") } else { b.WriteString(claim) } b.WriteString(resign) case Won: b.WriteString("Winner: " + g.Winner.String() + ", paid " + gnot(2*g.Stake-g.Fee) + ".\n") case Draw: b.WriteString("Draw: both stakes refunded.\n") case Void: b.WriteString("Void: the first mover never moved, both stakes refunded.\n") case Cancelled: b.WriteString("Cancelled: stake refunded to the creator.\n") } return b.String() } // boardView draws the board. While a move can be played it is 7 column // images, each wrapped in its Play link (gnoweb lays linked images out side by // side). Otherwise it is one image of all 7 columns, since gnoweb stacks bare // images vertically. The last move gets a dark ring, a winning line a green one. func boardView(g *Game, now time.Time) string { playable := g.Status == Playing && g.Turn != 0 && !now.After(g.deadline()) var win [cols][rows]bool if g.Status == Won && g.Moves > 0 { for _, cell := range g.Board.winLine(g.LastCol, g.LastRow) { win[cell[0]][cell[1]] = true } } const height = headerPx + rows*cellPx if !playable { canvas := svg.NewCanvas(cols*cellPx, height).WithViewBox(0, 0, cols*cellPx, height) alt := "board:" for c := 0; c < cols; c++ { alt += " " + drawColumn(canvas, g, &win, c, c*cellPx) + ";" } return canvas.Render(alt) } id := strconv.Itoa(g.ID) var b strings.Builder for c := 0; c < cols; c++ { canvas := svg.NewCanvas(cellPx, height).WithViewBox(0, 0, cellPx, height) alt := drawColumn(canvas, g, &win, c, 0) b.WriteString("[" + canvas.Render(alt) + "](" + txlink.NewLink("Play").AddArgs("id", id, "column", strconv.Itoa(c+1), "move", strconv.Itoa(g.Moves)).URL() + ")") } return b.String() } // drawColumn draws column c at horizontal offset x and returns its alt text. func drawColumn(canvas *svg.Canvas, g *Game, win *[cols][rows]bool, c, x int) string { bg := svg.NewRectangle(x, 0, cellPx, headerPx+rows*cellPx, boardBlue) bg.Attr.Class = "col" canvas.Append(bg, svg.NewText(x+cellPx/2-4, 15, strconv.Itoa(c+1), hole)) alt := "column " + strconv.Itoa(c+1) + ":" for r := 0; r < rows; r++ { disc := svg.NewCircle(x+cellPx/2, headerPx+(rows-1-r)*cellPx+cellPx/2, cellPx/2-5, pieceColor[g.Board[c][r]]) switch { case win[c][r]: disc.Attr.Stroke, disc.Attr.StrokeWidth = winStroke, "5" case g.Moves > 0 && c == g.LastCol && r == g.LastRow: disc.Attr.Stroke, disc.Attr.StrokeWidth = lastMoveStroke, "3" } canvas.Append(disc) alt += " " + pieceName[g.Board[c][r]] } return alt } // onTurn names who the game waits for. func onTurn(g *Game) string { switch { case g.Turn != 0: return g.player(g.Turn).String() case g.Revealed: return g.Acceptor.String() + " (reveal seed)" } return g.Creator.String() + " (reveal)" } // clockLeft is the time left on the move clock, or "clock ran out". Play is // still accepted in the deadline block itself, so zero left is not expired. func clockLeft(g *Game, now time.Time) string { if now.After(g.deadline()) { return "clock ran out" } return remaining(g.deadline().Sub(now)) } func acceptURL(g *Game) string { return txlink.NewLink("Accept").AddArgs("id", strconv.Itoa(g.ID), "seedCommitment", ""). SetSend(strconv.FormatInt(g.Stake, 10) + denom).URL() } func digits(s string) bool { for _, c := range s { if c < '0' || c > '9' { return false } } return true } func openTo(g *Game) string { if g.Opponent != "" { return g.Opponent.String() } return "anyone" } // parseGNOT parses a decimal GNOT amount ("10", "2.5") into ugnot. func parseGNOT(s string) (int64, bool) { whole, frac, _ := strings.Cut(s, ".") if whole == "" || len(whole) > 12 || len(frac) > 6 { return 0, false } if !digits(whole) || !digits(frac) { return 0, false } w, _ := strconv.ParseInt(whole, 10, 64) f, _ := strconv.ParseInt(frac+strings.Repeat("0", 6-len(frac)), 10, 64) return w*1_000_000 + f, true } // leaderboard renders a board kept at settlement (topWins, topGnot). func leaderboard(board []leader, format func(int64) string) string { if len(board) == 0 { return "No finished games yet.\n\n" } var b strings.Builder b.WriteString("| # | Player | |\n|---|---|---|\n") for i, r := range board { b.WriteString("| " + strconv.Itoa(i+1) + " | " + r.Addr.String() + " | " + format(r.Score) + " |\n") } b.WriteString("\n") return b.String() } // gnot formats ugnot as GNOT with up to 6 decimals. func gnot(ugnot int64) string { whole := strconv.FormatInt(ugnot/1_000_000, 10) frac := ugnot % 1_000_000 if frac == 0 { return whole + " GNOT" } f := strconv.FormatInt(frac, 10) f = strings.TrimRight(strings.Repeat("0", 6-len(f))+f, "0") return whole + "." + f + " GNOT" } func remaining(d time.Duration) string { return d.Truncate(time.Second).String() } func orNone(a address) string { if a == "" { return "waiting for opponent" } return a.String() }
  14. #14/gno.MemPackageType
  15. #15 MPUserAll

Result log

msg:0,success:true,log:,events:[]

← Back to block 658,483