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.

rpsduel.gno

13.09 Kb · 489 lines
  1// Package rpsduel is an asynchronous, two-player rock-paper-scissors duel
  2// with a commit-reveal handshake. The challenger locks in a move as a
  3// sha256 commitment up front so the opponent can't peek at it; the
  4// opponent then replies in the clear (seeing only a hash gives them no
  5// edge); the challenger reveals last to settle the round. A challenger who
  6// tries to dodge a losing reveal can be forfeited by the opponent once the
  7// reveal window expires.
  8package rpsduel
  9
 10import (
 11	"crypto/sha256"
 12	"encoding/hex"
 13	"strconv"
 14	"strings"
 15
 16	"chain"
 17	"chain/runtime"
 18
 19	"gno.land/p/nym-moultest123/kit/ui/v0"
 20	"gno.land/p/nt/avl/v0"
 21)
 22
 23// Move is one of rock, paper, or scissors, ordered so that
 24// (winner - loser + 3) % 3 == 1 for every winning pair.
 25type Move int
 26
 27const (
 28	Rock Move = iota
 29	Paper
 30	Scissors
 31)
 32
 33func moveName(m Move) string {
 34	switch m {
 35	case Rock:
 36		return "rock"
 37	case Paper:
 38		return "paper"
 39	case Scissors:
 40		return "scissors"
 41	default:
 42		return "?"
 43	}
 44}
 45
 46func parseMove(s string) (Move, bool) {
 47	switch strings.ToLower(strings.TrimSpace(s)) {
 48	case "rock", "r":
 49		return Rock, true
 50	case "paper", "p":
 51		return Paper, true
 52	case "scissors", "s":
 53		return Scissors, true
 54	default:
 55		return 0, false
 56	}
 57}
 58
 59// judge returns 0 for a tie, 1 if p beats h, 2 if h beats p.
 60func judge(p, h Move) int {
 61	return (int(p) - int(h) + 3) % 3
 62}
 63
 64// revealWindow is how many blocks the challenger gets to reveal after the
 65// opponent accepts before the opponent can claim a forfeit win.
 66const revealWindow int64 = 50
 67
 68type duelStatus int
 69
 70const (
 71	statusPending duelStatus = iota
 72	statusAwaitingReveal
 73	statusResolved
 74	statusForfeited
 75	statusCancelled
 76)
 77
 78func (s duelStatus) String() string {
 79	switch s {
 80	case statusPending:
 81		return "pending"
 82	case statusAwaitingReveal:
 83		return "awaiting reveal"
 84	case statusResolved:
 85		return "resolved"
 86	case statusForfeited:
 87		return "forfeited"
 88	case statusCancelled:
 89		return "cancelled"
 90	default:
 91		return "?"
 92	}
 93}
 94
 95// duel is one asynchronous round between Challenger and Opponent. The
 96// challenger's move is hidden behind Commit until Reveal; the opponent's
 97// move is stored in the clear once they Accept.
 98type duel struct {
 99	ID             string
100	Challenger     address
101	Opponent       address
102	Commit         string // hex sha256 of "<move>:<salt>"
103	OpponentMove   Move
104	Status         duelStatus
105	Result         string // "challenger" | "opponent" | "draw" once settled
106	Winner         address
107	OpenedAt       int64
108	AcceptedAt     int64
109	RevealDeadline int64
110}
111
112// playerState is the persisted record for one address.
113type playerState struct {
114	Wins     int
115	Losses   int
116	Draws    int
117	Forfeits int // times this player, as challenger, failed to reveal in time
118	Duels    int
119}
120
121var (
122	duels   avl.Tree // duel ID -> *duel
123	players avl.Tree // address string -> *playerState
124
125	nextID        int
126	totalResolved int
127
128	champion     address
129	championWins int
130)
131
132// ComputeCommit hashes a move and salt exactly the way Reveal checks it, so
133// a caller can compute their commitment (e.g. via a read-only query) before
134// calling Open, then remember the salt to pass to Reveal later.
135func ComputeCommit(moveStr, salt string) string {
136	m, ok := parseMove(moveStr)
137	if !ok {
138		panic("invalid move: use rock, paper, or scissors (r/p/s)")
139	}
140	sum := sha256.Sum256([]byte(moveName(m) + ":" + salt))
141	return hex.EncodeToString(sum[:])
142}
143
144func getOrCreate(addr address) *playerState {
145	key := addr.String()
146	if v := players.Get(key); v != nil {
147		return v.(*playerState)
148	}
149	ps := &playerState{}
150	players.Set(key, ps)
151	return ps
152}
153
154func bumpChampion(addr address, wins int) {
155	if wins > championWins {
156		championWins = wins
157		champion = addr
158	}
159}
160
161// settle scores a revealed round and updates both players' records.
162func settle(d *duel, challengerMove Move, result int) {
163	cs := getOrCreate(d.Challenger)
164	os := getOrCreate(d.Opponent)
165	cs.Duels++
166	os.Duels++
167
168	switch result {
169	case 1:
170		cs.Wins++
171		os.Losses++
172		d.Winner = d.Challenger
173		d.Result = "challenger"
174		bumpChampion(d.Challenger, cs.Wins)
175	case 2:
176		os.Wins++
177		cs.Losses++
178		d.Winner = d.Opponent
179		d.Result = "opponent"
180		bumpChampion(d.Opponent, os.Wins)
181	default:
182		cs.Draws++
183		os.Draws++
184		d.Result = "draw"
185	}
186	d.Status = statusResolved
187	totalResolved++
188}
189
190// Open challenges opponentAddr to a duel, locking in the challenger's move
191// as a commitment (see ComputeCommit) so the opponent can't see it before
192// replying.
193func Open(cur realm, opponentAddr string, commitHex string) string {
194	challenger := cur.Previous().Address()
195
196	opponent := address(strings.TrimSpace(opponentAddr))
197	if !opponent.IsValid() {
198		panic("invalid opponent address")
199	}
200	if opponent == challenger {
201		panic("cannot duel yourself")
202	}
203
204	commit := strings.ToLower(strings.TrimSpace(commitHex))
205	if len(commit) != sha256.Size*2 {
206		panic("commit must be a 64-character hex sha256 digest; build it with ComputeCommit")
207	}
208	if _, err := hex.DecodeString(commit); err != nil {
209		panic("commit must be valid hex")
210	}
211
212	nextID++
213	id := strconv.Itoa(nextID)
214	d := &duel{
215		ID:         id,
216		Challenger: challenger,
217		Opponent:   opponent,
218		Commit:     commit,
219		Status:     statusPending,
220		OpenedAt:   runtime.ChainHeight(),
221	}
222	duels.Set(id, d)
223
224	chain.Emit("DuelOpened",
225		"id", id,
226		"challenger", challenger.String(),
227		"opponent", opponent.String(),
228	)
229
230	return "duel #" + id + " opened against " + opponent.String() + " -- waiting for them to Accept"
231}
232
233// Accept replies to a pending duel with a plain move. Only the challenged
234// opponent may call it; going second behind the challenger's hidden
235// commitment is what keeps this fair.
236func Accept(cur realm, id string, moveStr string) string {
237	caller := cur.Previous().Address()
238
239	v := duels.Get(id)
240	if v == nil {
241		panic("no such duel")
242	}
243	d := v.(*duel)
244
245	if d.Status != statusPending {
246		panic("duel is " + d.Status.String() + ", not pending")
247	}
248	if caller != d.Opponent {
249		panic("only the challenged opponent can accept this duel")
250	}
251
252	m, ok := parseMove(moveStr)
253	if !ok {
254		panic("invalid move: use rock, paper, or scissors (r/p/s)")
255	}
256
257	d.OpponentMove = m
258	d.Status = statusAwaitingReveal
259	d.AcceptedAt = runtime.ChainHeight()
260	d.RevealDeadline = d.AcceptedAt + revealWindow
261
262	chain.Emit("DuelAccepted", "id", id, "opponentMove", moveName(m))
263
264	return "you played " + moveName(m) + " in duel #" + id +
265		" -- waiting for " + d.Challenger.String() + " to reveal by block " +
266		strconv.Itoa(int(d.RevealDeadline))
267}
268
269// Reveal settles an accepted duel. Only the original challenger may call
270// it, and only with the exact move+salt that produced their commitment.
271func Reveal(cur realm, id string, moveStr string, salt string) string {
272	caller := cur.Previous().Address()
273
274	v := duels.Get(id)
275	if v == nil {
276		panic("no such duel")
277	}
278	d := v.(*duel)
279
280	if d.Status != statusAwaitingReveal {
281		panic("duel is " + d.Status.String() + ", not awaiting reveal")
282	}
283	if caller != d.Challenger {
284		panic("only the challenger can reveal")
285	}
286
287	m, ok := parseMove(moveStr)
288	if !ok {
289		panic("invalid move: use rock, paper, or scissors (r/p/s)")
290	}
291	sum := sha256.Sum256([]byte(moveName(m) + ":" + salt))
292	if hex.EncodeToString(sum[:]) != d.Commit {
293		panic("revealed move+salt doesn't match your original commitment")
294	}
295
296	result := judge(m, d.OpponentMove)
297	settle(d, m, result)
298
299	chain.Emit("DuelResolved",
300		"id", id,
301		"result", d.Result,
302		"challengerMove", moveName(m),
303		"opponentMove", moveName(d.OpponentMove),
304	)
305
306	return "you revealed " + moveName(m) + " vs " + moveName(d.OpponentMove) + " -- " + outcomeMsg(d)
307}
308
309func outcomeMsg(d *duel) string {
310	switch d.Result {
311	case "challenger":
312		return "you win duel #" + d.ID + "!"
313	case "opponent":
314		return "you lose duel #" + d.ID + " -- " + d.Opponent.String() + " wins."
315	default:
316		return "draw."
317	}
318}
319
320// ClaimForfeit lets the opponent collect a default win when the challenger
321// dodges revealing (e.g. because they saw the opponent's move and knew
322// they'd lose) past the reveal window.
323func ClaimForfeit(cur realm, id string) string {
324	caller := cur.Previous().Address()
325
326	v := duels.Get(id)
327	if v == nil {
328		panic("no such duel")
329	}
330	d := v.(*duel)
331
332	if d.Status != statusAwaitingReveal {
333		panic("duel is " + d.Status.String() + ", not awaiting reveal")
334	}
335	if caller != d.Opponent {
336		panic("only the waiting opponent can claim a forfeit")
337	}
338	if runtime.ChainHeight() <= d.RevealDeadline {
339		panic("reveal window hasn't expired yet")
340	}
341
342	d.Status = statusForfeited
343	d.Winner = d.Opponent
344	d.Result = "opponent"
345
346	cs := getOrCreate(d.Challenger)
347	cs.Duels++
348	cs.Losses++
349	cs.Forfeits++
350	os := getOrCreate(d.Opponent)
351	os.Duels++
352	os.Wins++
353	totalResolved++
354	bumpChampion(d.Opponent, os.Wins)
355
356	chain.Emit("DuelForfeited", "id", id, "winner", d.Opponent.String())
357
358	return "duel #" + id + " forfeited -- " + d.Challenger.String() + " never revealed"
359}
360
361// Cancel withdraws a still-pending duel. Only the challenger may cancel,
362// and only before the opponent accepts.
363func Cancel(cur realm, id string) string {
364	caller := cur.Previous().Address()
365
366	v := duels.Get(id)
367	if v == nil {
368		panic("no such duel")
369	}
370	d := v.(*duel)
371
372	if d.Status != statusPending {
373		panic("duel is " + d.Status.String() + ", not pending")
374	}
375	if caller != d.Challenger {
376		panic("only the challenger can cancel this duel")
377	}
378
379	d.Status = statusCancelled
380	return "duel #" + id + " cancelled"
381}
382
383func renderHome() string {
384	var b strings.Builder
385	b.WriteString("# Rock-Paper-Scissors Duel\n\n")
386	b.WriteString("Challenge another address to rock-paper-scissors, async and " +
387		"fair: you commit your move as a hash, they reply in the open, then you " +
388		"reveal to settle it. Stall on revealing a losing round and your opponent " +
389		"can claim a forfeit win once the window expires.\n\n")
390
391	b.WriteString("- Total duels resolved: " + strconv.Itoa(totalResolved) + "\n")
392	if champion.IsValid() {
393		b.WriteString("- Reigning champion: `" + champion.String() + "` (" +
394			strconv.Itoa(championWins) + " wins)\n")
395	} else {
396		b.WriteString("- No champion yet -- be the first to win a duel.\n")
397	}
398
399	b.WriteString("\n## How to play\n\n")
400	b.WriteString("1. Pick a move and a random salt, e.g. `rock` + `xyz123`.\n")
401	b.WriteString("2. Compute your commitment: `ComputeCommit(\"rock\", \"xyz123\")` (read-only call).\n")
402	b.WriteString("3. `Open(opponentAddr, commit)` -- opens the duel, returns its ID.\n")
403	b.WriteString("4. The opponent calls `Accept(id, \"paper\"|\"scissors\"|\"rock\")`.\n")
404	b.WriteString("5. You call `Reveal(id, \"rock\", \"xyz123\")` -- must match your commitment exactly.\n")
405	b.WriteString("6. If you never reveal, the opponent can call `ClaimForfeit(id)` after " +
406		strconv.Itoa(int(revealWindow)) + " blocks.\n\n")
407	b.WriteString("View a duel at this realm's path plus its ID (e.g. `.../rpsduel:3`), " +
408		"or a player's record plus their address (e.g. `.../rpsduel:g1youraddress...`).\n\n")
409
410	b.WriteString("## Open duels\n\n")
411	open := 0
412	duels.Iterate("", "", func(key string, value interface{}) bool {
413		d := value.(*duel)
414		if d.Status == statusPending {
415			open++
416			b.WriteString("- #" + d.ID + ": `" + d.Challenger.String() +
417				"` waiting on `" + d.Opponent.String() + "` to Accept\n")
418		} else if d.Status == statusAwaitingReveal {
419			open++
420			b.WriteString("- #" + d.ID + ": `" + d.Challenger.String() +
421				"` must Reveal by block " + strconv.Itoa(int(d.RevealDeadline)) + "\n")
422		}
423		return false
424	})
425	if open == 0 {
426		b.WriteString("_none right now_\n")
427	}
428
429	return b.String()
430}
431
432func renderDuel(id string) string {
433	v := duels.Get(id)
434	if v == nil {
435		return "# Duel #" + ui.Inline(id) + "\n\nNo such duel.\n"
436	}
437	d := v.(*duel)
438
439	var b strings.Builder
440	b.WriteString("# Duel #" + d.ID + "\n\n")
441	b.WriteString("- Challenger: `" + d.Challenger.String() + "`\n")
442	b.WriteString("- Opponent: `" + d.Opponent.String() + "`\n")
443	b.WriteString("- Status: " + d.Status.String() + "\n")
444
445	if d.Status == statusAwaitingReveal {
446		b.WriteString("- Opponent played: **" + moveName(d.OpponentMove) + "**\n")
447		b.WriteString("- Reveal deadline: block " + strconv.Itoa(int(d.RevealDeadline)) + "\n")
448	}
449	if d.Status == statusResolved || d.Status == statusForfeited {
450		b.WriteString("- Result: " + d.Result + "\n")
451		b.WriteString("- Winner: `" + d.Winner.String() + "`\n")
452	}
453	return b.String()
454}
455
456func renderPlayer(rawAddr string) string {
457	addr := strings.TrimSpace(rawAddr)
458	safe := ui.Inline(addr)
459
460	v := players.Get(addr)
461	if v == nil {
462		return "# Player " + safe + "\n\nNo recorded duels yet.\n"
463	}
464	ps := v.(*playerState)
465
466	var b strings.Builder
467	b.WriteString("# Player " + safe + "\n\n")
468	b.WriteString("- Wins: " + strconv.Itoa(ps.Wins) + "\n")
469	b.WriteString("- Losses: " + strconv.Itoa(ps.Losses) + "\n")
470	b.WriteString("- Draws: " + strconv.Itoa(ps.Draws) + "\n")
471	b.WriteString("- Duels played: " + strconv.Itoa(ps.Duels) + "\n")
472	if ps.Forfeits > 0 {
473		b.WriteString("- Forfeited (didn't reveal in time): " + strconv.Itoa(ps.Forfeits) + "\n")
474	}
475	return b.String()
476}
477
478// Render shows the duel lobby at "", a specific duel when path is a
479// numeric ID, or one player's record when path is their bech32 address.
480func Render(path string) string {
481	path = strings.TrimPrefix(strings.TrimSpace(path), "/")
482	if path == "" {
483		return renderHome()
484	}
485	if _, err := strconv.Atoi(path); err == nil {
486		return renderDuel(path)
487	}
488	return renderPlayer(path)
489}