rps.gno
5.46 Kb · 237 lines
1package rps
2
3import (
4 "chain"
5 "chain/runtime"
6 "chain/runtime/unsafe"
7 "strconv"
8 "strings"
9
10 "gno.land/p/moul/kit/ui/v0"
11 "gno.land/p/nt/avl/v0"
12)
13
14// Moves.
15const (
16 rock = 0
17 paper = 1
18 scissors = 2
19)
20
21var moveNames = [3]string{"rock", "paper", "scissors"}
22
23// Outcomes.
24const (
25 outLose = 0 // player loses
26 outDraw = 1
27 outWin = 2 // player wins
28)
29
30// tally holds a single player's cumulative record.
31type tally struct {
32 wins int
33 losses int
34 draws int
35 plays int // total rounds this player has played (drives entropy)
36}
37
38// round is one recorded game (for the recent-rounds table).
39type round struct {
40 player address
41 height int64
42 player_m int // player move
43 house_m int // house move
44 outcome int
45}
46
47// State.
48var (
49 players = avl.NewTree() // address string -> *tally
50 rounds []round // append-only history; Render shows the tail
51
52 // Global tally (caller-agnostic).
53 totalWins int // player wins
54 totalLosses int // player losses
55 totalDraws int
56 totalRounds int
57)
58
59const maxRecent = 8
60
61// parseChoice maps a choice string to a move index; ok=false if invalid.
62func parseChoice(choice string) (int, bool) {
63 switch strings.ToLower(strings.TrimSpace(choice)) {
64 case "rock", "r":
65 return rock, true
66 case "paper", "p":
67 return paper, true
68 case "scissors", "s":
69 return scissors, true
70 }
71 return 0, false
72}
73
74// houseMove derives the house move deterministically from the chain height
75// and the caller's play count (so repeated calls in the same block differ).
76func houseMove(height int64, playCount int) int {
77 x := height + int64(playCount)*7
78 m := x % 3
79 if m < 0 {
80 m += 3
81 }
82 return int(m)
83}
84
85// decide returns the outcome from the player's perspective.
86func decide(playerMove, houseM int) int {
87 if playerMove == houseM {
88 return outDraw
89 }
90 // player beats (player+1)%3 ... rock(0) beats scissors(2), etc.
91 if (playerMove+2)%3 == houseM {
92 return outWin
93 }
94 return outLose
95}
96
97func getTally(key string) *tally {
98 if v := players.Get(key); v != nil {
99 return v.(*tally)
100 }
101 return nil
102}
103
104// Play plays one round against the chain. choice is "rock"|"paper"|"scissors"
105// (single-letter shortcuts accepted). It panics on an invalid choice.
106func Play(cur realm, choice string) {
107 mv, ok := parseChoice(choice)
108 if !ok {
109 panic("invalid choice: use rock, paper, or scissors")
110 }
111
112 caller := unsafe.PreviousRealm().Address()
113 key := caller.String()
114
115 t := getTally(key)
116 if t == nil {
117 t = &tally{}
118 players.Set(key, t)
119 }
120
121 height := runtime.ChainHeight()
122 hm := houseMove(height, t.plays)
123 outcome := decide(mv, hm)
124
125 t.plays++
126 switch outcome {
127 case outWin:
128 t.wins++
129 totalWins++
130 case outLose:
131 t.losses++
132 totalLosses++
133 default:
134 t.draws++
135 totalDraws++
136 }
137 totalRounds++
138
139 rounds = append(rounds, round{
140 player: caller,
141 height: height,
142 player_m: mv,
143 house_m: hm,
144 outcome: outcome,
145 })
146
147 chain.Emit(
148 "RoundPlayed",
149 "player", key,
150 "choice", moveNames[mv],
151 "house", moveNames[hm],
152 "outcome", outcomeName(outcome),
153 )
154}
155
156func outcomeName(o int) string {
157 switch o {
158 case outWin:
159 return "win"
160 case outLose:
161 return "lose"
162 default:
163 return "draw"
164 }
165}
166
167func winRate(wins, plays int) string {
168 if plays == 0 {
169 return "0%"
170 }
171 return strconv.Itoa(wins*100/plays) + "%"
172}
173
174// Render returns a Markdown dashboard: global tally, per-player table, and the
175// last few rounds. It is caller-agnostic (no cur).
176func Render(path string) string {
177 var b strings.Builder
178
179 b.WriteString("# Rock-Paper-Scissors — play vs the chain\n\n")
180 b.WriteString("Call `Play(cur, \"rock\"|\"paper\"|\"scissors\")`. ")
181 b.WriteString("The house move is derived deterministically from the current block height ")
182 b.WriteString("and your personal play count.\n\n")
183
184 // Global tally.
185 b.WriteString("## Global tally\n\n")
186 b.WriteString("| Rounds | Player wins | Player losses | Draws | Player win rate |\n")
187 b.WriteString("|---|---|---|---|---|\n")
188 b.WriteString("| " + strconv.Itoa(totalRounds))
189 b.WriteString(" | " + strconv.Itoa(totalWins))
190 b.WriteString(" | " + strconv.Itoa(totalLosses))
191 b.WriteString(" | " + strconv.Itoa(totalDraws))
192 b.WriteString(" | " + winRate(totalWins, totalRounds) + " |\n\n")
193
194 // Per-player table (deterministic order via avl iteration).
195 b.WriteString("## Players\n\n")
196 if players.Size() == 0 {
197 b.WriteString("_No games played yet. Be the first!_\n\n")
198 } else {
199 b.WriteString("| Player | W | L | D | Rounds | Win rate |\n")
200 b.WriteString("|---|---|---|---|---|---|\n")
201 players.Iterate("", "", func(key string, v interface{}) bool {
202 t := v.(*tally)
203 b.WriteString("| " + ui.AddrOf(key))
204 b.WriteString(" | " + strconv.Itoa(t.wins))
205 b.WriteString(" | " + strconv.Itoa(t.losses))
206 b.WriteString(" | " + strconv.Itoa(t.draws))
207 b.WriteString(" | " + strconv.Itoa(t.plays))
208 b.WriteString(" | " + winRate(t.wins, t.plays) + " |\n")
209 return false
210 })
211 b.WriteString("\n")
212 }
213
214 // Recent rounds (tail).
215 b.WriteString("## Recent rounds\n\n")
216 if len(rounds) == 0 {
217 b.WriteString("_None yet._\n")
218 } else {
219 b.WriteString("| Height | Player | Choice | House | Result |\n")
220 b.WriteString("|---|---|---|---|---|\n")
221 start := len(rounds) - maxRecent
222 if start < 0 {
223 start = 0
224 }
225 // Show newest first.
226 for i := len(rounds) - 1; i >= start; i-- {
227 r := rounds[i]
228 b.WriteString("| " + strconv.FormatInt(r.height, 10))
229 b.WriteString(" | " + ui.Addr(r.player))
230 b.WriteString(" | " + moveNames[r.player_m])
231 b.WriteString(" | " + moveNames[r.house_m])
232 b.WriteString(" | " + outcomeName(r.outcome) + " |\n")
233 }
234 }
235
236 return b.String()
237}