← Back to Realms

Realm

gno.land/r/moul/x/daily/coinflipduel/v1

Overview

Realm Path
gno.land/r/moul/x/daily/coinflipduel/v1
Exported Functions
4
State Entries
13
Source Files
4
Total Package Entries
24

Exported Functions

4 exported functions

State

13 state entries

Source Code

FILES
coinflipduel.gno
go
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}
322

Raw Package Data

Raw JSON data