tictactoe.gno
7.43 Kb · 355 lines
1package tictactoe
2
3import (
4 "bytes"
5 "strconv"
6 "strings"
7
8 "gno.land/p/nt/avl/v0"
9 "chain"
10)
11
12type cell uint8
13
14const (
15 empty cell = 0
16 xMark cell = 1
17 oMark cell = 2
18)
19
20type gameStatus uint8
21
22const (
23 statusOngoing gameStatus = 0
24 statusXWins gameStatus = 1
25 statusOWins gameStatus = 2
26 statusDraw gameStatus = 3
27)
28
29type game struct {
30 id int
31 board [9]cell
32 playerX address
33 playerO address
34 turn cell
35 status gameStatus
36}
37
38var (
39 games avl.Tree
40 counter int
41)
42
43// NewGame creates a new tic-tac-toe game. The caller becomes player X.
44// Returns the game ID to share with your opponent.
45func NewGame(cur realm) int {
46 if !cur.IsCurrent() {
47 panic("spoofed realm")
48 }
49 if !cur.Previous().IsUserCall() {
50 panic("EOA call only")
51 }
52
53 counter++
54 g := &game{
55 id: counter,
56 playerX: cur.Previous().Address(),
57 turn: xMark,
58 status: statusOngoing,
59 }
60 games.Set(strconv.Itoa(counter), g)
61
62 chain.Emit("GameCreated",
63 "gameID", strconv.Itoa(counter),
64 "playerX", g.playerX.String(),
65 )
66
67 return counter
68}
69
70// Join lets a second player join an existing game as player O.
71func Join(cur realm, gameID int) {
72 if !cur.IsCurrent() {
73 panic("spoofed realm")
74 }
75 if !cur.Previous().IsUserCall() {
76 panic("EOA call only")
77 }
78
79 g := mustGetGame(gameID)
80 caller := cur.Previous().Address()
81
82 if g.playerO != address("") {
83 panic("game already has two players")
84 }
85 if g.playerX == caller {
86 panic("cannot play against yourself")
87 }
88
89 g.playerO = caller
90
91 chain.Emit("PlayerJoined",
92 "gameID", strconv.Itoa(gameID),
93 "playerO", caller.String(),
94 )
95}
96
97// Move places the caller's mark at the given board position.
98// Positions are 0-8, laid out row-major:
99//
100// 0 | 1 | 2
101// 3 | 4 | 5
102// 6 | 7 | 8
103func Move(cur realm, gameID int, pos int) {
104 if !cur.IsCurrent() {
105 panic("spoofed realm")
106 }
107 if !cur.Previous().IsUserCall() {
108 panic("EOA call only")
109 }
110
111 g := mustGetGame(gameID)
112 caller := cur.Previous().Address()
113
114 if g.status != statusOngoing {
115 panic("game is already over")
116 }
117 if g.playerO == address("") {
118 panic("waiting for second player to join")
119 }
120 if g.turn == xMark && caller != g.playerX {
121 panic("not your turn")
122 }
123 if g.turn == oMark && caller != g.playerO {
124 panic("not your turn")
125 }
126 if pos < 0 || pos > 8 {
127 panic("position out of range: must be 0-8")
128 }
129 if g.board[pos] != empty {
130 panic("position already occupied")
131 }
132
133 g.board[pos] = g.turn
134
135 chain.Emit("MoveMade",
136 "gameID", strconv.Itoa(gameID),
137 "player", caller.String(),
138 "pos", strconv.Itoa(pos),
139 "mark", markStr(g.turn),
140 )
141
142 if checkWin(g.board, g.turn) {
143 if g.turn == xMark {
144 g.status = statusXWins
145 } else {
146 g.status = statusOWins
147 }
148 chain.Emit("GameOver",
149 "gameID", strconv.Itoa(gameID),
150 "result", markStr(g.turn)+"Wins",
151 "winner", caller.String(),
152 )
153 return
154 }
155
156 if isBoardFull(g.board) {
157 g.status = statusDraw
158 chain.Emit("GameOver",
159 "gameID", strconv.Itoa(gameID),
160 "result", "draw",
161 )
162 return
163 }
164
165 if g.turn == xMark {
166 g.turn = oMark
167 } else {
168 g.turn = xMark
169 }
170}
171
172// Render returns the home page ("") or a specific game ("game/1").
173func Render(path string) string {
174 if path == "" {
175 return renderHome()
176 }
177 if strings.HasPrefix(path, "game/") {
178 idStr := strings.TrimPrefix(path, "game/")
179 id, err := strconv.Atoi(idStr)
180 if err != nil || id < 1 {
181 return "> [!WARNING]\n> Invalid game ID.\n"
182 }
183 g, ok := getGame(id)
184 if !ok {
185 return "> [!WARNING]\n> Game not found.\n"
186 }
187 return renderGame(g)
188 }
189 return "> [!WARNING]\n> Path not found.\n"
190}
191
192func renderHome() string {
193 var b bytes.Buffer
194
195 b.WriteString("# Tic-Tac-Toe\n\n")
196 b.WriteString("Challenge friends on-chain — create a game and share the link!\n\n")
197 b.WriteString("## New Game\n\n")
198 b.WriteString("<gno-form exec=\"NewGame\">\n</gno-form>\n\n")
199
200 if counter == 0 {
201 b.WriteString("_No games yet — be the first!_\n")
202 return b.String()
203 }
204
205 b.WriteString("## Games\n\n")
206 b.WriteString("| # | Status | Player X | Player O |\n")
207 b.WriteString("|---|--------|----------|----------|\n")
208
209 games.Iterate("", "", func(key string, value any) bool {
210 g := value.(*game)
211 playerO := "_(open)_"
212 if g.playerO != address("") {
213 playerO = g.playerO.String()
214 }
215 b.WriteString("| [#" + key + "](:game/" + key + ") | " +
216 gameStatusStr(g) + " | " +
217 g.playerX.String() + " | " +
218 playerO + " |\n")
219 return false
220 })
221
222 return b.String()
223}
224
225func renderGame(g *game) string {
226 var b bytes.Buffer
227 id := strconv.Itoa(g.id)
228
229 b.WriteString("# Game #" + id + "\n\n")
230 b.WriteString(renderBoard(g.board))
231 b.WriteString("\n\n")
232
233 switch g.status {
234 case statusXWins:
235 b.WriteString("> [!SUCCESS]\n> **X wins!** Congratulations!\n\n")
236 case statusOWins:
237 b.WriteString("> [!SUCCESS]\n> **O wins!** Congratulations!\n\n")
238 case statusDraw:
239 b.WriteString("> [!INFO]\n> **Draw!** Well played, both.\n\n")
240 case statusOngoing:
241 if g.playerO == address("") {
242 b.WriteString("> [!NOTE]\n> Waiting for Player O to join.\n\n")
243 b.WriteString("## Join as Player O\n\n")
244 b.WriteString("<gno-form exec=\"Join\">\n")
245 b.WriteString("<gno-input name=\"gameID\" value=\"" + id + "\" readonly=\"true\" />\n")
246 b.WriteString("</gno-form>\n\n")
247 } else {
248 b.WriteString("> [!INFO]\n> It's **" + markStr(g.turn) + "'s** turn.\n\n")
249 b.WriteString("## Make a Move\n\n")
250 b.WriteString("<gno-form exec=\"Move\">\n")
251 b.WriteString("<gno-input name=\"gameID\" value=\"" + id + "\" readonly=\"true\" />\n")
252 b.WriteString("<gno-input name=\"pos\" type=\"number\" placeholder=\"Position (0-8)\" required=\"true\" />\n")
253 b.WriteString("</gno-form>\n\n")
254 }
255 }
256
257 b.WriteString("**Player X:** " + g.playerX.String() + "\n\n")
258 if g.playerO != address("") {
259 b.WriteString("**Player O:** " + g.playerO.String() + "\n\n")
260 }
261 b.WriteString("[← All games](:)\n")
262
263 return b.String()
264}
265
266// renderBoard renders the 3x3 board as a markdown table.
267// Empty cells show their position number (0-8) to guide the next move.
268// The top row doubles as the markdown table header.
269func renderBoard(board [9]cell) string {
270 var b bytes.Buffer
271 b.WriteString("| ")
272 for col := 0; col < 3; col++ {
273 b.WriteString(cellDisplay(board[col], col) + " | ")
274 }
275 b.WriteString("\n|:---:|:---:|:---:|\n")
276 for row := 1; row < 3; row++ {
277 b.WriteString("| ")
278 for col := 0; col < 3; col++ {
279 pos := row*3 + col
280 b.WriteString(cellDisplay(board[pos], pos) + " | ")
281 }
282 b.WriteString("\n")
283 }
284 return b.String()
285}
286
287func cellDisplay(c cell, pos int) string {
288 if c == empty {
289 return strconv.Itoa(pos)
290 }
291 return markStr(c)
292}
293
294func markStr(c cell) string {
295 if c == xMark {
296 return "X"
297 }
298 return "O"
299}
300
301func gameStatusStr(g *game) string {
302 switch g.status {
303 case statusXWins:
304 return "X Wins"
305 case statusOWins:
306 return "O Wins"
307 case statusDraw:
308 return "Draw"
309 default:
310 if g.playerO == address("") {
311 return "Open"
312 }
313 return "Ongoing"
314 }
315}
316
317func mustGetGame(id int) *game {
318 g, ok := getGame(id)
319 if !ok {
320 panic("game not found: " + strconv.Itoa(id))
321 }
322 return g
323}
324
325func getGame(id int) (*game, bool) {
326 v, ok := games.Get(strconv.Itoa(id))
327 if !ok {
328 return nil, false
329 }
330 return v.(*game), true
331}
332
333var winLines = [8][3]int{
334 {0, 1, 2}, {3, 4, 5}, {6, 7, 8},
335 {0, 3, 6}, {1, 4, 7}, {2, 5, 8},
336 {0, 4, 8}, {2, 4, 6},
337}
338
339func checkWin(board [9]cell, mark cell) bool {
340 for _, line := range winLines {
341 if board[line[0]] == mark && board[line[1]] == mark && board[line[2]] == mark {
342 return true
343 }
344 }
345 return false
346}
347
348func isBoardFull(board [9]cell) bool {
349 for _, c := range board {
350 if c == empty {
351 return false
352 }
353 }
354 return true
355}