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