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

rpsduel.gno

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