dice.gno
5.21 Kb · 189 lines
1// Package dice implements a provably-fair on-chain dice game for gno.land.
2//
3// There is no true randomness on-chain: every node must reach the same result
4// deterministically. Instead, each roll derives its 1-6 face from public block
5// entropy (chain height) mixed with the caller address and the caller's roll
6// count. The derivation is fully reproducible from public data, which is what
7// "provably fair" means here — anyone can recompute a roll and verify it.
8package dice
9
10import (
11 "chain"
12 "chain/runtime"
13 "strconv"
14 "strings"
15
16 "gno.land/p/nt/avl/v0"
17)
18
19// history holds every roll made by a single caller, most recent last.
20type history struct {
21 rolls []int // each value in [1,6]
22}
23
24var (
25 // count[i] is the total number of times face (i+1) has come up globally.
26 count [6]int
27 // total is the total number of rolls across all callers.
28 total int
29 // players maps caller address string -> *history (deterministic iteration).
30 players = avl.NewTree()
31)
32
33// deriveFace turns public entropy into a face in [1,6]. It is deterministic:
34// the same (height, addr, n) always yields the same face, so any observer can
35// recompute and verify a roll. Uses a simple FNV-1a-style hash over the mixed
36// entropy bytes so different callers at the same height get different faces.
37func deriveFace(height int64, addr string, n int) int {
38 const (
39 offset = uint64(1469598103934665603)
40 prime = uint64(1099511628211)
41 )
42 h := offset
43 mix := func(v uint64) {
44 for i := 0; i < 8; i++ {
45 h ^= v & 0xff
46 h *= prime
47 v >>= 8
48 }
49 }
50 mix(uint64(height))
51 mix(uint64(n))
52 for i := 0; i < len(addr); i++ {
53 h ^= uint64(addr[i])
54 h *= prime
55 }
56 return int(h%6) + 1
57}
58
59// Roll performs one dice roll for the calling account and records it.
60//
61// It is a crossing function (gno 0.9 interrealm convention: `cur realm` first
62// parameter). Callers invoke it as Roll(cross(cur)). The result is derived
63// deterministically from the current block height, the caller address, and the
64// caller's prior roll count — no stored secret, fully verifiable.
65func Roll(cur realm) int {
66 if !cur.IsCurrent() {
67 panic("dice: spoofed realm value")
68 }
69 addr := cur.Previous().Address().String()
70
71 var h *history
72 if v, ok := players.Get(addr); ok {
73 h = v.(*history)
74 } else {
75 h = &history{}
76 players.Set(addr, h)
77 }
78
79 face := deriveFace(runtime.ChainHeight(), addr, len(h.rolls))
80 h.rolls = append(h.rolls, face)
81 count[face-1]++
82 total++
83
84 chain.Emit("Roll", "player", addr, "face", strconv.Itoa(face))
85 return face
86}
87
88// RollsOf returns the recorded roll history for an address (oldest first).
89// Read-only helper, safe to call as a query.
90func RollsOf(addr string) []int {
91 v, ok := players.Get(addr)
92 if !ok {
93 return nil
94 }
95 out := v.(*history).rolls
96 cp := make([]int, len(out))
97 copy(cp, out)
98 return cp
99}
100
101// Total returns the global roll count.
102func Total() int { return total }
103
104// Distribution returns the global per-face counts, faces 1-6.
105func Distribution() [6]int { return count }
106
107// bar renders a proportional ▓/░ bar of fixed width for a face's share.
108func bar(n, max, width int) string {
109 if max <= 0 {
110 return strings.Repeat("░", width)
111 }
112 filled := n * width / max
113 if filled > width {
114 filled = width
115 }
116 return strings.Repeat("▓", filled) + strings.Repeat("░", width-filled)
117}
118
119// Render returns the realm's gnoweb Markdown view.
120//
121// Render is NOT a crossing function (no `cur realm` param) — it is a read-only
122// query surface. It shows the total roll count, a distribution bar chart for
123// faces 1-6, and the most recent rolls.
124func Render(path string) string {
125 var b strings.Builder
126 b.WriteString("# 🎲 Provably-Fair Dice\n\n")
127 b.WriteString("Every roll is derived deterministically from the block height, ")
128 b.WriteString("the caller address, and their roll count — no hidden randomness, ")
129 b.WriteString("fully reproducible from public data.\n\n")
130
131 b.WriteString("**Total rolls:** " + strconv.Itoa(total) + "\n\n")
132
133 // Distribution bar chart.
134 b.WriteString("## Distribution\n\n")
135 max := 0
136 for _, c := range count {
137 if c > max {
138 max = c
139 }
140 }
141 for face := 1; face <= 6; face++ {
142 c := count[face-1]
143 pct := 0
144 if total > 0 {
145 pct = c * 100 / total
146 }
147 b.WriteString("`" + strconv.Itoa(face) + "` ")
148 b.WriteString(bar(c, max, 20))
149 b.WriteString(" " + strconv.Itoa(c))
150 b.WriteString(" (" + strconv.Itoa(pct) + "%)\n\n")
151 }
152
153 // Recent rolls: walk players, collect last rolls, show most recent globally.
154 b.WriteString("## Recent rolls\n\n")
155 if total == 0 {
156 b.WriteString("_No rolls yet. Call `Roll` to play._\n")
157 return b.String()
158 }
159 type entry struct {
160 addr string
161 face int
162 }
163 recent := []entry{}
164 players.Iterate("", "", func(key string, value interface{}) bool {
165 h := value.(*history)
166 // take up to the last 3 rolls of each player
167 start := len(h.rolls) - 3
168 if start < 0 {
169 start = 0
170 }
171 for _, f := range h.rolls[start:] {
172 recent = append(recent, entry{addr: key, face: f})
173 }
174 return false
175 })
176 // show at most the last 10 collected
177 start := len(recent) - 10
178 if start < 0 {
179 start = 0
180 }
181 for _, e := range recent[start:] {
182 short := e.addr
183 if len(short) > 12 {
184 short = short[:8] + "…" + short[len(short)-4:]
185 }
186 b.WriteString("- 🎲 **" + strconv.Itoa(e.face) + "** — `" + short + "`\n")
187 }
188 return b.String()
189}