Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}