Transaction

D71377E324D675…C206AD3C7D26

Block 408,521 · index 0 · indexed

Summary

Hash
D71377E324D675F09164FBFF5C0AB31A351F8319B9E0CE2029A8C206AD3C7D26
Block
408,521
Size
45296 bytes
Gas used
32,629,779 / 140,521,600
Fee
421564ugnot
Status
failed

Messages

#1AddPackagegno.land/r/moul/gno49 arguments
Attached funds
9000000ugnot

Arguments · 9

  1. #1gno4
  2. #2README.md
  3. #3# `gno.land/r/moul/gno4` **Connect Four with the chain as the referee.** Open a table, wait for an opponent, and play. Every move is a transaction; every board is a page. The rules are not here. They live in [`p/moul/gno4`](/p/moul/gno4/v0), which has never heard of a chain. This realm owns the three things that package cannot decide: | | | |---|---| | **who** | a move is refused unless the caller holds the seat whose turn it is | | **when** | an open table cannot be played, a finished one cannot be resumed | | **what everyone sees** | `Render`, which is the product and not a debug view | **A game is stored as its move list and nothing else.** Seven bytes for the game above, 42 for the longest possible one. The board is replayed on read. That keeps storage minimal and bounded, keeps one canonical representation, and means an engine change never needs a state migration, because what is on chain is the input rather than a derived structure some future version might read differently. **Nothing here is a wager.** A table holds no coins, so there is no escrow, no payout, and no abandoned-table timeout to get wrong. Money is a second design and not a flag on this one. **No page hardcodes this realm's path.** The same source is deployed twice, here and at `/preview`, and every link is built from the package path the code is actually running under. A hardcoded link would send every click on the preview back to production and look like it was working. Pages: the lobby at the root, `:g/<id>` for a table, `:u/<address>` for a player, `:about` for what the repository is exploring. All of them are readable with no wallet and no JavaScript. Source, the checklist it is written against, and the web front-end: [github.com/moul/gno4](https://github.com/moul/gno4).
  4. #4gno4.gno
  5. #5// Package gno4 is Connect Four with the chain as the referee. // // The rules live in [p/moul/gno4](/p/moul/gno4/v0), which knows nothing about // the chain. This realm owns the three things a pure engine cannot: who may // move, when, and what the world gets to see. That split is the whole design, // and it is why the engine has 14 tests that need no node. // // Nothing here is a wager. A game holds no coins, so there is no escrow, no // payout and no abandoned-table timeout to get wrong. Money is a second design // and not a flag on this one. package gno4 import ( "chain/runtime" "errors" "gno.land/p/moul/gno4/v0" "gno.land/p/nt/avl/v0" ) // Game is one table. // // The position is NOT stored: Moves is, and the board is replayed from it on // demand. Three reasons, in the order they matter. The move list is the whole // game in at most 42 bytes, so storage is minimal and bounded. It is the // engine's own canonical form, so no second encoder can disagree with it. And // an engine change never needs a state migration, because the only thing on // chain is the input, which no engine version can reinterpret. type Game struct { ID int Red address Yellow address // zero while the table is open Moves string // gno4.Board.Encode() Opened int64 // block height Touched int64 // block height of the last move Quit address // whoever resigned, zero otherwise } // Record is a player's standing. Rating is deliberately absent: an Elo needs a // K factor, a provisional period and an answer for resignations, and every one // of those is a policy argument that would outlive the three lines of code. type Record struct { Wins, Losses, Draws int } var ( games avl.Tree // padded id -> *Game records avl.Tree // address -> *Record nextID = 1 // open and live are id lists kept in most-recent-first order. An avl walk // could derive both, but it would walk every game ever played to render a // page that shows ten, and that cost grows forever. open []int live []int done []int ) const doneKept = 20 // how many finished games the home page remembers var ( ErrNoSuchGame = errors.New("gno4: no such game") ErrNotOpen = errors.New("gno4: that table is not open") ErrOwnGame = errors.New("gno4: you cannot play yourself") ErrNotYours = errors.New("gno4: you are not in this game") ErrNotYourTurn = errors.New("gno4: not your turn") ErrFinished = errors.New("gno4: that game is over") ) // Open starts a table anyone may join. The caller takes red and moves first. func Open(cur realm) int { caller := cur.Previous().Address() g := &Game{ ID: nextID, Red: caller, Opened: runtime.ChainHeight(), } nextID++ games.Set(key(g.ID), g) open = prepend(open, g.ID) return g.ID } // Join takes the yellow seat of an open table. func Join(cur realm, id int) { g := mustGame(id) caller := cur.Previous().Address() if g.Yellow != "" { panic(ErrNotOpen) } if g.Red == caller { panic(ErrOwnGame) } g.Yellow = caller g.Touched = runtime.ChainHeight() open = remove(open, id) live = prepend(live, id) } // Play drops the caller's disc into col, counted from 1. // // Every refusal below is a rule the engine cannot enforce, because the engine // has no idea who is calling. The engine still owns "is that column legal", // and Drop is what answers it. func Play(cur realm, id, col int) { g := mustGame(id) caller := cur.Previous().Address() if g.Over() { panic(ErrFinished) } if g.Yellow == "" { panic(ErrNotOpen) } b := g.Board() var seat address if b.Turn() == gno4.Red { seat = g.Red } else { seat = g.Yellow } if caller != g.Red && caller != g.Yellow { panic(ErrNotYours) } if caller != seat { panic(ErrNotYourTurn) } if _, err := b.Drop(col - 1); err != nil { panic(err) } g.Moves = b.Encode() g.Touched = runtime.ChainHeight() if b.Over() { finish(g, b.Winner()) } } // Resign concedes. A game nobody resigns and nobody finishes stays live // forever; that is a known hole, and a timeout is the fix, not a cron. func Resign(cur realm, id int) { g := mustGame(id) caller := cur.Previous().Address() if g.Over() { panic(ErrFinished) } if caller != g.Red && caller != g.Yellow { panic(ErrNotYours) } g.Quit = caller g.Touched = runtime.ChainHeight() if g.Yellow == "" { // An open table nobody joined: withdraw it, and score nothing. open = remove(open, id) done = trim(prepend(done, id)) return } winner := gno4.Red if caller == g.Red { winner = gno4.Yellow } finish(g, winner) } func finish(g *Game, winner gno4.Disc) { live = remove(live, g.ID) done = trim(prepend(done, g.ID)) switch winner { case gno4.Red: record(g.Red).Wins++ record(g.Yellow).Losses++ case gno4.Yellow: record(g.Yellow).Wins++ record(g.Red).Losses++ default: record(g.Red).Draws++ record(g.Yellow).Draws++ } } // Board replays the move list. It never returns nil and never panics: a stored // encoding that no longer decodes would make the game's page unreadable // forever, so it degrades to an empty board instead. func (g *Game) Board() *gno4.Board { b, err := gno4.Decode(g.Moves) if err != nil { return gno4.New() } return b } // Over reports whether the game is settled, by the rules or by a resignation. func (g *Game) Over() bool { return g.Quit != "" || g.Board().Over() } // Winner returns the winning seat, or the zero address on a draw, an open // table or a game still in play. func (g *Game) Winner() address { if g.Quit != "" { if g.Quit == g.Red { return g.Yellow } return g.Red } switch g.Board().Winner() { case gno4.Red: return g.Red case gno4.Yellow: return g.Yellow } return "" } // GameOf returns a copy of one game's public facts, for another realm or an // off-chain reader that wants state rather than a rendered page. // // It returns values, not the *Game: this realm is private = true, so handing // out a pointer into its own storage is the one thing that cannot be undone // later. func GameOf(id int) (red, yellow address, moves string, over bool) { v := games.Get(key(id)) if v == nil { return "", "", "", false } g := v.(*Game) return g.Red, g.Yellow, g.Moves, g.Over() } // RecordOf returns a player's standing. func RecordOf(addr address) (wins, losses, draws int) { v := records.Get(addr.String()) if v == nil { return 0, 0, 0 } r := v.(*Record) return r.Wins, r.Losses, r.Draws } func mustGame(id int) *Game { v := games.Get(key(id)) if v == nil { panic(ErrNoSuchGame) } return v.(*Game) } func record(addr address) *Record { if v := records.Get(addr.String()); v != nil { return v.(*Record) } r := &Record{} records.Set(addr.String(), r) return r } // key zero-pads an id so avl's byte ordering is numeric ordering. ufmt has no // width verb, which is why this is five lines instead of one. func key(id int) string { s := itoa(id) for len(s) < 10 { s = "0" + s } return s } func itoa(n int) string { if n == 0 { return "0" } neg := n < 0 if neg { n = -n } out := "" for n > 0 { out = string(rune('0'+n%10)) + out n /= 10 } if neg { out = "-" + out } return out } func prepend(list []int, id int) []int { return append([]int{id}, list...) } func remove(list []int, id int) []int { out := make([]int, 0, len(list)) for _, v := range list { if v != id { out = append(out, v) } } return out } func trim(list []int) []int { if len(list) > doneKept { return list[:doneKept] } return list } // Feed returns every game this realm knows, one per line, newest first: // // <id>\t<red>\t<yellow>\t<moves>\t<state> // // where state is open, live, or the address that won, or "draw". // // A realm that only renders markdown forces its own front-end to scrape the // page it renders, and then a wording change breaks the app. So there are two // views of the same state: Render for people, Feed for programs. Neither is // derived from the other, both are derived from the state, and the machine one // is the cheap one to keep stable. func Feed() string { out := "" games.ReverseIterate("", "", func(_ string, v any) bool { out += line(v.(*Game)) + "\n" return false }) return out } // Line returns one game in the same shape as [Feed], or "" if there is no such // game. A front-end polling one table asks for this, not for the whole feed. func Line(id int) string { v := games.Get(key(id)) if v == nil { return "" } return line(v.(*Game)) } func line(g *Game) string { state := "live" switch { case g.Yellow == "" && g.Quit == "": state = "open" case g.Over(): if w := g.Winner(); w != "" { state = w.String() } else if g.Yellow == "" { state = "withdrawn" } else { state = "draw" } } return itoa(g.ID) + "\t" + g.Red.String() + "\t" + g.Yellow.String() + "\t" + g.Moves + "\t" + state }
  6. #6gnomod.toml
  7. #7module = "gno.land/r/moul/gno4" gno = "0.9" private = true
  8. #8render.gno
  9. #9package gno4 import ( "chain/runtime" "chain/runtime/unsafe" "strings" "gno.land/p/moul/gno4/v0" "gno.land/p/moul/md/v0" "gno.land/p/moul/txlink/v0" ) // self is this realm's own gnoweb prefix, read from the package path it is // running under. // // Not a constant, on purpose. The same source is deployed twice: once at // r/moul/gno4 and once at r/moul/gno4/preview, the redeployable twin. A // hardcoded "/r/moul/gno4" would send every link on the preview back to // production, which reads as working and is the most confusing possible bug. // txlink already does exactly this for the transaction links; this is the same // rule applied to the page links. func self() string { return strings.TrimPrefix(unsafe.CurrentRealm().PkgPath(), runtime.ChainDomain()) } // Render is the product, not a debug view. // // Everything this realm knows is reachable from gnoweb with no wallet, no // JavaScript and no front-end: the board is an inline SVG, every move is an // ordinary link, and a game is a URL you can paste to somebody. The web app in // web/ is a nicer way to do the same thing, never the only way. // // It is also deterministic. Nothing here reads the current height or the // current time, only heights already recorded in state, which is what lets // render_example_test.gno pin the output and turn a rendering regression into // a red build instead of a permanently broken page. func Render(path string) string { switch { case path == "": return renderHome() case path == "about": return renderAbout() case strings.HasPrefix(path, "g/"): return renderGame(strings.TrimPrefix(path, "g/")) case strings.HasPrefix(path, "u/"): return renderPlayer(strings.TrimPrefix(path, "u/")) } return "# Not found\n\nNo page at `" + md.EscapeText(path) + "`. [Back to the lobby](" + self() + ").\n" } func renderHome() string { var b strings.Builder b.WriteString("# Connect Four\n\n") b.WriteString("**The chain is the referee.** The rules live in a pure package that has never heard of a chain; ") b.WriteString("this realm owns the only three things that package cannot decide: who may move, when, and what everyone else gets to see.\n\n") b.WriteString(md.Link("Open a table", txlink.Call("Open")) + " and wait for someone, or join one below.\n\n") b.WriteString(section("Open tables", renderOpen())) b.WriteString(section("In play", renderLive())) b.WriteString(section("Finished", renderDone())) b.WriteString(section("Standings", renderStandings())) b.WriteString(md.HorizontalRule()) b.WriteString(md.CollapsibleSection("How to play from a terminal", howTo())) b.WriteString("\n" + md.Link("What this realm is exploring", self()+":about") + "\n") return b.String() } func section(title, body string) string { if body == "" { return "" } return md.H2(title) + body + "\n" } func renderOpen() string { if len(open) == 0 { return "" } rows := "| table | opened by | at height | |\n|---|---|---|---|\n" for _, id := range open { g := mustGame(id) rows += "| " + gameLink(id) + " | " + player(g.Red) + " | " + itoa64(g.Opened) + " | " + md.Link("join", txlink.Call("Join", "id", itoa(id))) + " |\n" } return rows } func renderLive() string { if len(live) == 0 { return "" } rows := "| table | red | yellow | to move | moves |\n|---|---|---|---|---|\n" for _, id := range live { g := mustGame(id) board := g.Board() turn := g.Red if board.Turn() == gno4.Yellow { turn = g.Yellow } rows += "| " + gameLink(id) + " | " + player(g.Red) + " | " + player(g.Yellow) + " | " + disc(board.Turn()) + " " + player(turn) + " | " + itoa(board.MoveCount()) + " |\n" } return rows } func renderDone() string { if len(done) == 0 { return "" } rows := "| table | red | yellow | result |\n|---|---|---|---|\n" for _, id := range done { g := mustGame(id) rows += "| " + gameLink(id) + " | " + player(g.Red) + " | " + player(g.Yellow) + " | " + result(g) + " |\n" } return rows } func renderStandings() string { if records.Size() == 0 { return "" } rows := "| player | won | lost | drawn |\n|---|---|---|---|\n" records.Iterate("", "", func(k string, v any) bool { r := v.(*Record) rows += "| " + player(address(k)) + " | " + itoa(r.Wins) + " | " + itoa(r.Losses) + " | " + itoa(r.Draws) + " |\n" return false }) return rows } func renderGame(raw string) string { id, ok := atoi(raw) if !ok || !games.Has(key(id)) { return "# Not found\n\nNo table numbered `" + md.EscapeText(raw) + "`. [Back to the lobby](" + self() + ").\n" } g := mustGame(id) board := g.Board() var b strings.Builder b.WriteString("# Table " + itoa(id) + "\n\n") b.WriteString(board.Image("Connect Four, table "+itoa(id)) + "\n\n") b.WriteString(status(g, board) + "\n\n") if !g.Over() && g.Yellow != "" { b.WriteString(moveRow(id, board) + "\n\n") b.WriteString(md.Italic("Anyone may click, but the chain only accepts the player whose turn it is.") + "\n\n") } if g.Yellow == "" && g.Quit == "" { b.WriteString(md.Link("Take the yellow seat", txlink.Call("Join", "id", itoa(id))) + "\n\n") } b.WriteString(md.H2("Seats")) b.WriteString("| | player | record |\n|---|---|---|\n") b.WriteString("| 🔴 red | " + player(g.Red) + " | " + recordLine(g.Red) + " |\n") if g.Yellow == "" { b.WriteString("| 🟡 yellow | _open_ | |\n") } else { b.WriteString("| 🟡 yellow | " + player(g.Yellow) + " | " + recordLine(g.Yellow) + " |\n") } b.WriteString("\n") b.WriteString(md.H2("The game, in full")) b.WriteString("The whole match is its move list, one digit per move. ") b.WriteString("Replaying it reproduces this position exactly, which is why that string is the only thing stored on chain.\n\n") b.WriteString(md.CodeBlock(orDash(g.Moves))) if !g.Over() && board.MoveCount() > 0 { b.WriteString(md.CollapsibleSection("Spoiler: is anyone one move from winning?", threats(board))) } b.WriteString("\n" + md.Link("Back to the lobby", self()) + "\n") return b.String() } func renderPlayer(raw string) string { addr := address(raw) if !addr.IsValid() { return "# Not a player\n\n`" + md.EscapeText(raw) + "` is not an address. [Back to the lobby](" + self() + ").\n" } var b strings.Builder b.WriteString("# " + short(addr) + "\n\n") b.WriteString(md.Link(addr.String(), "/u/"+addr.String()) + "\n\n") b.WriteString("Record: " + recordLine(addr) + "\n\n") rows := "" games.ReverseIterate("", "", func(k string, v any) bool { g := v.(*Game) if g.Red != addr && g.Yellow != addr { return false } rows += "| " + gameLink(g.ID) + " | " + player(other(g, addr)) + " | " + result(g) + " |\n" return false }) if rows != "" { b.WriteString(md.H2("Games")) b.WriteString("| table | against | result |\n|---|---|---|\n" + rows + "\n") } b.WriteString(md.Link("Back to the lobby", self()) + "\n") return b.String() } func renderAbout() string { return `# What this realm is exploring This is one of four small games built to answer a single question: **what does a web2.5 application on gno.land actually need to get right?** Not the game, the scaffolding around it. The engine is a pure package with no chain import, so its rules are testable with no node. This realm owns authority and rendering, and nothing else. The board you see is an SVG generated at render time and inlined as a data URI, so it costs no storage and there is no asset to go missing. Every page here is reachable with no wallet and no JavaScript. There are two deployments of this same source: this one, and a ` + "`/preview`" + ` twin flagged ` + "`private = true`" + ` so it stays redeployable while the design is still moving. A public path can never be redeployed, which makes the flag the most consequential line in the whole repository. The repository, the checklist it is written against, and the other three games: [github.com/moul/gno4](https://github.com/moul/gno4). ` + md.Link("Back to the lobby", self()) + "\n" } // --- small helpers ------------------------------------------------------- func status(g *Game, board *gno4.Board) string { switch { case g.Quit != "": return md.Bold(short(g.Quit)+" resigned.") + " " + player(g.Winner()) + " takes it." case board.Winner() != gno4.Empty: return md.Bold(disc(board.Winner())+" "+short(g.Winner())+" wins.") + " In " + itoa(board.MoveCount()) + " moves." case board.Draw(): return md.Bold("A draw.") + " Forty-two discs, no line of four." case g.Yellow == "": return md.Bold("Waiting for an opponent.") default: turn := g.Red if board.Turn() == gno4.Yellow { turn = g.Yellow } return md.Bold(disc(board.Turn())+" "+short(turn)+" to move.") + " Move " + itoa(board.MoveCount()+1) + "." } } // moveRow is the move menu: one link per playable column, in column order, so // its position under the board matches the board itself. func moveRow(id int, board *gno4.Board) string { cells := make([]string, 0, gno4.Cols) for col := 0; col < gno4.Cols; col++ { label := itoa(col + 1) if !board.CanDrop(col) { cells = append(cells, "·") continue } cells = append(cells, md.Link("⬇ "+label, txlink.Call("Play", "id", itoa(id), "col", label))) } return strings.Join(cells, " · ") } func threats(board *gno4.Board) string { out := "" for _, d := range []gno4.Disc{gno4.Red, gno4.Yellow} { if col := board.WinningMove(d); col >= 0 { out += "- " + disc(d) + " wins immediately by playing column " + itoa(col+1) + ".\n" } } if out == "" { return "Neither side can win on this move.\n" } return out } func gameLink(id int) string { return md.Link(itoa(id), self()+":g/"+itoa(id)) } func player(addr address) string { if addr == "" { return "_nobody_" } return md.Link(short(addr), self()+":u/"+addr.String()) } func short(addr address) string { s := addr.String() if len(s) <= 12 { return s } return s[:8] + "…" + s[len(s)-4:] } func disc(d gno4.Disc) string { switch d { case gno4.Red: return "🔴" case gno4.Yellow: return "🟡" } return "⚫" } func result(g *Game) string { if !g.Over() { return "in play" } if w := g.Winner(); w != "" { if g.Quit != "" { return player(w) + " (resignation)" } return player(w) } if g.Yellow == "" { return "withdrawn" } return "draw" } func recordLine(addr address) string { w, l, d := RecordOf(addr) return itoa(w) + "W / " + itoa(l) + "L / " + itoa(d) + "D" } func other(g *Game, addr address) address { if g.Red == addr { return g.Yellow } return g.Red } func orDash(s string) string { if s == "" { return "(no moves yet)" } return s } // howTo prints the two commands that cover the whole game, against whichever // path this realm is actually deployed at. func howTo() string { pkg := runtime.ChainDomain() + strings.TrimPrefix(self(), "/") tail := " \\\n -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast" + " -chainid " + runtime.ChainID() + " \\\n -remote https://rpc.gno.land:443 YOURKEY\n" return "```sh\n" + "# open a table; the transaction's output carries the id\n" + "gnokey maketx call -pkgpath " + pkg + " -func Open" + tail + "\n" + "# drop a disc into column 4 of table 7\n" + "gnokey maketx call -pkgpath " + pkg + " -func Play -args 7 -args 4" + tail + "```\n" + "\nThose gas numbers are a starting point, not a measurement. Simulate first: " + "drop `-broadcast` and the node answers with what the call actually costs.\n" } func itoa64(n int64) string { return itoa(int(n)) } func atoi(s string) (int, bool) { if s == "" || len(s) > 9 { return 0, false } n := 0 for i := 0; i < len(s); i++ { c := s[i] if c < '0' || c > '9' { return 0, false } n = n*10 + int(c-'0') } return n, true }
#2AddPackagegno.land/r/moul/gno4/preview9 arguments
Attached funds
9000000ugnot

Arguments · 9

  1. #1gno4
  2. #2README.md
  3. #3# `gno.land/r/moul/gno4` **Connect Four with the chain as the referee.** Open a table, wait for an opponent, and play. Every move is a transaction; every board is a page. The rules are not here. They live in [`p/moul/gno4`](/p/moul/gno4/v0), which has never heard of a chain. This realm owns the three things that package cannot decide: | | | |---|---| | **who** | a move is refused unless the caller holds the seat whose turn it is | | **when** | an open table cannot be played, a finished one cannot be resumed | | **what everyone sees** | `Render`, which is the product and not a debug view | **A game is stored as its move list and nothing else.** Seven bytes for the game above, 42 for the longest possible one. The board is replayed on read. That keeps storage minimal and bounded, keeps one canonical representation, and means an engine change never needs a state migration, because what is on chain is the input rather than a derived structure some future version might read differently. **Nothing here is a wager.** A table holds no coins, so there is no escrow, no payout, and no abandoned-table timeout to get wrong. Money is a second design and not a flag on this one. **No page hardcodes this realm's path.** The same source is deployed twice, here and at `/preview`, and every link is built from the package path the code is actually running under. A hardcoded link would send every click on the preview back to production and look like it was working. Pages: the lobby at the root, `:g/<id>` for a table, `:u/<address>` for a player, `:about` for what the repository is exploring. All of them are readable with no wallet and no JavaScript. Source, the checklist it is written against, and the web front-end: [github.com/moul/gno4](https://github.com/moul/gno4).
  4. #4gno4.gno
  5. #5// Package gno4 is Connect Four with the chain as the referee. // // The rules live in [p/moul/gno4](/p/moul/gno4/v0), which knows nothing about // the chain. This realm owns the three things a pure engine cannot: who may // move, when, and what the world gets to see. That split is the whole design, // and it is why the engine has 14 tests that need no node. // // Nothing here is a wager. A game holds no coins, so there is no escrow, no // payout and no abandoned-table timeout to get wrong. Money is a second design // and not a flag on this one. package gno4 import ( "chain/runtime" "errors" "gno.land/p/moul/gno4/v0" "gno.land/p/nt/avl/v0" ) // Game is one table. // // The position is NOT stored: Moves is, and the board is replayed from it on // demand. Three reasons, in the order they matter. The move list is the whole // game in at most 42 bytes, so storage is minimal and bounded. It is the // engine's own canonical form, so no second encoder can disagree with it. And // an engine change never needs a state migration, because the only thing on // chain is the input, which no engine version can reinterpret. type Game struct { ID int Red address Yellow address // zero while the table is open Moves string // gno4.Board.Encode() Opened int64 // block height Touched int64 // block height of the last move Quit address // whoever resigned, zero otherwise } // Record is a player's standing. Rating is deliberately absent: an Elo needs a // K factor, a provisional period and an answer for resignations, and every one // of those is a policy argument that would outlive the three lines of code. type Record struct { Wins, Losses, Draws int } var ( games avl.Tree // padded id -> *Game records avl.Tree // address -> *Record nextID = 1 // open and live are id lists kept in most-recent-first order. An avl walk // could derive both, but it would walk every game ever played to render a // page that shows ten, and that cost grows forever. open []int live []int done []int ) const doneKept = 20 // how many finished games the home page remembers var ( ErrNoSuchGame = errors.New("gno4: no such game") ErrNotOpen = errors.New("gno4: that table is not open") ErrOwnGame = errors.New("gno4: you cannot play yourself") ErrNotYours = errors.New("gno4: you are not in this game") ErrNotYourTurn = errors.New("gno4: not your turn") ErrFinished = errors.New("gno4: that game is over") ) // Open starts a table anyone may join. The caller takes red and moves first. func Open(cur realm) int { caller := cur.Previous().Address() g := &Game{ ID: nextID, Red: caller, Opened: runtime.ChainHeight(), } nextID++ games.Set(key(g.ID), g) open = prepend(open, g.ID) return g.ID } // Join takes the yellow seat of an open table. func Join(cur realm, id int) { g := mustGame(id) caller := cur.Previous().Address() if g.Yellow != "" { panic(ErrNotOpen) } if g.Red == caller { panic(ErrOwnGame) } g.Yellow = caller g.Touched = runtime.ChainHeight() open = remove(open, id) live = prepend(live, id) } // Play drops the caller's disc into col, counted from 1. // // Every refusal below is a rule the engine cannot enforce, because the engine // has no idea who is calling. The engine still owns "is that column legal", // and Drop is what answers it. func Play(cur realm, id, col int) { g := mustGame(id) caller := cur.Previous().Address() if g.Over() { panic(ErrFinished) } if g.Yellow == "" { panic(ErrNotOpen) } b := g.Board() var seat address if b.Turn() == gno4.Red { seat = g.Red } else { seat = g.Yellow } if caller != g.Red && caller != g.Yellow { panic(ErrNotYours) } if caller != seat { panic(ErrNotYourTurn) } if _, err := b.Drop(col - 1); err != nil { panic(err) } g.Moves = b.Encode() g.Touched = runtime.ChainHeight() if b.Over() { finish(g, b.Winner()) } } // Resign concedes. A game nobody resigns and nobody finishes stays live // forever; that is a known hole, and a timeout is the fix, not a cron. func Resign(cur realm, id int) { g := mustGame(id) caller := cur.Previous().Address() if g.Over() { panic(ErrFinished) } if caller != g.Red && caller != g.Yellow { panic(ErrNotYours) } g.Quit = caller g.Touched = runtime.ChainHeight() if g.Yellow == "" { // An open table nobody joined: withdraw it, and score nothing. open = remove(open, id) done = trim(prepend(done, id)) return } winner := gno4.Red if caller == g.Red { winner = gno4.Yellow } finish(g, winner) } func finish(g *Game, winner gno4.Disc) { live = remove(live, g.ID) done = trim(prepend(done, g.ID)) switch winner { case gno4.Red: record(g.Red).Wins++ record(g.Yellow).Losses++ case gno4.Yellow: record(g.Yellow).Wins++ record(g.Red).Losses++ default: record(g.Red).Draws++ record(g.Yellow).Draws++ } } // Board replays the move list. It never returns nil and never panics: a stored // encoding that no longer decodes would make the game's page unreadable // forever, so it degrades to an empty board instead. func (g *Game) Board() *gno4.Board { b, err := gno4.Decode(g.Moves) if err != nil { return gno4.New() } return b } // Over reports whether the game is settled, by the rules or by a resignation. func (g *Game) Over() bool { return g.Quit != "" || g.Board().Over() } // Winner returns the winning seat, or the zero address on a draw, an open // table or a game still in play. func (g *Game) Winner() address { if g.Quit != "" { if g.Quit == g.Red { return g.Yellow } return g.Red } switch g.Board().Winner() { case gno4.Red: return g.Red case gno4.Yellow: return g.Yellow } return "" } // GameOf returns a copy of one game's public facts, for another realm or an // off-chain reader that wants state rather than a rendered page. // // It returns values, not the *Game: this realm is private = true, so handing // out a pointer into its own storage is the one thing that cannot be undone // later. func GameOf(id int) (red, yellow address, moves string, over bool) { v := games.Get(key(id)) if v == nil { return "", "", "", false } g := v.(*Game) return g.Red, g.Yellow, g.Moves, g.Over() } // RecordOf returns a player's standing. func RecordOf(addr address) (wins, losses, draws int) { v := records.Get(addr.String()) if v == nil { return 0, 0, 0 } r := v.(*Record) return r.Wins, r.Losses, r.Draws } func mustGame(id int) *Game { v := games.Get(key(id)) if v == nil { panic(ErrNoSuchGame) } return v.(*Game) } func record(addr address) *Record { if v := records.Get(addr.String()); v != nil { return v.(*Record) } r := &Record{} records.Set(addr.String(), r) return r } // key zero-pads an id so avl's byte ordering is numeric ordering. ufmt has no // width verb, which is why this is five lines instead of one. func key(id int) string { s := itoa(id) for len(s) < 10 { s = "0" + s } return s } func itoa(n int) string { if n == 0 { return "0" } neg := n < 0 if neg { n = -n } out := "" for n > 0 { out = string(rune('0'+n%10)) + out n /= 10 } if neg { out = "-" + out } return out } func prepend(list []int, id int) []int { return append([]int{id}, list...) } func remove(list []int, id int) []int { out := make([]int, 0, len(list)) for _, v := range list { if v != id { out = append(out, v) } } return out } func trim(list []int) []int { if len(list) > doneKept { return list[:doneKept] } return list } // Feed returns every game this realm knows, one per line, newest first: // // <id>\t<red>\t<yellow>\t<moves>\t<state> // // where state is open, live, or the address that won, or "draw". // // A realm that only renders markdown forces its own front-end to scrape the // page it renders, and then a wording change breaks the app. So there are two // views of the same state: Render for people, Feed for programs. Neither is // derived from the other, both are derived from the state, and the machine one // is the cheap one to keep stable. func Feed() string { out := "" games.ReverseIterate("", "", func(_ string, v any) bool { out += line(v.(*Game)) + "\n" return false }) return out } // Line returns one game in the same shape as [Feed], or "" if there is no such // game. A front-end polling one table asks for this, not for the whole feed. func Line(id int) string { v := games.Get(key(id)) if v == nil { return "" } return line(v.(*Game)) } func line(g *Game) string { state := "live" switch { case g.Yellow == "" && g.Quit == "": state = "open" case g.Over(): if w := g.Winner(); w != "" { state = w.String() } else if g.Yellow == "" { state = "withdrawn" } else { state = "draw" } } return itoa(g.ID) + "\t" + g.Red.String() + "\t" + g.Yellow.String() + "\t" + g.Moves + "\t" + state }
  6. #6gnomod.toml
  7. #7module = "gno.land/r/moul/gno4/preview" gno = "0.9" private = true
  8. #8render.gno
  9. #9package gno4 import ( "chain/runtime" "chain/runtime/unsafe" "strings" "gno.land/p/moul/gno4/v0" "gno.land/p/moul/md/v0" "gno.land/p/moul/txlink/v0" ) // self is this realm's own gnoweb prefix, read from the package path it is // running under. // // Not a constant, on purpose. The same source is deployed twice: once at // r/moul/gno4 and once at r/moul/gno4/preview, the redeployable twin. A // hardcoded "/r/moul/gno4" would send every link on the preview back to // production, which reads as working and is the most confusing possible bug. // txlink already does exactly this for the transaction links; this is the same // rule applied to the page links. func self() string { return strings.TrimPrefix(unsafe.CurrentRealm().PkgPath(), runtime.ChainDomain()) } // Render is the product, not a debug view. // // Everything this realm knows is reachable from gnoweb with no wallet, no // JavaScript and no front-end: the board is an inline SVG, every move is an // ordinary link, and a game is a URL you can paste to somebody. The web app in // web/ is a nicer way to do the same thing, never the only way. // // It is also deterministic. Nothing here reads the current height or the // current time, only heights already recorded in state, which is what lets // render_example_test.gno pin the output and turn a rendering regression into // a red build instead of a permanently broken page. func Render(path string) string { switch { case path == "": return renderHome() case path == "about": return renderAbout() case strings.HasPrefix(path, "g/"): return renderGame(strings.TrimPrefix(path, "g/")) case strings.HasPrefix(path, "u/"): return renderPlayer(strings.TrimPrefix(path, "u/")) } return "# Not found\n\nNo page at `" + md.EscapeText(path) + "`. [Back to the lobby](" + self() + ").\n" } func renderHome() string { var b strings.Builder b.WriteString("# Connect Four\n\n") b.WriteString("**The chain is the referee.** The rules live in a pure package that has never heard of a chain; ") b.WriteString("this realm owns the only three things that package cannot decide: who may move, when, and what everyone else gets to see.\n\n") b.WriteString(md.Link("Open a table", txlink.Call("Open")) + " and wait for someone, or join one below.\n\n") b.WriteString(section("Open tables", renderOpen())) b.WriteString(section("In play", renderLive())) b.WriteString(section("Finished", renderDone())) b.WriteString(section("Standings", renderStandings())) b.WriteString(md.HorizontalRule()) b.WriteString(md.CollapsibleSection("How to play from a terminal", howTo())) b.WriteString("\n" + md.Link("What this realm is exploring", self()+":about") + "\n") return b.String() } func section(title, body string) string { if body == "" { return "" } return md.H2(title) + body + "\n" } func renderOpen() string { if len(open) == 0 { return "" } rows := "| table | opened by | at height | |\n|---|---|---|---|\n" for _, id := range open { g := mustGame(id) rows += "| " + gameLink(id) + " | " + player(g.Red) + " | " + itoa64(g.Opened) + " | " + md.Link("join", txlink.Call("Join", "id", itoa(id))) + " |\n" } return rows } func renderLive() string { if len(live) == 0 { return "" } rows := "| table | red | yellow | to move | moves |\n|---|---|---|---|---|\n" for _, id := range live { g := mustGame(id) board := g.Board() turn := g.Red if board.Turn() == gno4.Yellow { turn = g.Yellow } rows += "| " + gameLink(id) + " | " + player(g.Red) + " | " + player(g.Yellow) + " | " + disc(board.Turn()) + " " + player(turn) + " | " + itoa(board.MoveCount()) + " |\n" } return rows } func renderDone() string { if len(done) == 0 { return "" } rows := "| table | red | yellow | result |\n|---|---|---|---|\n" for _, id := range done { g := mustGame(id) rows += "| " + gameLink(id) + " | " + player(g.Red) + " | " + player(g.Yellow) + " | " + result(g) + " |\n" } return rows } func renderStandings() string { if records.Size() == 0 { return "" } rows := "| player | won | lost | drawn |\n|---|---|---|---|\n" records.Iterate("", "", func(k string, v any) bool { r := v.(*Record) rows += "| " + player(address(k)) + " | " + itoa(r.Wins) + " | " + itoa(r.Losses) + " | " + itoa(r.Draws) + " |\n" return false }) return rows } func renderGame(raw string) string { id, ok := atoi(raw) if !ok || !games.Has(key(id)) { return "# Not found\n\nNo table numbered `" + md.EscapeText(raw) + "`. [Back to the lobby](" + self() + ").\n" } g := mustGame(id) board := g.Board() var b strings.Builder b.WriteString("# Table " + itoa(id) + "\n\n") b.WriteString(board.Image("Connect Four, table "+itoa(id)) + "\n\n") b.WriteString(status(g, board) + "\n\n") if !g.Over() && g.Yellow != "" { b.WriteString(moveRow(id, board) + "\n\n") b.WriteString(md.Italic("Anyone may click, but the chain only accepts the player whose turn it is.") + "\n\n") } if g.Yellow == "" && g.Quit == "" { b.WriteString(md.Link("Take the yellow seat", txlink.Call("Join", "id", itoa(id))) + "\n\n") } b.WriteString(md.H2("Seats")) b.WriteString("| | player | record |\n|---|---|---|\n") b.WriteString("| 🔴 red | " + player(g.Red) + " | " + recordLine(g.Red) + " |\n") if g.Yellow == "" { b.WriteString("| 🟡 yellow | _open_ | |\n") } else { b.WriteString("| 🟡 yellow | " + player(g.Yellow) + " | " + recordLine(g.Yellow) + " |\n") } b.WriteString("\n") b.WriteString(md.H2("The game, in full")) b.WriteString("The whole match is its move list, one digit per move. ") b.WriteString("Replaying it reproduces this position exactly, which is why that string is the only thing stored on chain.\n\n") b.WriteString(md.CodeBlock(orDash(g.Moves))) if !g.Over() && board.MoveCount() > 0 { b.WriteString(md.CollapsibleSection("Spoiler: is anyone one move from winning?", threats(board))) } b.WriteString("\n" + md.Link("Back to the lobby", self()) + "\n") return b.String() } func renderPlayer(raw string) string { addr := address(raw) if !addr.IsValid() { return "# Not a player\n\n`" + md.EscapeText(raw) + "` is not an address. [Back to the lobby](" + self() + ").\n" } var b strings.Builder b.WriteString("# " + short(addr) + "\n\n") b.WriteString(md.Link(addr.String(), "/u/"+addr.String()) + "\n\n") b.WriteString("Record: " + recordLine(addr) + "\n\n") rows := "" games.ReverseIterate("", "", func(k string, v any) bool { g := v.(*Game) if g.Red != addr && g.Yellow != addr { return false } rows += "| " + gameLink(g.ID) + " | " + player(other(g, addr)) + " | " + result(g) + " |\n" return false }) if rows != "" { b.WriteString(md.H2("Games")) b.WriteString("| table | against | result |\n|---|---|---|\n" + rows + "\n") } b.WriteString(md.Link("Back to the lobby", self()) + "\n") return b.String() } func renderAbout() string { return `# What this realm is exploring This is one of four small games built to answer a single question: **what does a web2.5 application on gno.land actually need to get right?** Not the game, the scaffolding around it. The engine is a pure package with no chain import, so its rules are testable with no node. This realm owns authority and rendering, and nothing else. The board you see is an SVG generated at render time and inlined as a data URI, so it costs no storage and there is no asset to go missing. Every page here is reachable with no wallet and no JavaScript. There are two deployments of this same source: this one, and a ` + "`/preview`" + ` twin flagged ` + "`private = true`" + ` so it stays redeployable while the design is still moving. A public path can never be redeployed, which makes the flag the most consequential line in the whole repository. The repository, the checklist it is written against, and the other three games: [github.com/moul/gno4](https://github.com/moul/gno4). ` + md.Link("Back to the lobby", self()) + "\n" } // --- small helpers ------------------------------------------------------- func status(g *Game, board *gno4.Board) string { switch { case g.Quit != "": return md.Bold(short(g.Quit)+" resigned.") + " " + player(g.Winner()) + " takes it." case board.Winner() != gno4.Empty: return md.Bold(disc(board.Winner())+" "+short(g.Winner())+" wins.") + " In " + itoa(board.MoveCount()) + " moves." case board.Draw(): return md.Bold("A draw.") + " Forty-two discs, no line of four." case g.Yellow == "": return md.Bold("Waiting for an opponent.") default: turn := g.Red if board.Turn() == gno4.Yellow { turn = g.Yellow } return md.Bold(disc(board.Turn())+" "+short(turn)+" to move.") + " Move " + itoa(board.MoveCount()+1) + "." } } // moveRow is the move menu: one link per playable column, in column order, so // its position under the board matches the board itself. func moveRow(id int, board *gno4.Board) string { cells := make([]string, 0, gno4.Cols) for col := 0; col < gno4.Cols; col++ { label := itoa(col + 1) if !board.CanDrop(col) { cells = append(cells, "·") continue } cells = append(cells, md.Link("⬇ "+label, txlink.Call("Play", "id", itoa(id), "col", label))) } return strings.Join(cells, " · ") } func threats(board *gno4.Board) string { out := "" for _, d := range []gno4.Disc{gno4.Red, gno4.Yellow} { if col := board.WinningMove(d); col >= 0 { out += "- " + disc(d) + " wins immediately by playing column " + itoa(col+1) + ".\n" } } if out == "" { return "Neither side can win on this move.\n" } return out } func gameLink(id int) string { return md.Link(itoa(id), self()+":g/"+itoa(id)) } func player(addr address) string { if addr == "" { return "_nobody_" } return md.Link(short(addr), self()+":u/"+addr.String()) } func short(addr address) string { s := addr.String() if len(s) <= 12 { return s } return s[:8] + "…" + s[len(s)-4:] } func disc(d gno4.Disc) string { switch d { case gno4.Red: return "🔴" case gno4.Yellow: return "🟡" } return "⚫" } func result(g *Game) string { if !g.Over() { return "in play" } if w := g.Winner(); w != "" { if g.Quit != "" { return player(w) + " (resignation)" } return player(w) } if g.Yellow == "" { return "withdrawn" } return "draw" } func recordLine(addr address) string { w, l, d := RecordOf(addr) return itoa(w) + "W / " + itoa(l) + "L / " + itoa(d) + "D" } func other(g *Game, addr address) address { if g.Red == addr { return g.Yellow } return g.Red } func orDash(s string) string { if s == "" { return "(no moves yet)" } return s } // howTo prints the two commands that cover the whole game, against whichever // path this realm is actually deployed at. func howTo() string { pkg := runtime.ChainDomain() + strings.TrimPrefix(self(), "/") tail := " \\\n -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast" + " -chainid " + runtime.ChainID() + " \\\n -remote https://rpc.gno.land:443 YOURKEY\n" return "```sh\n" + "# open a table; the transaction's output carries the id\n" + "gnokey maketx call -pkgpath " + pkg + " -func Open" + tail + "\n" + "# drop a disc into column 4 of table 7\n" + "gnokey maketx call -pkgpath " + pkg + " -func Play -args 7 -args 4" + tail + "```\n" + "\nThose gas numbers are a starting point, not a measurement. Simulate first: " + "drop `-broadcast` and the node answers with what the call actually costs.\n" } func itoa64(n int64) string { return itoa(int(n)) } func atoi(s string) (int, bool) { if s == "" || len(s) > 9 { return 0, false } n := 0 for i := 0; i < len(s); i++ { c := s[i] if c < '0' || c > '9' { return 0, false } n = n*10 + int(c-'0') } return n, true }

Result log

msg:0,success:true,log:,events:[]
msg:1,success:false,log:--= Error =--
Data: vm.InvalidPkgPathError{abciError:vm.abciError{}}
Msg Traces:
    0  github.com/gnolang/gno/gno.land/pkg/sdk/vm/errors.go:88 - package name "gno4" does not match path element "preview"
Stack Trace:
    0  github.com/gnolang/gno/tm2/pkg/errors/errors.go:93
    1  github.com/gnolang/gno/gno.land/pkg/sdk/vm/errors.go:88
    2  github.com/gnolang/gno/gno.land/pkg/sdk/vm/keeper.go:608
    3  github.com/gnolang/gno/gno.land/pkg/sdk/vm/keeper.go:786
    4  github.com/gnolang/gno/gno.land/pkg/sdk/vm/handler.go:46
    5  github.com/gnolang/gno/gno.land/pkg/sdk/vm/handler.go:29
    6  github.com/gnolang/gno/tm2/pkg/sdk/baseapp.go:719
    7  github.com/gnolang/gno/tm2/pkg/sdk/baseapp.go:963
    8  github.com/gnolang/gno/tm2/pkg/sdk/baseapp.go:650
    9  github.com/gnolang/gno/tm2/pkg/bft/abci/client/local_client.go:95
   10  github.com/gnolang/gno/tm2/pkg/bft/appconn/app_conn.go:74
... 13 more lines elided,events:[]

← Back to block 408,521