connect4.gno
6.89 Kb · 294 lines
1package connect4
2
3import (
4 "chain"
5 "errors"
6 "strconv"
7 "strings"
8
9 "gno.land/p/nym-moultest123/kit/store/v0"
10 "gno.land/p/nym-moultest123/kit/ui/v0"
11 "gno.land/p/nym-moultest123/md/v0"
12)
13
14// selfPath is this realm's own page. A root-relative link resolves against
15// the domain, not the realm, so every link back into it names it in full.
16const selfPath = "/r/nym-moultest123/x/daily/connect4/v1"
17
18const (
19 cols = 7
20 rows = 6
21)
22
23// cell values
24const (
25 empty = 0
26 red = 1 // 🔴 game creator (caller of NewGame)
27 yel = 2 // 🟡 opponent
28)
29
30// Game holds the full state of one Connect Four match.
31// board is indexed board[row][col]; row 0 is the BOTTOM row.
32// It carries no ID field: the id belongs to the store, which hands it back on
33// lookup and iteration.
34type Game struct {
35 Red address // 🔴 creator, moves first
36 Yellow address // 🟡 opponent
37 Board [rows][cols]int
38 Turn int // whose turn: red or yel
39 Winner int // empty until decided; red/yel = winner
40 Draw bool // true when board full with no winner
41 Finished bool
42}
43
44// games assigns the game ids. v1 kept its own nextID plus an idKey() that
45// zero-padded to width 12, which stopped ordering the Render list past 10^12.
46var games = store.Named("game")
47
48// NewGame creates a match between the caller (🔴) and opponent (🟡).
49// Returns the new game id.
50func NewGame(cur realm, opponent address) int64 {
51 if !cur.IsCurrent() {
52 panic("connect4: spoofed realm")
53 }
54 caller := cur.Previous().Address()
55 if opponent == caller {
56 panic("opponent must differ from caller")
57 }
58 if opponent.String() == "" {
59 panic("opponent address is empty")
60 }
61 id := games.Add(&Game{
62 Red: caller,
63 Yellow: opponent,
64 Turn: red,
65 })
66 chain.Emit("GameCreated",
67 "id", id.String(),
68 "red", caller.String(),
69 "yellow", opponent.String(),
70 )
71 return int64(id)
72}
73
74func getGame(id int64) (*Game, error) {
75 v, ok := games.Get(store.ID(id))
76 if !ok {
77 return nil, errors.New("game not found: " + strconv.FormatInt(id, 10))
78 }
79 return v.(*Game), nil
80}
81
82// Drop places the caller's disc into the given column (0-6).
83// Enforces turn order by caller, rejects full columns, and detects
84// a 4-in-a-row win or a draw.
85func Drop(cur realm, gameID int64, column int) {
86 if !cur.IsCurrent() {
87 panic("connect4: spoofed realm")
88 }
89 if column < 0 || column >= cols {
90 panic("column out of range (0-6)")
91 }
92 g, err := getGame(gameID)
93 if err != nil {
94 panic(err.Error())
95 }
96 if g.Finished {
97 panic("game already finished")
98 }
99
100 caller := cur.Previous().Address()
101 var mover int
102 switch caller {
103 case g.Red:
104 mover = red
105 case g.Yellow:
106 mover = yel
107 default:
108 panic("caller is not a player in this game")
109 }
110 if mover != g.Turn {
111 panic("not your turn")
112 }
113
114 // find lowest empty row in the column
115 placed := -1
116 for r := 0; r < rows; r++ {
117 if g.Board[r][column] == empty {
118 g.Board[r][column] = mover
119 placed = r
120 break
121 }
122 }
123 if placed == -1 {
124 panic("column is full")
125 }
126
127 if wins(&g.Board, placed, column, mover) {
128 g.Winner = mover
129 g.Finished = true
130 chain.Emit("GameWon",
131 "id", strconv.FormatInt(gameID, 10),
132 "winner", caller.String(),
133 )
134 } else if full(&g.Board) {
135 g.Draw = true
136 g.Finished = true
137 chain.Emit("GameDraw", "id", strconv.FormatInt(gameID, 10))
138 } else {
139 if g.Turn == red {
140 g.Turn = yel
141 } else {
142 g.Turn = red
143 }
144 chain.Emit("DiscDropped",
145 "id", strconv.FormatInt(gameID, 10),
146 "col", strconv.Itoa(column),
147 "player", caller.String(),
148 )
149 }
150
151}
152
153func full(b *[rows][cols]int) bool {
154 for c := 0; c < cols; c++ {
155 if b[rows-1][c] == empty {
156 return false
157 }
158 }
159 return true
160}
161
162// wins checks whether the disc just placed at (r,c) for player p completes
163// a run of 4 in any of the four directions.
164func wins(b *[rows][cols]int, r, c, p int) bool {
165 // direction pairs: horizontal, vertical, diag /, diag \
166 dirs := [4][2]int{{0, 1}, {1, 0}, {1, 1}, {1, -1}}
167 for _, d := range dirs {
168 count := 1
169 count += run(b, r, c, d[0], d[1], p)
170 count += run(b, r, c, -d[0], -d[1], p)
171 if count >= 4 {
172 return true
173 }
174 }
175 return false
176}
177
178func run(b *[rows][cols]int, r, c, dr, dc, p int) int {
179 n := 0
180 for i := 1; i < 4; i++ {
181 rr := r + dr*i
182 cc := c + dc*i
183 if rr < 0 || rr >= rows || cc < 0 || cc >= cols {
184 break
185 }
186 if b[rr][cc] != p {
187 break
188 }
189 n++
190 }
191 return n
192}
193
194func glyph(v int) string {
195 switch v {
196 case red:
197 return "🔴"
198 case yel:
199 return "🟡"
200 default:
201 return "·"
202 }
203}
204
205// Render draws the board with 🔴🟡· and shows whose turn / the winner.
206// path "" lists all games; path "<id>" shows a single board.
207func Render(path string) string {
208 path = strings.TrimSpace(strings.Trim(path, "/"))
209 if path == "" {
210 return renderList()
211 }
212 id, ok := store.ParseID(path)
213 if !ok {
214 return "# Connect Four\n\nInvalid game id: " + md.InlineCode(path) + "\n"
215 }
216 g, err := getGame(int64(id))
217 if err != nil {
218 return "# Connect Four\n\n" + err.Error() + "\n"
219 }
220 return renderGame(id, g)
221}
222
223func renderList() string {
224 var sb strings.Builder
225 sb.WriteString("# Connect Four\n\n")
226 sb.WriteString("Two-player Connect Four on a 7×6 board. 🔴 is the game creator, 🟡 the opponent.\n\n")
227 if games.Len() == 0 {
228 sb.WriteString("_No games yet. Call `NewGame(opponent)` to start one._\n")
229 return sb.String()
230 }
231 sb.WriteString("## Games\n\n")
232 sb.WriteString("| ID | 🔴 Red | 🟡 Yellow | Status |\n")
233 sb.WriteString("|----|--------|-----------|--------|\n")
234 games.Each(func(id store.ID, v any) {
235 g := v.(*Game)
236 status := ""
237 switch {
238 case g.Winner == red:
239 status = "🔴 won"
240 case g.Winner == yel:
241 status = "🟡 won"
242 case g.Draw:
243 status = "draw"
244 case g.Turn == red:
245 status = "🔴 to move"
246 default:
247 status = "🟡 to move"
248 }
249 sb.WriteString("| [" + id.String() + "](" + selfPath + ":" + id.String() + ") | " +
250 ui.Addr(g.Red) + " | " + ui.Addr(g.Yellow) + " | " + status + " |\n")
251 })
252 sb.WriteString("\nOpen a game by its id (e.g. path `1`).\n")
253 return sb.String()
254}
255
256func renderGame(id store.ID, g *Game) string {
257 var sb strings.Builder
258 sb.WriteString("# Connect Four — Game " + id.String() + "\n\n")
259 sb.WriteString("- 🔴 Red: `" + g.Red.String() + "`\n")
260 sb.WriteString("- 🟡 Yellow: `" + g.Yellow.String() + "`\n\n")
261
262 // status line
263 switch {
264 case g.Winner == red:
265 sb.WriteString("**🔴 Red wins!**\n\n")
266 case g.Winner == yel:
267 sb.WriteString("**🟡 Yellow wins!**\n\n")
268 case g.Draw:
269 sb.WriteString("**Draw — board full.**\n\n")
270 case g.Turn == red:
271 sb.WriteString("**Turn: 🔴 Red**\n\n")
272 default:
273 sb.WriteString("**Turn: 🟡 Yellow**\n\n")
274 }
275
276 // board top-down (row rows-1 at top, row 0 at bottom)
277 sb.WriteString("```\n")
278 for r := rows - 1; r >= 0; r-- {
279 for c := 0; c < cols; c++ {
280 sb.WriteString(glyph(g.Board[r][c]))
281 }
282 sb.WriteString("\n")
283 }
284 // column indices
285 for c := 0; c < cols; c++ {
286 sb.WriteString(strconv.Itoa(c))
287 }
288 sb.WriteString("\n```\n\n")
289
290 if !g.Finished {
291 sb.WriteString("Drop a disc: `Drop(" + id.String() + ", <col 0-6>)`\n")
292 }
293 return sb.String()
294}