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}