Search Apps Documentation Source Content File Folder Download Copy Actions Download 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}