Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

coinflipduel.gno

8.21 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 := players.Get(key); v != nil {
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 := duels.Get(id)
174	if v == nil {
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 := duels.Get(id)
216	if v == nil {
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 := duels.Get(id)
288	if v == nil {
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 := players.Get(addr)
311	if v == nil {
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}