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}