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

rpsmatch.gno

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