rps.gno
4.61 Kb · 222 lines
1// Package rps implements a simple two-player Rock Paper Scissors game.
2//
3// Note: moves are stored in plaintext on-chain. Player 2 can read
4// Player 1's move before submitting their own. For a fair game, a
5// commit-reveal scheme would be needed.
6package rps
7
8import (
9 "strconv"
10 "strings"
11
12 "chain"
13 "gno.land/p/nt/avl/v0"
14)
15
16const (
17 Rock = "rock"
18 Paper = "paper"
19 Scissors = "scissors"
20)
21
22// Game holds state for a single match.
23type Game struct {
24 ID string
25 Player1 address
26 Player2 address
27 Move1 string
28 Move2 string
29 Status string // "waiting" | "finished"
30 Winner string // "player1" | "player2" | "draw" | ""
31}
32
33var (
34 games avl.Tree
35 counter int
36)
37
38// NewGame creates a match between the caller (player 1) and opponent (player 2).
39// Returns the game ID.
40func NewGame(cur realm, opponent address) string {
41 if !cur.IsCurrent() {
42 panic("spoofed realm")
43 }
44 if !opponent.IsValid() {
45 panic("invalid opponent address")
46 }
47 caller := cur.Previous().Address()
48 if caller == opponent {
49 panic("cannot play against yourself")
50 }
51
52 counter++
53 gameID := strconv.Itoa(counter)
54
55 games.Set(gameID, &Game{
56 ID: gameID,
57 Player1: caller,
58 Player2: opponent,
59 Status: "waiting",
60 })
61
62 chain.Emit("NewGame",
63 "gameID", gameID,
64 "player1", caller.String(),
65 "player2", opponent.String(),
66 )
67
68 return gameID
69}
70
71// Play submits a move for the calling player. move must be "rock", "paper", or "scissors".
72// The game resolves automatically when both players have submitted.
73func Play(cur realm, gameID string, move string) {
74 if !cur.IsCurrent() {
75 panic("spoofed realm")
76 }
77
78 move = strings.ToLower(strings.TrimSpace(move))
79 if move != Rock && move != Paper && move != Scissors {
80 panic("invalid move: must be rock, paper, or scissors")
81 }
82
83 val, ok := games.Get(gameID)
84 if !ok {
85 panic("game not found: " + gameID)
86 }
87 game := val.(*Game)
88
89 if game.Status == "finished" {
90 panic("game already finished")
91 }
92
93 caller := cur.Previous().Address()
94 switch caller {
95 case game.Player1:
96 if game.Move1 != "" {
97 panic("already submitted a move")
98 }
99 game.Move1 = move
100 case game.Player2:
101 if game.Move2 != "" {
102 panic("already submitted a move")
103 }
104 game.Move2 = move
105 default:
106 panic("not a player in this game")
107 }
108
109 if game.Move1 != "" && game.Move2 != "" {
110 game.Status = "finished"
111 game.Winner = determineWinner(game.Move1, game.Move2)
112 chain.Emit("GameFinished",
113 "gameID", gameID,
114 "winner", game.Winner,
115 "move1", game.Move1,
116 "move2", game.Move2,
117 )
118 }
119}
120
121func determineWinner(move1, move2 string) string {
122 if move1 == move2 {
123 return "draw"
124 }
125 if beats(move1, move2) {
126 return "player1"
127 }
128 return "player2"
129}
130
131func beats(a, b string) bool {
132 return (a == Rock && b == Scissors) ||
133 (a == Paper && b == Rock) ||
134 (a == Scissors && b == Paper)
135}
136
137// Render shows all games on the home path, or a single game on /{gameID}.
138func Render(path string) string {
139 path = strings.TrimPrefix(path, "/")
140
141 if path != "" {
142 return renderGame(path)
143 }
144
145 if games.Size() == 0 {
146 return "# Rock Paper Scissors\n\nNo games yet.\n"
147 }
148
149 out := "# Rock Paper Scissors\n\n"
150 out += "| ID | Player 1 | Player 2 | Status | Winner |\n"
151 out += "|---|---|---|---|---|\n"
152
153 games.ReverseIterate("", "", func(key string, val any) bool {
154 g := val.(*Game)
155 winner := "-"
156 switch g.Winner {
157 case "player1":
158 winner = "Player 1"
159 case "player2":
160 winner = "Player 2"
161 case "draw":
162 winner = "Draw"
163 }
164 out += "| " + g.ID +
165 " | " + shortAddr(g.Player1) +
166 " | " + shortAddr(g.Player2) +
167 " | " + g.Status +
168 " | " + winner + " |\n"
169 return false
170 })
171
172 return out
173}
174
175func renderGame(gameID string) string {
176 val, ok := games.Get(gameID)
177 if !ok {
178 return "> [!WARNING]\n> Game not found: " + gameID + "\n"
179 }
180
181 g := val.(*Game)
182 out := "# Game #" + g.ID + "\n\n"
183 out += "- **Player 1**: " + g.Player1.String() + "\n"
184 out += "- **Player 2**: " + g.Player2.String() + "\n"
185 out += "- **Status**: " + g.Status + "\n\n"
186
187 if g.Status == "finished" {
188 out += "## Result\n\n"
189 out += "- Player 1 played: **" + g.Move1 + "**\n"
190 out += "- Player 2 played: **" + g.Move2 + "**\n\n"
191 switch g.Winner {
192 case "player1":
193 out += "**Player 1 wins!**\n"
194 case "player2":
195 out += "**Player 2 wins!**\n"
196 case "draw":
197 out += "**Draw!**\n"
198 }
199 } else {
200 out += "## Moves\n\n"
201 if g.Move1 == "" {
202 out += "- Player 1: waiting...\n"
203 } else {
204 out += "- Player 1: submitted\n"
205 }
206 if g.Move2 == "" {
207 out += "- Player 2: waiting...\n"
208 } else {
209 out += "- Player 2: submitted\n"
210 }
211 }
212
213 return out
214}
215
216func shortAddr(addr address) string {
217 s := addr.String()
218 if len(s) <= 14 {
219 return s
220 }
221 return s[:6] + "..." + s[len(s)-4:]
222}