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