rpsmatch.gno
6.27 Kb · 259 lines
1// Package rpsmatch is a best-of-three rock-paper-scissors arena against the
2// house. Unlike a single stateless throw, a match persists across calls:
3// the first player to win two rounds takes the match, and every player's
4// match record accumulates on-chain.
5package rpsmatch
6
7import (
8 "strconv"
9 "strings"
10
11 "chain"
12 "chain/runtime"
13
14 "gno.land/p/nt/avl/v0"
15)
16
17// Move is one of rock, paper, or scissors, ordered so that
18// (winner - loser + 3) % 3 == 1 for every winning pair.
19type Move int
20
21const (
22 Rock Move = iota
23 Paper
24 Scissors
25)
26
27// winsNeeded is the number of round wins required to take a match.
28const winsNeeded = 2
29
30// match tracks an in-progress best-of-three series for one player.
31type match struct {
32 PlayerWins int
33 HouseWins int
34 Rounds int
35}
36
37// playerState is the persisted record for one address.
38type playerState struct {
39 Active *match
40 MatchWins int
41 MatchLosses int
42 MatchesPlayed int
43 RoundsPlayed int
44}
45
46var (
47 players avl.Tree // address string -> *playerState
48
49 nonce int
50 totalRounds int
51
52 champion address
53 championWins int
54)
55
56func moveName(m Move) string {
57 switch m {
58 case Rock:
59 return "rock"
60 case Paper:
61 return "paper"
62 case Scissors:
63 return "scissors"
64 default:
65 return "?"
66 }
67}
68
69func parseMove(s string) (Move, bool) {
70 switch strings.ToLower(strings.TrimSpace(s)) {
71 case "rock", "r":
72 return Rock, true
73 case "paper", "p":
74 return Paper, true
75 case "scissors", "s":
76 return Scissors, true
77 default:
78 return 0, false
79 }
80}
81
82// judge returns 0 for a tie, 1 if p beats h, 2 if h beats p.
83func judge(p, h Move) int {
84 return (int(p) - int(h) + 3) % 3
85}
86
87// pickHouseMove derives a deterministic pseudo-random move from the current
88// chain height, a monotonic per-realm nonce, and the caller's address, so
89// repeated throws in the same block still diverge.
90func pickHouseMove(caller address, n int) Move {
91 seed := runtime.ChainHeight() + int64(n)
92 s := caller.String()
93 for i := 0; i < len(s); i++ {
94 seed += int64(s[i])
95 }
96 if seed < 0 {
97 seed = -seed
98 }
99 return Move(seed % 3)
100}
101
102func getOrCreate(addr address) *playerState {
103 key := addr.String()
104 if v, ok := players.Get(key); ok {
105 return v.(*playerState)
106 }
107 ps := &playerState{}
108 players.Set(key, ps)
109 return ps
110}
111
112// Throw plays one round of the caller's current match, starting a fresh
113// match if none is in progress. Accepts "rock"/"paper"/"scissors" or the
114// single-letter shorthand "r"/"p"/"s".
115func Throw(cur realm, moveStr string) string {
116 if !cur.IsCurrent() {
117 panic("invalid realm")
118 }
119 caller := cur.Previous().Address()
120
121 playerMove, ok := parseMove(moveStr)
122 if !ok {
123 panic("invalid move: use rock, paper, or scissors (r/p/s)")
124 }
125
126 ps := getOrCreate(caller)
127 if ps.Active == nil {
128 ps.Active = &match{}
129 }
130 m := ps.Active
131
132 nonce++
133 houseMove := pickHouseMove(caller, nonce)
134 round := judge(playerMove, houseMove)
135
136 m.Rounds++
137 totalRounds++
138 ps.RoundsPlayed++
139
140 var roundMsg string
141 switch round {
142 case 1:
143 m.PlayerWins++
144 roundMsg = "you win the round"
145 case 2:
146 m.HouseWins++
147 roundMsg = "house wins the round"
148 default:
149 roundMsg = "round tied"
150 }
151
152 out := "round " + strconv.Itoa(m.Rounds) + ": you played " + moveName(playerMove) +
153 ", house played " + moveName(houseMove) + " -> " + roundMsg +
154 " (score " + strconv.Itoa(m.PlayerWins) + "-" + strconv.Itoa(m.HouseWins) + ")"
155
156 if m.PlayerWins >= winsNeeded || m.HouseWins >= winsNeeded {
157 ps.Active = nil
158 ps.MatchesPlayed++
159 playerTookMatch := m.PlayerWins > m.HouseWins
160 if playerTookMatch {
161 ps.MatchWins++
162 if ps.MatchWins > championWins {
163 championWins = ps.MatchWins
164 champion = caller
165 }
166 out += ". MATCH WON!"
167 } else {
168 ps.MatchLosses++
169 out += ". match lost."
170 }
171 chain.Emit("MatchFinished",
172 "player", caller.String(),
173 "playerWins", strconv.Itoa(m.PlayerWins),
174 "houseWins", strconv.Itoa(m.HouseWins),
175 )
176 }
177
178 chain.Emit("RoundPlayed",
179 "player", caller.String(),
180 "playerMove", moveName(playerMove),
181 "houseMove", moveName(houseMove),
182 "result", strconv.Itoa(round),
183 )
184
185 return out
186}
187
188func renderHome() string {
189 var b strings.Builder
190 b.WriteString("# Rock-Paper-Scissors Arena\n\n")
191 b.WriteString("Best-of-three matches against the house. First to " +
192 strconv.Itoa(winsNeeded) + " round wins takes the match.\n\n")
193 b.WriteString("- Total rounds played: " + strconv.Itoa(totalRounds) + "\n")
194
195 if champion.IsValid() {
196 b.WriteString("- Reigning champion: `" + champion.String() +
197 "` (" + strconv.Itoa(championWins) + " match wins)\n")
198 } else {
199 b.WriteString("- No champion yet — be the first to win a match.\n")
200 }
201
202 b.WriteString("\n## How to play\n\n")
203 b.WriteString("Call `Throw(\"rock\"|\"paper\"|\"scissors\")` (or `r`/`p`/`s`). " +
204 "Your throw starts a new match if you don't have one in progress, " +
205 "and each call plays one round of it.\n\n")
206 b.WriteString("View your own record at this realm's path plus your address, " +
207 "e.g. `.../rpsmatch:g1youraddress...`\n")
208 return b.String()
209}
210
211// escapeInline neutralizes markdown-active characters in untrusted text
212// before it's embedded inline in Render output.
213func escapeInline(s string) string {
214 r := strings.NewReplacer(
215 "\\", "\\\\",
216 "`", "\\`",
217 "*", "\\*",
218 "_", "\\_",
219 "[", "\\[",
220 "]", "\\]",
221 "|", "\\|",
222 )
223 return r.Replace(s)
224}
225
226func renderPlayer(rawAddr string) string {
227 addr := strings.TrimSpace(rawAddr)
228 safe := escapeInline(addr)
229
230 v, ok := players.Get(addr)
231 if !ok {
232 return "# Player " + safe + "\n\nNo recorded throws yet.\n"
233 }
234 ps := v.(*playerState)
235
236 var b strings.Builder
237 b.WriteString("# Player " + safe + "\n\n")
238 b.WriteString("- Matches won: " + strconv.Itoa(ps.MatchWins) + "\n")
239 b.WriteString("- Matches lost: " + strconv.Itoa(ps.MatchLosses) + "\n")
240 b.WriteString("- Matches played: " + strconv.Itoa(ps.MatchesPlayed) + "\n")
241 b.WriteString("- Rounds played: " + strconv.Itoa(ps.RoundsPlayed) + "\n")
242
243 if ps.Active != nil {
244 b.WriteString("\n**Match in progress:** " + strconv.Itoa(ps.Active.PlayerWins) +
245 "-" + strconv.Itoa(ps.Active.HouseWins) + " through " +
246 strconv.Itoa(ps.Active.Rounds) + " round(s).\n")
247 }
248 return b.String()
249}
250
251// Render shows the arena dashboard at "", or one player's record when path
252// is their bech32 address.
253func Render(path string) string {
254 path = strings.TrimPrefix(strings.TrimSpace(path), "/")
255 if path == "" {
256 return renderHome()
257 }
258 return renderPlayer(path)
259}