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/nt/avl/v0"14)1516// duelStatus tracks the lifecycle of a duel.17type duelStatus int1819const (20 statusPending duelStatus = iota21 statusResolved22 statusCancelled23)2425func (s duelStatus) String() string {26 switch s {27 case statusPending:28 return "pending"29 case statusResolved:30 return "resolved"31 case statusCancelled:32 return "cancelled"33 default:34 return "?"35 }36}3738// duel is one challenge from Challenger to Opponent over a single coin flip.39// The Challenger calls a side up front; the Opponent's only move is to40// accept, which triggers the flip.41type duel struct {42 ID string43 Challenger address44 Opponent address45 Call string // "heads" or "tails" — the challenger's pick46 Status duelStatus47 Result string // "heads" or "tails" once resolved48 Winner address49 CreatedAt int6450 ResolvedAt int6451}5253// playerState is the persisted win/loss record for one address.54type playerState struct {55 Wins int56 Losses int57 Duels int58}5960var (61 duels avl.Tree // duel ID -> *duel62 players avl.Tree // address string -> *playerState6364 nextID int65 flipNonce int66 totalDone int6768 champion address69 championWins int70)7172func parseCall(s string) (string, bool) {73 switch strings.ToLower(strings.TrimSpace(s)) {74 case "heads", "h":75 return "heads", true76 case "tails", "t":77 return "tails", true78 default:79 return "", false80 }81}8283// flipCoin derives a deterministic pseudo-random side from the current chain84// height, a monotonic per-realm nonce, and both duelists' addresses, so two85// duels resolved in the same block still diverge.86func flipCoin(d *duel) string {87 flipNonce++88 seed := runtime.ChainHeight() + int64(flipNonce)89 s := d.Challenger.String() + d.Opponent.String() + d.ID90 for i := 0; i < len(s); i++ {91 seed += int64(s[i])92 }93 if seed < 0 {94 seed = -seed95 }96 if seed%2 == 0 {97 return "heads"98 }99 return "tails"100}101102func getOrCreate(addr address) *playerState {103 key := addr.String()104 if v := players.Get(key); v != nil {105 return v.(*playerState)106 }107 ps := &playerState{}108 players.Set(key, ps)109 return ps110}111112func recordResult(winner, loser address) {113 w := getOrCreate(winner)114 w.Wins++115 w.Duels++116 l := getOrCreate(loser)117 l.Losses++118 l.Duels++119120 if w.Wins > championWins {121 championWins = w.Wins122 champion = winner123 }124}125126// Challenge opens a new duel against opponentAddr, locking in the127// challenger's call of "heads"/"tails" ("h"/"t" also accepted). The128// opponent settles it by calling Accept with the returned duel ID.129func Challenge(cur realm, opponentAddr string, call string) string {130 challenger := cur.Previous().Address()131132 opponent := address(strings.TrimSpace(opponentAddr))133 if !opponent.IsValid() {134 panic("invalid opponent address")135 }136 if opponent == challenger {137 panic("cannot duel yourself")138 }139140 pick, ok := parseCall(call)141 if !ok {142 panic("call must be heads or tails (h/t)")143 }144145 nextID++146 id := strconv.Itoa(nextID)147 d := &duel{148 ID: id,149 Challenger: challenger,150 Opponent: opponent,151 Call: pick,152 Status: statusPending,153 CreatedAt: runtime.ChainHeight(),154 }155 duels.Set(id, d)156157 chain.Emit("DuelChallenged",158 "id", id,159 "challenger", challenger.String(),160 "opponent", opponent.String(),161 "call", pick,162 )163164 return "duel #" + id + " created: you called " + pick +165 ", waiting for " + opponent.String() + " to accept"166}167168// Accept settles a pending duel. Only the challenged opponent may call it;169// the flip happens immediately and the result is final.170func Accept(cur realm, id string) string {171 caller := cur.Previous().Address()172173 v := duels.Get(id)174 if v == nil {175 panic("no such duel")176 }177 d := v.(*duel)178179 if d.Status != statusPending {180 panic("duel already " + d.Status.String())181 }182 if caller != d.Opponent {183 panic("only the challenged opponent can accept this duel")184 }185186 result := flipCoin(d)187 d.Result = result188 d.Status = statusResolved189 d.ResolvedAt = runtime.ChainHeight()190191 if result == d.Call {192 d.Winner = d.Challenger193 recordResult(d.Challenger, d.Opponent)194 } else {195 d.Winner = d.Opponent196 recordResult(d.Opponent, d.Challenger)197 }198199 totalDone++200201 chain.Emit("DuelResolved",202 "id", id,203 "result", result,204 "winner", d.Winner.String(),205 )206207 return "the coin landed on " + result + " -- " + d.Winner.String() + " wins duel #" + id208}209210// Cancel withdraws a still-pending duel. Only the challenger may cancel,211// and only before the opponent accepts.212func Cancel(cur realm, id string) string {213 caller := cur.Previous().Address()214215 v := duels.Get(id)216 if v == nil {217 panic("no such duel")218 }219 d := v.(*duel)220221 if d.Status != statusPending {222 panic("duel already " + d.Status.String())223 }224 if caller != d.Challenger {225 panic("only the challenger can cancel this duel")226 }227228 d.Status = statusCancelled229 return "duel #" + id + " cancelled"230}231232// escapeInline neutralizes markdown-active characters in untrusted text233// before it's embedded inline in Render output.234func escapeInline(s string) string {235 r := strings.NewReplacer(236 "\\", "\\\\",237 "`", "\\`",238 "*", "\\*",239 "_", "\\_",240 "[", "\\[",241 "]", "\\]",242 "|", "\\|",243 )244 return r.Replace(s)245}246247func renderHome() string {248 var b strings.Builder249 b.WriteString("# Coin-Flip Duel\n\n")250 b.WriteString("Challenge another address to a coin flip. You call heads or tails " +251 "up front; they accept and the block decides. No stakes -- just a record.\n\n")252 b.WriteString("- Total duels resolved: " + strconv.Itoa(totalDone) + "\n")253254 if champion.IsValid() {255 b.WriteString("- Reigning champion: `" + champion.String() +256 "` (" + strconv.Itoa(championWins) + " wins)\n")257 } else {258 b.WriteString("- No champion yet -- be the first to win a duel.\n")259 }260261 b.WriteString("\n## How to play\n\n")262 b.WriteString("1. `Challenge(opponentAddr, \"heads\"|\"tails\")` -- opens a duel, returns its ID.\n")263 b.WriteString("2. The opponent calls `Accept(id)` -- flips the coin and settles it on the spot.\n")264 b.WriteString("3. The challenger may `Cancel(id)` while it's still pending.\n\n")265 b.WriteString("View a duel at this realm's path plus its ID (e.g. `.../coinflipduel:3`), " +266 "or a player's record plus their address (e.g. `.../coinflipduel:g1youraddress...`).\n\n")267268 b.WriteString("## Pending duels\n\n")269 pending := 0270 duels.Iterate("", "", func(key string, value interface{}) bool {271 d := value.(*duel)272 if d.Status == statusPending {273 pending++274 b.WriteString("- #" + d.ID + ": `" + d.Challenger.String() + "` called " +275 d.Call + ", waiting on `" + d.Opponent.String() + "`\n")276 }277 return false278 })279 if pending == 0 {280 b.WriteString("_none right now_\n")281 }282283 return b.String()284}285286func renderDuel(id string) string {287 v := duels.Get(id)288 if v == nil {289 return "# Duel #" + escapeInline(id) + "\n\nNo such duel.\n"290 }291 d := v.(*duel)292293 var b strings.Builder294 b.WriteString("# Duel #" + d.ID + "\n\n")295 b.WriteString("- Challenger: `" + d.Challenger.String() + "` called **" + d.Call + "**\n")296 b.WriteString("- Opponent: `" + d.Opponent.String() + "`\n")297 b.WriteString("- Status: " + d.Status.String() + "\n")298299 if d.Status == statusResolved {300 b.WriteString("- Coin landed on: **" + d.Result + "**\n")301 b.WriteString("- Winner: `" + d.Winner.String() + "`\n")302 }303 return b.String()304}305306func renderPlayer(rawAddr string) string {307 addr := strings.TrimSpace(rawAddr)308 safe := escapeInline(addr)309310 v := players.Get(addr)311 if v == nil {312 return "# Player " + safe + "\n\nNo recorded duels yet.\n"313 }314 ps := v.(*playerState)315316 var b strings.Builder317 b.WriteString("# Player " + safe + "\n\n")318 b.WriteString("- Wins: " + strconv.Itoa(ps.Wins) + "\n")319 b.WriteString("- Losses: " + strconv.Itoa(ps.Losses) + "\n")320 b.WriteString("- Duels played: " + strconv.Itoa(ps.Duels) + "\n")321 return b.String()322}323324// Render shows the duel arena at "", a specific duel when path is a numeric325// ID, or one player's record when path is their bech32 address.326func Render(path string) string {327 path = strings.TrimPrefix(strings.TrimSpace(path), "/")328 if path == "" {329 return renderHome()330 }331 if _, err := strconv.Atoi(path); err == nil {332 return renderDuel(path)333 }334 return renderPlayer(path)335}336Accept(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.