coinflipduel.gno
7.92 Kb · 321 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/moul/kit/ui/v0"
14 "gno.land/p/nt/avl/v0"
15)
16
17// duelStatus tracks the lifecycle of a duel.
18type duelStatus int
19
20const (
21 statusPending duelStatus = iota
22 statusResolved
23 statusCancelled
24)
25
26func (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}
38
39// 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 to
41// accept, which triggers the flip.
42type duel struct {
43 ID string
44 Challenger address
45 Opponent address
46 Call string // "heads" or "tails" — the challenger's pick
47 Status duelStatus
48 Result string // "heads" or "tails" once resolved
49 Winner address
50 CreatedAt int64
51 ResolvedAt int64
52}
53
54// playerState is the persisted win/loss record for one address.
55type playerState struct {
56 Wins int
57 Losses int
58 Duels int
59}
60
61var (
62 duels avl.Tree // duel ID -> *duel
63 players avl.Tree // address string -> *playerState
64
65 nextID int
66 flipNonce int
67 totalDone int
68
69 champion address
70 championWins int
71)
72
73func parseCall(s string) (string, bool) {
74 switch strings.ToLower(strings.TrimSpace(s)) {
75 case "heads", "h":
76 return "heads", true
77 case "tails", "t":
78 return "tails", true
79 default:
80 return "", false
81 }
82}
83
84// flipCoin derives a deterministic pseudo-random side from the current chain
85// height, a monotonic per-realm nonce, and both duelists' addresses, so two
86// 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.ID
91 for i := 0; i < len(s); i++ {
92 seed += int64(s[i])
93 }
94 if seed < 0 {
95 seed = -seed
96 }
97 if seed%2 == 0 {
98 return "heads"
99 }
100 return "tails"
101}
102
103func 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 ps
111}
112
113func recordResult(winner, loser address) {
114 w := getOrCreate(winner)
115 w.Wins++
116 w.Duels++
117 l := getOrCreate(loser)
118 l.Losses++
119 l.Duels++
120
121 if w.Wins > championWins {
122 championWins = w.Wins
123 champion = winner
124 }
125}
126
127// Challenge opens a new duel against opponentAddr, locking in the
128// challenger's call of "heads"/"tails" ("h"/"t" also accepted). The
129// 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()
132
133 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 }
140
141 pick, ok := parseCall(call)
142 if !ok {
143 panic("call must be heads or tails (h/t)")
144 }
145
146 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)
157
158 chain.Emit("DuelChallenged",
159 "id", id,
160 "challenger", challenger.String(),
161 "opponent", opponent.String(),
162 "call", pick,
163 )
164
165 return "duel #" + id + " created: you called " + pick +
166 ", waiting for " + opponent.String() + " to accept"
167}
168
169// 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()
173
174 v := duels.Get(id)
175 if v == nil {
176 panic("no such duel")
177 }
178 d := v.(*duel)
179
180 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 }
186
187 result := flipCoin(d)
188 d.Result = result
189 d.Status = statusResolved
190 d.ResolvedAt = runtime.ChainHeight()
191
192 if result == d.Call {
193 d.Winner = d.Challenger
194 recordResult(d.Challenger, d.Opponent)
195 } else {
196 d.Winner = d.Opponent
197 recordResult(d.Opponent, d.Challenger)
198 }
199
200 totalDone++
201
202 chain.Emit("DuelResolved",
203 "id", id,
204 "result", result,
205 "winner", d.Winner.String(),
206 )
207
208 return "the coin landed on " + result + " -- " + d.Winner.String() + " wins duel #" + id
209}
210
211// 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()
215
216 v := duels.Get(id)
217 if v == nil {
218 panic("no such duel")
219 }
220 d := v.(*duel)
221
222 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 }
228
229 d.Status = statusCancelled
230 return "duel #" + id + " cancelled"
231}
232
233func renderHome() string {
234 var b strings.Builder
235 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")
239
240 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 }
246
247 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")
253
254 b.WriteString("## Pending duels\n\n")
255 pending := 0
256 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 false
264 })
265 if pending == 0 {
266 b.WriteString("_none right now_\n")
267 }
268
269 return b.String()
270}
271
272func 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)
278
279 var b strings.Builder
280 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")
284
285 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}
291
292func renderPlayer(rawAddr string) string {
293 addr := strings.TrimSpace(rawAddr)
294 safe := ui.Inline(addr)
295
296 v := players.Get(addr)
297 if v == nil {
298 return "# Player " + safe + "\n\nNo recorded duels yet.\n"
299 }
300 ps := v.(*playerState)
301
302 var b strings.Builder
303 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}
309
310// Render shows the duel arena at "", a specific duel when path is a numeric
311// 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}