coinflipduel.gno
8.20 Kb · 335 lines
1// Package coinflipduel is a one-on-one coin-flip duel: challenge another
2// 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 coinflipduel
5
6import (
7 "strconv"
8 "strings"
9
10 "chain"
11 "chain/runtime"
12
13 "gno.land/p/nt/avl/v0"
14)
15
16// duelStatus tracks the lifecycle of a duel.
17type duelStatus int
18
19const (
20 statusPending duelStatus = iota
21 statusResolved
22 statusCancelled
23)
24
25func (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}
37
38// 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 to
40// accept, which triggers the flip.
41type duel struct {
42 ID string
43 Challenger address
44 Opponent address
45 Call string // "heads" or "tails" — the challenger's pick
46 Status duelStatus
47 Result string // "heads" or "tails" once resolved
48 Winner address
49 CreatedAt int64
50 ResolvedAt int64
51}
52
53// playerState is the persisted win/loss record for one address.
54type playerState struct {
55 Wins int
56 Losses int
57 Duels int
58}
59
60var (
61 duels avl.Tree // duel ID -> *duel
62 players avl.Tree // address string -> *playerState
63
64 nextID int
65 flipNonce int
66 totalDone int
67
68 champion address
69 championWins int
70)
71
72func parseCall(s string) (string, bool) {
73 switch strings.ToLower(strings.TrimSpace(s)) {
74 case "heads", "h":
75 return "heads", true
76 case "tails", "t":
77 return "tails", true
78 default:
79 return "", false
80 }
81}
82
83// flipCoin derives a deterministic pseudo-random side from the current chain
84// height, a monotonic per-realm nonce, and both duelists' addresses, so two
85// 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.ID
90 for i := 0; i < len(s); i++ {
91 seed += int64(s[i])
92 }
93 if seed < 0 {
94 seed = -seed
95 }
96 if seed%2 == 0 {
97 return "heads"
98 }
99 return "tails"
100}
101
102func getOrCreate(addr address) *playerState {
103 key := addr.String()
104 if v, ok := players.Get(key); ok {
105 return v.(*playerState)
106 }
107 ps := &playerState{}
108 players.Set(key, ps)
109 return ps
110}
111
112func recordResult(winner, loser address) {
113 w := getOrCreate(winner)
114 w.Wins++
115 w.Duels++
116 l := getOrCreate(loser)
117 l.Losses++
118 l.Duels++
119
120 if w.Wins > championWins {
121 championWins = w.Wins
122 champion = winner
123 }
124}
125
126// Challenge opens a new duel against opponentAddr, locking in the
127// challenger's call of "heads"/"tails" ("h"/"t" also accepted). The
128// 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()
131
132 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 }
139
140 pick, ok := parseCall(call)
141 if !ok {
142 panic("call must be heads or tails (h/t)")
143 }
144
145 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)
156
157 chain.Emit("DuelChallenged",
158 "id", id,
159 "challenger", challenger.String(),
160 "opponent", opponent.String(),
161 "call", pick,
162 )
163
164 return "duel #" + id + " created: you called " + pick +
165 ", waiting for " + opponent.String() + " to accept"
166}
167
168// 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()
172
173 v, ok := duels.Get(id)
174 if !ok {
175 panic("no such duel")
176 }
177 d := v.(*duel)
178
179 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 }
185
186 result := flipCoin(d)
187 d.Result = result
188 d.Status = statusResolved
189 d.ResolvedAt = runtime.ChainHeight()
190
191 if result == d.Call {
192 d.Winner = d.Challenger
193 recordResult(d.Challenger, d.Opponent)
194 } else {
195 d.Winner = d.Opponent
196 recordResult(d.Opponent, d.Challenger)
197 }
198
199 totalDone++
200
201 chain.Emit("DuelResolved",
202 "id", id,
203 "result", result,
204 "winner", d.Winner.String(),
205 )
206
207 return "the coin landed on " + result + " -- " + d.Winner.String() + " wins duel #" + id
208}
209
210// 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()
214
215 v, ok := duels.Get(id)
216 if !ok {
217 panic("no such duel")
218 }
219 d := v.(*duel)
220
221 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 }
227
228 d.Status = statusCancelled
229 return "duel #" + id + " cancelled"
230}
231
232// escapeInline neutralizes markdown-active characters in untrusted text
233// 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}
246
247func renderHome() string {
248 var b strings.Builder
249 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")
253
254 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 }
260
261 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")
267
268 b.WriteString("## Pending duels\n\n")
269 pending := 0
270 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 false
278 })
279 if pending == 0 {
280 b.WriteString("_none right now_\n")
281 }
282
283 return b.String()
284}
285
286func renderDuel(id string) string {
287 v, ok := duels.Get(id)
288 if !ok {
289 return "# Duel #" + escapeInline(id) + "\n\nNo such duel.\n"
290 }
291 d := v.(*duel)
292
293 var b strings.Builder
294 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")
298
299 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}
305
306func renderPlayer(rawAddr string) string {
307 addr := strings.TrimSpace(rawAddr)
308 safe := escapeInline(addr)
309
310 v, ok := players.Get(addr)
311 if !ok {
312 return "# Player " + safe + "\n\nNo recorded duels yet.\n"
313 }
314 ps := v.(*playerState)
315
316 var b strings.Builder
317 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}
323
324// Render shows the duel arena at "", a specific duel when path is a numeric
325// 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}