1// Package rpsduel is an asynchronous, two-player rock-paper-scissors duel2// with a commit-reveal handshake. The challenger locks in a move as a3// sha256 commitment up front so the opponent can't peek at it; the4// opponent then replies in the clear (seeing only a hash gives them no5// edge); the challenger reveals last to settle the round. A challenger who6// tries to dodge a losing reveal can be forfeited by the opponent once the7// reveal window expires.8package rpsduel910import (11 "crypto/sha256"12 "encoding/hex"13 "strconv"14 "strings"1516 "chain"17 "chain/runtime"1819 "gno.land/p/nt/avl/v0"20)2122// Move is one of rock, paper, or scissors, ordered so that23// (winner - loser + 3) % 3 == 1 for every winning pair.24type Move int2526const (27 Rock Move = iota28 Paper29 Scissors30)3132func 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}4445func parseMove(s string) (Move, bool) {46 switch strings.ToLower(strings.TrimSpace(s)) {47 case "rock", "r":48 return Rock, true49 case "paper", "p":50 return Paper, true51 case "scissors", "s":52 return Scissors, true53 default:54 return 0, false55 }56}5758// 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) % 361}6263// revealWindow is how many blocks the challenger gets to reveal after the64// opponent accepts before the opponent can claim a forfeit win.65const revealWindow int64 = 506667type duelStatus int6869const (70 statusPending duelStatus = iota71 statusAwaitingReveal72 statusResolved73 statusForfeited74 statusCancelled75)7677func (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}9394// duel is one asynchronous round between Challenger and Opponent. The95// challenger's move is hidden behind Commit until Reveal; the opponent's96// move is stored in the clear once they Accept.97type duel struct {98 ID string99 Challenger address100 Opponent address101 Commit string // hex sha256 of "<move>:<salt>"102 OpponentMove Move103 Status duelStatus104 Result string // "challenger" | "opponent" | "draw" once settled105 Winner address106 OpenedAt int64107 AcceptedAt int64108 RevealDeadline int64109}110111// playerState is the persisted record for one address.112type playerState struct {113 Wins int114 Losses int115 Draws int116 Forfeits int // times this player, as challenger, failed to reveal in time117 Duels int118}119120var (121 duels avl.Tree // duel ID -> *duel122 players avl.Tree // address string -> *playerState123124 nextID int125 totalResolved int126127 champion address128 championWins int129)130131// ComputeCommit hashes a move and salt exactly the way Reveal checks it, so132// a caller can compute their commitment (e.g. via a read-only query) before133// 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}142143func 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 ps151}152153func bumpChampion(addr address, wins int) {154 if wins > championWins {155 championWins = wins156 champion = addr157 }158}159160// 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++166167 switch result {168 case 1:169 cs.Wins++170 os.Losses++171 d.Winner = d.Challenger172 d.Result = "challenger"173 bumpChampion(d.Challenger, cs.Wins)174 case 2:175 os.Wins++176 cs.Losses++177 d.Winner = d.Opponent178 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 = statusResolved186 totalResolved++187}188189// Open challenges opponentAddr to a duel, locking in the challenger's move190// as a commitment (see ComputeCommit) so the opponent can't see it before191// replying.192func Open(cur realm, opponentAddr string, commitHex string) string {193 challenger := cur.Previous().Address()194195 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 }202203 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 }210211 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)222223 chain.Emit("DuelOpened",224 "id", id,225 "challenger", challenger.String(),226 "opponent", opponent.String(),227 )228229 return "duel #" + id + " opened against " + opponent.String() + " -- waiting for them to Accept"230}231232// Accept replies to a pending duel with a plain move. Only the challenged233// opponent may call it; going second behind the challenger's hidden234// commitment is what keeps this fair.235func Accept(cur realm, id string, moveStr string) string {236 caller := cur.Previous().Address()237238 v := duels.Get(id)239 if v == nil {240 panic("no such duel")241 }242 d := v.(*duel)243244 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 }250251 m, ok := parseMove(moveStr)252 if !ok {253 panic("invalid move: use rock, paper, or scissors (r/p/s)")254 }255256 d.OpponentMove = m257 d.Status = statusAwaitingReveal258 d.AcceptedAt = runtime.ChainHeight()259 d.RevealDeadline = d.AcceptedAt + revealWindow260261 chain.Emit("DuelAccepted", "id", id, "opponentMove", moveName(m))262263 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}267268// Reveal settles an accepted duel. Only the original challenger may call269// 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()272273 v := duels.Get(id)274 if v == nil {275 panic("no such duel")276 }277 d := v.(*duel)278279 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 }285286 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 }294295 result := judge(m, d.OpponentMove)296 settle(d, m, result)297298 chain.Emit("DuelResolved",299 "id", id,300 "result", d.Result,301 "challengerMove", moveName(m),302 "opponentMove", moveName(d.OpponentMove),303 )304305 return "you revealed " + moveName(m) + " vs " + moveName(d.OpponentMove) + " -- " + outcomeMsg(d)306}307308func 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}318319// ClaimForfeit lets the opponent collect a default win when the challenger320// dodges revealing (e.g. because they saw the opponent's move and knew321// they'd lose) past the reveal window.322func ClaimForfeit(cur realm, id string) string {323 caller := cur.Previous().Address()324325 v := duels.Get(id)326 if v == nil {327 panic("no such duel")328 }329 d := v.(*duel)330331 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 }340341 d.Status = statusForfeited342 d.Winner = d.Opponent343 d.Result = "opponent"344345 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)354355 chain.Emit("DuelForfeited", "id", id, "winner", d.Opponent.String())356357 return "duel #" + id + " forfeited -- " + d.Challenger.String() + " never revealed"358}359360// 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()364365 v := duels.Get(id)366 if v == nil {367 panic("no such duel")368 }369 d := v.(*duel)370371 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 }377378 d.Status = statusCancelled379 return "duel #" + id + " cancelled"380}381382// escapeInline neutralizes markdown-active characters in untrusted text383// 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}396397func renderHome() string {398 var b strings.Builder399 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")404405 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 }412413 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")423424 b.WriteString("## Open duels\n\n")425 open := 0426 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 false438 })439 if open == 0 {440 b.WriteString("_none right now_\n")441 }442443 return b.String()444}445446func 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)452453 var b strings.Builder454 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")458459 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}469470func renderPlayer(rawAddr string) string {471 addr := strings.TrimSpace(rawAddr)472 safe := escapeInline(addr)473474 v := players.Get(addr)475 if v == nil {476 return "# Player " + safe + "\n\nNo recorded duels yet.\n"477 }478 ps := v.(*playerState)479480 var b strings.Builder481 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}491492// Render shows the duel lobby at "", a specific duel when path is a493// 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}504Accept(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id string, moveStr string) string
Cancel(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id string) string
ClaimForfeit(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id string) string
ComputeCommit(moveStr string, salt string) string
Open(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, opponentAddr string, commitHex string) string
Render(path string) string
Reveal(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id string, moveStr string, salt string) string
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.