Search Apps Documentation Source Content File Folder Download Copy Actions Download

rpsmatch.gno

6.27 Kb · 259 lines
  1// Package rpsmatch is a best-of-three rock-paper-scissors arena against the
  2// house. Unlike a single stateless throw, a match persists across calls:
  3// the first player to win two rounds takes the match, and every player's
  4// match record accumulates on-chain.
  5package rpsmatch
  6
  7import (
  8	"strconv"
  9	"strings"
 10
 11	"chain"
 12	"chain/runtime"
 13
 14	"gno.land/p/nt/avl/v0"
 15)
 16
 17// Move is one of rock, paper, or scissors, ordered so that
 18// (winner - loser + 3) % 3 == 1 for every winning pair.
 19type Move int
 20
 21const (
 22	Rock Move = iota
 23	Paper
 24	Scissors
 25)
 26
 27// winsNeeded is the number of round wins required to take a match.
 28const winsNeeded = 2
 29
 30// match tracks an in-progress best-of-three series for one player.
 31type match struct {
 32	PlayerWins int
 33	HouseWins  int
 34	Rounds     int
 35}
 36
 37// playerState is the persisted record for one address.
 38type playerState struct {
 39	Active        *match
 40	MatchWins     int
 41	MatchLosses   int
 42	MatchesPlayed int
 43	RoundsPlayed  int
 44}
 45
 46var (
 47	players avl.Tree // address string -> *playerState
 48
 49	nonce       int
 50	totalRounds int
 51
 52	champion     address
 53	championWins int
 54)
 55
 56func moveName(m Move) string {
 57	switch m {
 58	case Rock:
 59		return "rock"
 60	case Paper:
 61		return "paper"
 62	case Scissors:
 63		return "scissors"
 64	default:
 65		return "?"
 66	}
 67}
 68
 69func parseMove(s string) (Move, bool) {
 70	switch strings.ToLower(strings.TrimSpace(s)) {
 71	case "rock", "r":
 72		return Rock, true
 73	case "paper", "p":
 74		return Paper, true
 75	case "scissors", "s":
 76		return Scissors, true
 77	default:
 78		return 0, false
 79	}
 80}
 81
 82// judge returns 0 for a tie, 1 if p beats h, 2 if h beats p.
 83func judge(p, h Move) int {
 84	return (int(p) - int(h) + 3) % 3
 85}
 86
 87// pickHouseMove derives a deterministic pseudo-random move from the current
 88// chain height, a monotonic per-realm nonce, and the caller's address, so
 89// repeated throws in the same block still diverge.
 90func pickHouseMove(caller address, n int) Move {
 91	seed := runtime.ChainHeight() + int64(n)
 92	s := caller.String()
 93	for i := 0; i < len(s); i++ {
 94		seed += int64(s[i])
 95	}
 96	if seed < 0 {
 97		seed = -seed
 98	}
 99	return Move(seed % 3)
100}
101
102func getOrCreate(addr address) *playerState {
103	key := addr.String()
104	if v, ok := players.Get(key); ok {
105		return v.(*playerState)
106	}
107	ps := &playerState{}
108	players.Set(key, ps)
109	return ps
110}
111
112// Throw plays one round of the caller's current match, starting a fresh
113// match if none is in progress. Accepts "rock"/"paper"/"scissors" or the
114// single-letter shorthand "r"/"p"/"s".
115func Throw(cur realm, moveStr string) string {
116	if !cur.IsCurrent() {
117		panic("invalid realm")
118	}
119	caller := cur.Previous().Address()
120
121	playerMove, ok := parseMove(moveStr)
122	if !ok {
123		panic("invalid move: use rock, paper, or scissors (r/p/s)")
124	}
125
126	ps := getOrCreate(caller)
127	if ps.Active == nil {
128		ps.Active = &match{}
129	}
130	m := ps.Active
131
132	nonce++
133	houseMove := pickHouseMove(caller, nonce)
134	round := judge(playerMove, houseMove)
135
136	m.Rounds++
137	totalRounds++
138	ps.RoundsPlayed++
139
140	var roundMsg string
141	switch round {
142	case 1:
143		m.PlayerWins++
144		roundMsg = "you win the round"
145	case 2:
146		m.HouseWins++
147		roundMsg = "house wins the round"
148	default:
149		roundMsg = "round tied"
150	}
151
152	out := "round " + strconv.Itoa(m.Rounds) + ": you played " + moveName(playerMove) +
153		", house played " + moveName(houseMove) + " -> " + roundMsg +
154		" (score " + strconv.Itoa(m.PlayerWins) + "-" + strconv.Itoa(m.HouseWins) + ")"
155
156	if m.PlayerWins >= winsNeeded || m.HouseWins >= winsNeeded {
157		ps.Active = nil
158		ps.MatchesPlayed++
159		playerTookMatch := m.PlayerWins > m.HouseWins
160		if playerTookMatch {
161			ps.MatchWins++
162			if ps.MatchWins > championWins {
163				championWins = ps.MatchWins
164				champion = caller
165			}
166			out += ". MATCH WON!"
167		} else {
168			ps.MatchLosses++
169			out += ". match lost."
170		}
171		chain.Emit("MatchFinished",
172			"player", caller.String(),
173			"playerWins", strconv.Itoa(m.PlayerWins),
174			"houseWins", strconv.Itoa(m.HouseWins),
175		)
176	}
177
178	chain.Emit("RoundPlayed",
179		"player", caller.String(),
180		"playerMove", moveName(playerMove),
181		"houseMove", moveName(houseMove),
182		"result", strconv.Itoa(round),
183	)
184
185	return out
186}
187
188func renderHome() string {
189	var b strings.Builder
190	b.WriteString("# Rock-Paper-Scissors Arena\n\n")
191	b.WriteString("Best-of-three matches against the house. First to " +
192		strconv.Itoa(winsNeeded) + " round wins takes the match.\n\n")
193	b.WriteString("- Total rounds played: " + strconv.Itoa(totalRounds) + "\n")
194
195	if champion.IsValid() {
196		b.WriteString("- Reigning champion: `" + champion.String() +
197			"` (" + strconv.Itoa(championWins) + " match wins)\n")
198	} else {
199		b.WriteString("- No champion yet — be the first to win a match.\n")
200	}
201
202	b.WriteString("\n## How to play\n\n")
203	b.WriteString("Call `Throw(\"rock\"|\"paper\"|\"scissors\")` (or `r`/`p`/`s`). " +
204		"Your throw starts a new match if you don't have one in progress, " +
205		"and each call plays one round of it.\n\n")
206	b.WriteString("View your own record at this realm's path plus your address, " +
207		"e.g. `.../rpsmatch:g1youraddress...`\n")
208	return b.String()
209}
210
211// escapeInline neutralizes markdown-active characters in untrusted text
212// before it's embedded inline in Render output.
213func escapeInline(s string) string {
214	r := strings.NewReplacer(
215		"\\", "\\\\",
216		"`", "\\`",
217		"*", "\\*",
218		"_", "\\_",
219		"[", "\\[",
220		"]", "\\]",
221		"|", "\\|",
222	)
223	return r.Replace(s)
224}
225
226func renderPlayer(rawAddr string) string {
227	addr := strings.TrimSpace(rawAddr)
228	safe := escapeInline(addr)
229
230	v, ok := players.Get(addr)
231	if !ok {
232		return "# Player " + safe + "\n\nNo recorded throws yet.\n"
233	}
234	ps := v.(*playerState)
235
236	var b strings.Builder
237	b.WriteString("# Player " + safe + "\n\n")
238	b.WriteString("- Matches won: " + strconv.Itoa(ps.MatchWins) + "\n")
239	b.WriteString("- Matches lost: " + strconv.Itoa(ps.MatchLosses) + "\n")
240	b.WriteString("- Matches played: " + strconv.Itoa(ps.MatchesPlayed) + "\n")
241	b.WriteString("- Rounds played: " + strconv.Itoa(ps.RoundsPlayed) + "\n")
242
243	if ps.Active != nil {
244		b.WriteString("\n**Match in progress:** " + strconv.Itoa(ps.Active.PlayerWins) +
245			"-" + strconv.Itoa(ps.Active.HouseWins) + " through " +
246			strconv.Itoa(ps.Active.Rounds) + " round(s).\n")
247	}
248	return b.String()
249}
250
251// Render shows the arena dashboard at "", or one player's record when path
252// is their bech32 address.
253func Render(path string) string {
254	path = strings.TrimPrefix(strings.TrimSpace(path), "/")
255	if path == "" {
256		return renderHome()
257	}
258	return renderPlayer(path)
259}