Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}