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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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 := players.Get(key); v != nil {
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 := players.Get(addr)
231	if v == nil {
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}