rpsoracle.gno
6.73 Kb · 257 lines
1// Package rpsoracle is a rock-paper-scissors opponent that doesn't roll
2// dice: it studies each player's move history and always throws the
3// counter to whichever move that player has favored most. Play a fixed
4// pattern and the oracle punishes it; play close to a uniform 1/3-1/3-1/3
5// mix and it can't out-guess you better than chance.
6package rpsoracle
7
8import (
9 "strconv"
10 "strings"
11
12 "chain"
13 "chain/runtime"
14
15 "gno.land/p/moul/kit/ui/v0"
16 "gno.land/p/nt/avl/v0"
17)
18
19// Move is one of rock, paper, or scissors, ordered so that
20// (winner - loser + 3) % 3 == 1 for every winning pair.
21type Move int
22
23const (
24 Rock Move = iota
25 Paper
26 Scissors
27)
28
29func moveName(m Move) string {
30 switch m {
31 case Rock:
32 return "rock"
33 case Paper:
34 return "paper"
35 case Scissors:
36 return "scissors"
37 default:
38 return "?"
39 }
40}
41
42func parseMove(s string) (Move, bool) {
43 switch strings.ToLower(strings.TrimSpace(s)) {
44 case "rock", "r":
45 return Rock, true
46 case "paper", "p":
47 return Paper, true
48 case "scissors", "s":
49 return Scissors, true
50 default:
51 return 0, false
52 }
53}
54
55// judge returns 0 for a tie, 1 if p beats h, 2 if h beats p.
56func judge(p, h Move) int {
57 return (int(p) - int(h) + 3) % 3
58}
59
60// beats returns the move that defeats m.
61func beats(m Move) Move {
62 return Move((int(m) + 1) % 3)
63}
64
65// playerState is the persisted record for one address.
66type playerState struct {
67 Counts [3]int // history tally per move, what the oracle predicts from
68 Rounds int
69 Wins int // player beat the oracle
70 Losses int // oracle beat the player
71 Draws int
72 BestStreak int
73 streak int // current player win streak against the oracle
74}
75
76var (
77 players avl.Tree // address string -> *playerState
78
79 nonce int
80 totalRounds int
81 oracleCorrect int // rounds the oracle won by successfully countering
82
83 topOutwitter address
84 topOutwitterWins int
85)
86
87func getOrCreate(addr address) *playerState {
88 key := addr.String()
89 if v := players.Get(key); v != nil {
90 return v.(*playerState)
91 }
92 ps := &playerState{}
93 players.Set(key, ps)
94 return ps
95}
96
97// predict guesses the player's next move as the most-played move in their
98// history so far. Ties (including a fresh player's all-zero history) fall
99// back to a chain-height-derived seed so the oracle doesn't always break
100// ties the same way.
101func predict(ps *playerState, seed int64) Move {
102 best := Rock
103 bestCount := ps.Counts[Rock]
104 tied := []Move{Rock}
105 for _, m := range []Move{Paper, Scissors} {
106 switch {
107 case ps.Counts[m] > bestCount:
108 bestCount = ps.Counts[m]
109 best = m
110 tied = []Move{m}
111 case ps.Counts[m] == bestCount:
112 tied = append(tied, m)
113 }
114 }
115 if len(tied) > 1 {
116 if seed < 0 {
117 seed = -seed
118 }
119 best = tied[int(seed)%len(tied)]
120 }
121 return best
122}
123
124// Play pits the caller against the oracle: it predicts your next move from
125// your own move history and throws the counter. Accepts
126// "rock"/"paper"/"scissors" or the single-letter shorthand "r"/"p"/"s".
127func Play(cur realm, moveStr string) string {
128 if !cur.IsCurrent() {
129 panic("invalid realm")
130 }
131 caller := cur.Previous().Address()
132
133 playerMove, ok := parseMove(moveStr)
134 if !ok {
135 panic("invalid move: use rock, paper, or scissors (r/p/s)")
136 }
137
138 ps := getOrCreate(caller)
139
140 nonce++
141 seed := runtime.ChainHeight() + int64(nonce)
142 predicted := predict(ps, seed)
143 oracleMove := beats(predicted)
144
145 result := judge(playerMove, oracleMove)
146
147 ps.Counts[playerMove]++
148 ps.Rounds++
149 totalRounds++
150
151 var msg string
152 switch result {
153 case 1:
154 ps.Wins++
155 ps.streak++
156 if ps.streak > ps.BestStreak {
157 ps.BestStreak = ps.streak
158 }
159 if ps.Wins > topOutwitterWins {
160 topOutwitterWins = ps.Wins
161 topOutwitter = caller
162 }
163 msg = "you outwitted the oracle!"
164 case 2:
165 ps.Losses++
166 ps.streak = 0
167 oracleCorrect++
168 msg = "the oracle read you like a book."
169 default:
170 ps.Draws++
171 ps.streak = 0
172 msg = "a draw — you and the oracle picked the same move."
173 }
174
175 chain.Emit("RoundPlayed",
176 "player", caller.String(),
177 "playerMove", moveName(playerMove),
178 "oraclePredicted", moveName(predicted),
179 "oracleMove", moveName(oracleMove),
180 "result", strconv.Itoa(result),
181 )
182
183 return "you played " + moveName(playerMove) + ", the oracle predicted " +
184 moveName(predicted) + " and threw " + moveName(oracleMove) + " -> " + msg
185}
186
187func renderHome() string {
188 var b strings.Builder
189 b.WriteString("# Rock-Paper-Scissors Oracle\n\n")
190 b.WriteString("An adaptive opponent: it doesn't roll dice, it studies you. ")
191 b.WriteString("Every throw is logged, and the oracle always counters whichever ")
192 b.WriteString("move you've played most often. Play a uniform mixed strategy and ")
193 b.WriteString("it can't out-guess you; fall into a habit and it will.\n\n")
194
195 b.WriteString("- Total rounds played: " + strconv.Itoa(totalRounds) + "\n")
196 if totalRounds > 0 {
197 pct := oracleCorrect * 100 / totalRounds
198 b.WriteString("- Oracle win rate: " + strconv.Itoa(pct) + "%\n")
199 }
200 if topOutwitter.IsValid() {
201 b.WriteString("- Top outwitter: `" + topOutwitter.String() + "` (" +
202 strconv.Itoa(topOutwitterWins) + " wins against the oracle)\n")
203 } else {
204 b.WriteString("- No one has beaten the oracle yet.\n")
205 }
206
207 b.WriteString("\n## How to play\n\n")
208 b.WriteString("Call `Play(\"rock\"|\"paper\"|\"scissors\")` (or `r`/`p`/`s`). ")
209 b.WriteString("View your own record at this realm's path plus your address, ")
210 b.WriteString("e.g. `.../rpsoracle:g1youraddress...`\n")
211 return b.String()
212}
213
214func renderPlayer(rawAddr string) string {
215 addr := strings.TrimSpace(rawAddr)
216 safe := ui.Inline(addr)
217
218 v := players.Get(addr)
219 if v == nil {
220 return "# Player " + safe + "\n\nNo recorded rounds yet.\n"
221 }
222 ps := v.(*playerState)
223
224 var b strings.Builder
225 b.WriteString("# Player " + safe + "\n\n")
226 b.WriteString("- Rounds played: " + strconv.Itoa(ps.Rounds) + "\n")
227 b.WriteString("- Beat the oracle: " + strconv.Itoa(ps.Wins) + "\n")
228 b.WriteString("- Lost to the oracle: " + strconv.Itoa(ps.Losses) + "\n")
229 b.WriteString("- Draws: " + strconv.Itoa(ps.Draws) + "\n")
230 b.WriteString("- Best win streak vs oracle: " + strconv.Itoa(ps.BestStreak) + "\n")
231 b.WriteString("- Move history — rock: " + strconv.Itoa(ps.Counts[Rock]) +
232 ", paper: " + strconv.Itoa(ps.Counts[Paper]) +
233 ", scissors: " + strconv.Itoa(ps.Counts[Scissors]) + "\n")
234
235 if ps.Rounds > 0 {
236 maxCount := ps.Counts[Rock]
237 for _, c := range ps.Counts[1:] {
238 if c > maxCount {
239 maxCount = c
240 }
241 }
242 predictability := maxCount * 100 / ps.Rounds
243 b.WriteString("- Predictability score: " + strconv.Itoa(predictability) +
244 "% (lower is harder for the oracle to read)\n")
245 }
246 return b.String()
247}
248
249// Render shows the oracle's dashboard at "", or one player's record when
250// path is their bech32 address.
251func Render(path string) string {
252 path = strings.TrimPrefix(strings.TrimSpace(path), "/")
253 if path == "" {
254 return renderHome()
255 }
256 return renderPlayer(path)
257}