1// Package coinflipduel is a one-on-one coin-flip duel: challenge another2// address, they call heads or tails, and the block settles it. No stakes,3// just bragging rights tracked in an on-chain win/loss record.4package coinflipduel56import (7 "strconv"8 "strings"910 "chain"11 "chain/runtime"1213 "gno.land/p/moul/kit/ui/v0"14 "gno.land/p/nt/avl/v0"15)1617// duelStatus tracks the lifecycle of a duel.18type duelStatus int1920const (21 statusPending duelStatus = iota22 statusResolved23 statusCancelled24)2526func (s duelStatus) String() string {27 switch s {28 case statusPending:29 return "pending"30 case statusResolved:31 return "resolved"32 case statusCancelled:33 return "cancelled"34 default:35 return "?"36 }37}3839// duel is one challenge from Challenger to Opponent over a single coin flip.40// The Challenger calls a side up front; the Opponent's only move is to41// accept, which triggers the flip.42type duel struct {43 ID string44 Challenger address45 Opponent address46 Call string // "heads" or "tails" — the challenger's pick47 Status duelStatus48 Result string // "heads" or "tails" once resolved49 Winner address50 CreatedAt int6451 ResolvedAt int6452}5354// playerState is the persisted win/loss record for one address.55type playerState struct {56 Wins int57 Losses int58 Duels int59}6061var (62 duels avl.Tree // duel ID -> *duel63 players avl.Tree // address string -> *playerState6465 nextID int66 flipNonce int67 totalDone int6869 champion address70 championWins int71)7273func parseCall(s string) (string, bool) {74 switch strings.ToLower(strings.TrimSpace(s)) {75 case "heads", "h":76 return "heads", true77 case "tails", "t":78 return "tails", true79 default:80 return "", false81 }82}8384// flipCoin derives a deterministic pseudo-random side from the current chain85// height, a monotonic per-realm nonce, and both duelists' addresses, so two86// duels resolved in the same block still diverge.87func flipCoin(d *duel) string {88 flipNonce++89 seed := runtime.ChainHeight() + int64(flipNonce)90 s := d.Challenger.String() + d.Opponent.String() + d.ID91 for i := 0; i < len(s); i++ {92 seed += int64(s[i])93 }94 if seed < 0 {95 seed = -seed96 }97 if seed%2 == 0 {98 return "heads"99 }100 return "tails"101}102103func getOrCreate(addr address) *playerState {104 key := addr.String()105 if v := players.Get(key); v != nil {106 return v.(*playerState)107 }108 ps := &playerState{}109 players.Set(key, ps)110 return ps111}112113func recordResult(winner, loser address) {114 w := getOrCreate(winner)115 w.Wins++116 w.Duels++117 l := getOrCreate(loser)118 l.Losses++119 l.Duels++120121 if w.Wins > championWins {122 championWins = w.Wins123 champion = winner124 }125}126127// Challenge opens a new duel against opponentAddr, locking in the128// challenger's call of "heads"/"tails" ("h"/"t" also accepted). The129// opponent settles it by calling Accept with the returned duel ID.130func Challenge(cur realm, opponentAddr string, call string) string {131 challenger := cur.Previous().Address()132133 opponent := address(strings.TrimSpace(opponentAddr))134 if !opponent.IsValid() {135 panic("invalid opponent address")136 }137 if opponent == challenger {138 panic("cannot duel yourself")139 }140141 pick, ok := parseCall(call)142 if !ok {143 panic("call must be heads or tails (h/t)")144 }145146 nextID++147 id := strconv.Itoa(nextID)148 d := &duel{149 ID: id,150 Challenger: challenger,151 Opponent: opponent,152 Call: pick,153 Status: statusPending,154 CreatedAt: runtime.ChainHeight(),155 }156 duels.Set(id, d)157158 chain.Emit("DuelChallenged",159 "id", id,160 "challenger", challenger.String(),161 "opponent", opponent.String(),162 "call", pick,163 )164165 return "duel #" + id + " created: you called " + pick +166 ", waiting for " + opponent.String() + " to accept"167}168169// Accept settles a pending duel. Only the challenged opponent may call it;170// the flip happens immediately and the result is final.171func Accept(cur realm, id string) string {172 caller := cur.Previous().Address()173174 v := duels.Get(id)175 if v == nil {176 panic("no such duel")177 }178 d := v.(*duel)179180 if d.Status != statusPending {181 panic("duel already " + d.Status.String())182 }183 if caller != d.Opponent {184 panic("only the challenged opponent can accept this duel")185 }186187 result := flipCoin(d)188 d.Result = result189 d.Status = statusResolved190 d.ResolvedAt = runtime.ChainHeight()191192 if result == d.Call {193 d.Winner = d.Challenger194 recordResult(d.Challenger, d.Opponent)195 } else {196 d.Winner = d.Opponent197 recordResult(d.Opponent, d.Challenger)198 }199200 totalDone++201202 chain.Emit("DuelResolved",203 "id", id,204 "result", result,205 "winner", d.Winner.String(),206 )207208 return "the coin landed on " + result + " -- " + d.Winner.String() + " wins duel #" + id209}210211// Cancel withdraws a still-pending duel. Only the challenger may cancel,212// and only before the opponent accepts.213func Cancel(cur realm, id string) string {214 caller := cur.Previous().Address()215216 v := duels.Get(id)217 if v == nil {218 panic("no such duel")219 }220 d := v.(*duel)221222 if d.Status != statusPending {223 panic("duel already " + d.Status.String())224 }225 if caller != d.Challenger {226 panic("only the challenger can cancel this duel")227 }228229 d.Status = statusCancelled230 return "duel #" + id + " cancelled"231}232233func renderHome() string {234 var b strings.Builder235 b.WriteString("# Coin-Flip Duel\n\n")236 b.WriteString("Challenge another address to a coin flip. You call heads or tails " +237 "up front; they accept and the block decides. No stakes -- just a record.\n\n")238 b.WriteString("- Total duels resolved: " + strconv.Itoa(totalDone) + "\n")239240 if champion.IsValid() {241 b.WriteString("- Reigning champion: `" + champion.String() +242 "` (" + strconv.Itoa(championWins) + " wins)\n")243 } else {244 b.WriteString("- No champion yet -- be the first to win a duel.\n")245 }246247 b.WriteString("\n## How to play\n\n")248 b.WriteString("1. `Challenge(opponentAddr, \"heads\"|\"tails\")` -- opens a duel, returns its ID.\n")249 b.WriteString("2. The opponent calls `Accept(id)` -- flips the coin and settles it on the spot.\n")250 b.WriteString("3. The challenger may `Cancel(id)` while it's still pending.\n\n")251 b.WriteString("View a duel at this realm's path plus its ID (e.g. `.../coinflipduel:3`), " +252 "or a player's record plus their address (e.g. `.../coinflipduel:g1youraddress...`).\n\n")253254 b.WriteString("## Pending duels\n\n")255 pending := 0256 duels.Iterate("", "", func(key string, value interface{}) bool {257 d := value.(*duel)258 if d.Status == statusPending {259 pending++260 b.WriteString("- #" + d.ID + ": `" + d.Challenger.String() + "` called " +261 d.Call + ", waiting on `" + d.Opponent.String() + "`\n")262 }263 return false264 })265 if pending == 0 {266 b.WriteString("_none right now_\n")267 }268269 return b.String()270}271272func renderDuel(id string) string {273 v := duels.Get(id)274 if v == nil {275 return "# Duel #" + ui.Inline(id) + "\n\nNo such duel.\n"276 }277 d := v.(*duel)278279 var b strings.Builder280 b.WriteString("# Duel #" + d.ID + "\n\n")281 b.WriteString("- Challenger: `" + d.Challenger.String() + "` called **" + d.Call + "**\n")282 b.WriteString("- Opponent: `" + d.Opponent.String() + "`\n")283 b.WriteString("- Status: " + d.Status.String() + "\n")284285 if d.Status == statusResolved {286 b.WriteString("- Coin landed on: **" + d.Result + "**\n")287 b.WriteString("- Winner: `" + d.Winner.String() + "`\n")288 }289 return b.String()290}291292func renderPlayer(rawAddr string) string {293 addr := strings.TrimSpace(rawAddr)294 safe := ui.Inline(addr)295296 v := players.Get(addr)297 if v == nil {298 return "# Player " + safe + "\n\nNo recorded duels yet.\n"299 }300 ps := v.(*playerState)301302 var b strings.Builder303 b.WriteString("# Player " + safe + "\n\n")304 b.WriteString("- Wins: " + strconv.Itoa(ps.Wins) + "\n")305 b.WriteString("- Losses: " + strconv.Itoa(ps.Losses) + "\n")306 b.WriteString("- Duels played: " + strconv.Itoa(ps.Duels) + "\n")307 return b.String()308}309310// Render shows the duel arena at "", a specific duel when path is a numeric311// ID, or one player's record when path is their bech32 address.312func Render(path string) string {313 path = strings.TrimPrefix(strings.TrimSpace(path), "/")314 if path == "" {315 return renderHome()316 }317 if _, err := strconv.Atoi(path); err == nil {318 return renderDuel(path)319 }320 return renderPlayer(path)321}322Accept(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
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
Challenge(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, call string) string
Render(path 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.