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

rpsduel.gno

13.08 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/moul/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}