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

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}