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

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}