Transaction

80D3835871189B…D88E190E43C4

Block 408,520 · index 0 · indexed

Summary

Hash
80D3835871189B2413E96C09B6866FAEE9B09C8F538B15DB2CD3D88E190E43C4
Block
408,520
Size
16015 bytes
Gas used
22,935,280 / 52,757,600
Fee
158272ugnot
Status
success

Messages

#1AddPackagegno.land/p/moul/gno4/v011 arguments
Attached funds
7000000ugnot

Arguments · 11

  1. #1gno4
  2. #2README.md
  3. #3# `gno.land/p/moul/gno4/v0` **Connect Four, as rules and nothing else**: `New`, `Drop`, `Winner`, `Encode`, `Decode`, `SVG`. ```go import "gno.land/p/moul/gno4/v0" b, _ := gno4.Decode("1122334") // replay a whole game from 7 bytes b.Winner() // -> gno4.Red b.Line() // -> the four cells that won b.Image("board") // -> ![board](data:image/svg+xml;base64,...) ``` A realm that wants a game needs three different things: the rules, the authority to decide who may move, and a way to show the result. Only the first is reusable, and only the first can be tested without a chain. This package is the first, alone: it imports no chain package, takes no address, reads no height, and has no idea a block exists. **A game is its move list, deliberately.** `Encode` returns one digit per move and nothing else; `Decode` replays them. The position, the winner and the winning line are all derived, never stored, so there is exactly one representation and no second encoder that can drift from it. A whole game is at most 42 bytes, which is small enough that a realm stores the string and replays it rather than persisting a board. Four behaviours worth knowing before you use it: - **`Drop` is the only mutator.** Every illegal position this package could reach is refused in one place, which is why `Decode` can be trusted: it replays through `Drop`, so an encoding describing an impossible game (a column overflowing, a move after the win) is rejected rather than becoming a board no rule allows. - **`At` returns `Empty` out of range instead of panicking.** Render loops walk past the edge on purpose, and a `Render` that panics is a realm page that is unreadable forever. - **The winner is computed once, by the `Drop` that creates it.** A finished game is rendered far more often than it is played, and rescanning 69 lines per read is gas spent to learn something the board already knew. - **`WinningMove` is one ply deep, on purpose.** It answers "can I win now" and "must I block now" for seven scans and no allocation. Real search belongs in the front-end, where nobody pays gas to think. `SVG` draws the board with [`p/nt/svg`](/p/nt/svg/v0) and `Image` wraps it as a `data:image/svg+xml;base64` markdown image, which is the one inline data URI gnoweb allows. It is generated at render time, so it costs no storage and there is no asset to go missing later. Live demo: [r/moul/gno4](/r/moul/gno4).
  4. #4encode.gno
  5. #5package gno4 import "strings" // Encode returns the whole game as one string: the 1-based column of each // move, in order. An empty game is the empty string, a full game is 42 bytes. // // The position is not stored, because it is not information: replaying the // moves reproduces it exactly, including the winner and the winning line. That // is what lets a match be a URL, a chain event and a test fixture without three // encoders that can disagree. func (b *Board) Encode() string { var sb strings.Builder for _, col := range b.moves { sb.WriteByte(byte('1' + col)) } return sb.String() } // Decode replays an encoding produced by [Board.Encode]. // // It replays through [Board.Drop] rather than writing cells directly, so an // encoding that describes an impossible game (a column overflowing, a move // after the win) is rejected here instead of becoming a board no rule allows. // Nothing else in this package can construct an illegal position. func Decode(s string) (*Board, error) { b := New() for i := 0; i < len(s); i++ { c := s[i] if c < '1' || c > '7' { return nil, ErrBadEncoding } if _, err := b.Drop(int(c - '1')); err != nil { return nil, ErrBadEncoding } } return b, nil } // String renders the board as seven columns of ASCII, top row first, the way a // player looks at it. Empty is '.', Red is 'R', Yellow is 'Y'. // // This is the debugging view and the one tests assert against. What gnoweb // shows is [Board.Image]; what a terminal or a diff shows is this. func (b *Board) String() string { var sb strings.Builder for row := Rows - 1; row >= 0; row-- { for col := 0; col < Cols; col++ { switch b.At(col, row) { case Red: sb.WriteByte('R') case Yellow: sb.WriteByte('Y') default: sb.WriteByte('.') } } sb.WriteByte('\n') } sb.WriteString("1234567\n") return sb.String() }
  6. #6gno4.gno
  7. #7// Package gno4 is a Connect Four engine: the rules, and nothing else. // // It imports no chain package, takes no address and reads no height, so it is // unit-testable with no node and a realm that uses it stays thin. Everything a // caller needs to know about a position is derivable from a [Board], and a // whole game round-trips through a string of at most 42 digits ([Board.Encode]). // // Live demo: [r/moul/gno4](/r/moul/gno4). package gno4 import "errors" // Cols and Rows are the standard board. They are constants rather than fields // because every rule below, the win scan in particular, is written against // them; a variable board is a different package, not a flag on this one. const ( Cols = 7 Rows = 6 ) // Disc identifies whose disc occupies a cell. Empty is the zero value, so a // freshly allocated Board is a legal empty board with no constructor call. type Disc byte const ( Empty Disc = 0 Red Disc = 1 Yellow Disc = 2 ) // Other returns the opponent of d, and Empty for Empty. func (d Disc) Other() Disc { switch d { case Red: return Yellow case Yellow: return Red } return Empty } func (d Disc) String() string { switch d { case Red: return "red" case Yellow: return "yellow" } return "empty" } var ( ErrColumnRange = errors.New("gno4: column out of range") ErrColumnFull = errors.New("gno4: column is full") ErrGameOver = errors.New("gno4: game is over") ErrBadEncoding = errors.New("gno4: not a valid game encoding") ) // Board is a position plus the moves that produced it. // // The winner and the winning line are computed once, by the Drop that creates // them, and stored. A realm renders a finished game far more often than it // plays a move, and rescanning 69 lines on every Render is gas spent to learn // something the board already knew. type Board struct { cells [Cols * Rows]Disc moves []int winner Disc line []int } // New returns an empty board. Red moves first. func New() *Board { return &Board{} } func idx(col, row int) int { return row*Cols + col } // At returns the disc at (col, row), counting rows from the bottom. Out of // range is Empty rather than a panic: At is called from render loops that walk // past the edge on purpose, and a Render that panics is a permanently // unreadable page. func (b *Board) At(col, row int) Disc { if col < 0 || col >= Cols || row < 0 || row >= Rows { return Empty } return b.cells[idx(col, row)] } // Turn returns whose move it is. Red on even move counts, Yellow on odd. func (b *Board) Turn() Disc { if len(b.moves)%2 == 0 { return Red } return Yellow } // MoveCount returns how many discs are on the board. func (b *Board) MoveCount() int { return len(b.moves) } // Moves returns a copy of the move list, as zero-based column indexes. func (b *Board) Moves() []int { out := make([]int, len(b.moves)) copy(out, b.moves) return out } // Height returns how many discs are stacked in col. func (b *Board) Height(col int) int { if col < 0 || col >= Cols { return 0 } n := 0 for row := 0; row < Rows; row++ { if b.cells[idx(col, row)] == Empty { break } n++ } return n } // Winner returns the disc that made four in a row, or Empty. func (b *Board) Winner() Disc { return b.winner } // Line returns the four cell indexes that won, or nil. An index is // row*Cols+col, the same order [Board.At] uses. func (b *Board) Line() []int { out := make([]int, len(b.line)) copy(out, b.line) return out } // Full reports whether all 42 cells are taken. func (b *Board) Full() bool { return len(b.moves) == Cols*Rows } // Over reports whether the game is decided: someone won, or the board filled. func (b *Board) Over() bool { return b.winner != Empty || b.Full() } // Draw reports whether the board filled with no winner. func (b *Board) Draw() bool { return b.winner == Empty && b.Full() } // CanDrop reports whether the current player may play col right now. func (b *Board) CanDrop(col int) bool { return !b.Over() && col >= 0 && col < Cols && b.Height(col) < Rows } // Legal returns every column the current player may play. Empty once the game // is over, which is what makes it safe to render as the move menu. func (b *Board) Legal() []int { out := []int{} if b.Over() { return out } for col := 0; col < Cols; col++ { if b.Height(col) < Rows { out = append(out, col) } } return out } // Drop plays the current player's disc into col and returns the row it landed // on. It is the only method that mutates a Board, so every illegal state this // package could reach is refused in one place. func (b *Board) Drop(col int) (int, error) { if b.Over() { return 0, ErrGameOver } if col < 0 || col >= Cols { return 0, ErrColumnRange } row := b.Height(col) if row >= Rows { return 0, ErrColumnFull } d := b.Turn() b.cells[idx(col, row)] = d b.moves = append(b.moves, col) if line := b.scan(col, row, d); line != nil { b.winner = d b.line = line } return row, nil } // Clone returns an independent copy. Callers that search (a hint, a bot) mutate // a clone; a Board handed out by a realm is never a scratch pad. func (b *Board) Clone() *Board { c := &Board{cells: b.cells, winner: b.winner} c.moves = make([]int, len(b.moves)) copy(c.moves, b.moves) c.line = make([]int, len(b.line)) copy(c.line, b.line) return c } // WinningMove returns a column where d wins on the spot, or -1. // // It is deliberately one ply deep. That covers the two things a player wants // from a board they are looking at ("can I win now", "can they win now, so I // must block") and costs seven clones; a real search belongs off-chain, in the // front-end, where nobody pays gas to think. func (b *Board) WinningMove(d Disc) int { if b.Over() { return -1 } for col := 0; col < Cols; col++ { row := b.Height(col) if row >= Rows { continue } if b.scan(col, row, d) != nil { return col } } return -1 } // dirs are the four axes a line can run along: east, north, north-east, // north-west. Their opposites are covered by scanning both ways from the // placed disc. var dirs = [4][2]int{{1, 0}, {0, 1}, {1, 1}, {1, -1}} // scan looks for a line of four through (col, row) for d. // // It never reads (col, row) itself, only the cells around it, so the same scan // answers both questions this package asks: "did the disc I just placed win" // and "would a disc placed here win". That is why [Board.WinningMove] needs no // clone and writes nothing. func (b *Board) scan(col, row int, d Disc) []int { for _, dir := range dirs { cells := []int{idx(col, row)} for _, sign := range [2]int{1, -1} { for step := 1; step < 4; step++ { c := col + dir[0]*sign*step r := row + dir[1]*sign*step if c < 0 || c >= Cols || r < 0 || r >= Rows { break } if b.cells[idx(c, r)] != d { break } cells = append(cells, idx(c, r)) } } if len(cells) >= 4 { return trimLine(cells, dir, col, row) } } return nil } // trimLine reduces a run of five or six to the four cells adjacent to the disc // that was just played, walking outward from it so the highlighted line always // contains the winning move. func trimLine(cells []int, dir [2]int, col, row int) []int { out := []int{idx(col, row)} for _, sign := range [2]int{1, -1} { for step := 1; step < 4 && len(out) < 4; step++ { c := col + dir[0]*sign*step r := row + dir[1]*sign*step if !contains(cells, idx(c, r)) { break } out = append(out, idx(c, r)) } } sortInts(out) return out } func contains(haystack []int, needle int) bool { for _, v := range haystack { if v == needle { return true } } return false } // sortInts is an insertion sort. gno has no sort.Slice, and the input is four // elements, so reaching for anything larger would be theatre. func sortInts(a []int) { for i := 1; i < len(a); i++ { v := a[i] j := i - 1 for j >= 0 && a[j] > v { a[j+1] = a[j] j-- } a[j+1] = v } }
  8. #8gnomod.toml
  9. #9module = "gno.land/p/moul/gno4/v0" gno = "0.9"
  10. #10view.gno
  11. #11package gno4 import ( "strings" "gno.land/p/nt/svg/v0" "gno.land/p/nt/ufmt/v0" ) // Board geometry, in SVG user units. Everything else is derived, so changing // cell changes the whole drawing and nothing goes out of step. const ( pad = 10 cell = 74 hole = 30 svgW = Cols*cell + 2*pad svgH = Rows*cell + 2*pad labY = svgH + 22 ) // The palette. Fixed hex rather than CSS variables: gnoweb renders the SVG // inside a data URI, which is its own document and inherits nothing from the // page, so a theme-aware colour would resolve to nothing at all. const ( colBoard = "#1d4ed8" colHole = "#f8fafc" colRed = "#ef4444" colYellow = "#facc15" colLabel = "#64748b" colWin = "#0f172a" ) // SVG returns the board as standalone <svg> markup. // // It is a drawing of a position, not a control: there is no script, no link // and no interactivity, because gnoweb serves this through a data URI and // would not run any of it. The clickable column menu is the realm's job, as // ordinary markdown links next to the image. func (b *Board) SVG() string { return b.canvas().String() } // Image returns the board as a markdown image, ready to drop into a realm's // Render output. The SVG travels inline as a data URI, which gnoweb allows for // image/svg+xml specifically, so the board needs no hosting and no asset // pipeline: there is nothing to go missing later, and no storage is spent on // it because it is drawn at render time rather than stored. func (b *Board) Image(alt string) string { return b.canvas().Render(alt) } func (b *Board) canvas() *svg.Canvas { c := svg.NewCanvas(svgW, svgH+30).WithViewBox(0, 0, svgW, svgH+30) c.AddStyle(".win", "stroke:"+colWin+";stroke-width:5") c.AddStyle(".last", "stroke:#ffffff;stroke-width:4") c.AddStyle(".lab", "font:600 22px system-ui,sans-serif;text-anchor:middle") c.Append(svg.NewRectangle(0, 0, svgW, svgH, colBoard)) last := -1 if n := len(b.moves); n > 0 { lastCol := b.moves[n-1] last = idx(lastCol, b.Height(lastCol)-1) } win := b.line for row := Rows - 1; row >= 0; row-- { for col := 0; col < Cols; col++ { cx := pad + col*cell + cell/2 cy := pad + (Rows-1-row)*cell + cell/2 fill := colHole switch b.At(col, row) { case Red: fill = colRed case Yellow: fill = colYellow } disc := svg.NewCircle(cx, cy, hole, fill) switch { case contains(win, idx(col, row)): disc.WithClass("win") case idx(col, row) == last: disc.WithClass("last") } c.Append(disc) } } for col := 0; col < Cols; col++ { x := pad + col*cell + cell/2 c.Append(svg.NewText(x, labY, ufmt.Sprintf("%d", col+1), colLabel).WithClass("lab")) } return c } // Emoji returns the board as seven columns of coloured squares, top row first. // // The text fallback, for the places an image cannot go: a chain event, a // terminal, a commit message, a diff. It is also what makes the whole package // legible in a test failure. func (b *Board) Emoji() string { var sb strings.Builder for row := Rows - 1; row >= 0; row-- { for col := 0; col < Cols; col++ { switch b.At(col, row) { case Red: sb.WriteString("🔴") case Yellow: sb.WriteString("🟡") default: sb.WriteString("⚫") } } sb.WriteByte('\n') } return sb.String() }

Result log

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

← Back to block 408,520