Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}