Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}