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.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}