eggling.gno
10.15 Kb · 360 lines
1// Package eggling is an on-chain egg incubator and creature collector.
2// Plant an egg, let it incubate for a minimum number of blocks, then Hatch
3// it: the longer you waited (and a little luck, seeded from the egg's own
4// history) decides the rarity of the creature you get. Train hatched
5// creatures to level them up. Unlike a tamagotchi there's nothing to feed
6// or lose to neglect — eggling is about patience and the luck of the
7// hatch, not survival.
8package eggling
9
10import (
11 "crypto/sha256"
12 "encoding/hex"
13 "sort"
14 "strconv"
15 "strings"
16
17 "chain/markdown"
18 "chain/runtime"
19
20 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/kit/ui/v0"
21 "gno.land/p/nt/avl/v0"
22)
23
24// minIncubation is the minimum number of blocks an egg must sit before it
25// can hatch.
26const minIncubation int64 = 20
27
28// waitCap is the incubation-time bonus cap (in blocks) used by rarityTier,
29// so waiting forever doesn't guarantee a legendary — it just improves odds.
30const waitCap int64 = 200
31
32var speciesNames = [...]string{
33 "Slime", "Sprout", "Ember", "Pebble", "Breeze", "Glimmer", "Shade", "Coral",
34}
35
36var rarityNames = [...]string{"Common", "Uncommon", "Rare", "Legendary"}
37var rarityEmoji = [...]string{"⚪", "🟢", "🔵", "🟡"}
38
39// egg is the persisted record for one planted egg, hatched or not.
40type egg struct {
41 ID string
42 Owner address
43 PlantedAt int64
44 Hatched bool
45 Species string
46 RarityTier int
47 Name string
48 XP int
49 Level int
50}
51
52var (
53 eggs avl.Tree // id string -> *egg
54 nextID int
55 totalPlanted int
56 totalHatched int
57 rarityCounts [4]int
58)
59
60func get(id string) (*egg, bool) {
61 v := eggs.Get(id)
62 if v == nil {
63 return nil, false
64 }
65 return v.(*egg), true
66}
67
68// hashSeed hashes the given parts (joined with ":") to a deterministic hex
69// digest. Used to derive an egg's species and rarity roll from facts
70// already fixed at plant time, so nobody — not even the owner — can game
71// the outcome after the fact.
72func hashSeed(parts ...string) string {
73 sum := sha256.Sum256([]byte(strings.Join(parts, ":")))
74 return hex.EncodeToString(sum[:])
75}
76
77// seedBytes pulls the first two bytes out of a hex digest for use as two
78// independent 0-255 rolls (species pick, rarity roll).
79func seedBytes(seedHex string) (byte, byte) {
80 raw, err := hex.DecodeString(seedHex)
81 if err != nil || len(raw) < 2 {
82 return 0, 0
83 }
84 return raw[0], raw[1]
85}
86
87// rarityTier turns a 0-255 roll plus the blocks waited into a tier index
88// 0..3 (common..legendary). Waiting longer raises the effective score, so
89// patience improves your odds, but a bad roll can still land common even
90// after a long wait, and a lucky roll can hatch rare almost immediately.
91func rarityTier(roll uint8, waitBlocks int64) int {
92 bonus := waitBlocks
93 if bonus > waitCap {
94 bonus = waitCap
95 }
96 score := int(roll) + int(bonus)/2
97 switch {
98 case score >= 300:
99 return 3
100 case score >= 220:
101 return 2
102 case score >= 140:
103 return 1
104 default:
105 return 0
106 }
107}
108
109// plant is the non-crossing core of PlantEgg.
110func plant(owner address, height int64) *egg {
111 nextID++
112 id := strconv.Itoa(nextID)
113 e := &egg{ID: id, Owner: owner, PlantedAt: height}
114 eggs.Set(id, e)
115 totalPlanted++
116 return e
117}
118
119// PlantEgg starts incubating a new egg for the caller. Returns its ID.
120func PlantEgg(cur realm) string {
121 if !cur.IsCurrent() {
122 panic("spoofed realm")
123 }
124 e := plant(cur.Previous().Address(), runtime.ChainHeight())
125 return "🥚 planted egg #" + e.ID + " — incubate at least " +
126 strconv.Itoa(int(minIncubation)) + " blocks, then Hatch(\"" + e.ID + "\")"
127}
128
129// hatch is the non-crossing core of Hatch.
130func hatch(e *egg, height int64) string {
131 if e.Hatched {
132 panic("egg #" + e.ID + " already hatched into a " + e.Species)
133 }
134 wait := height - e.PlantedAt
135 if wait < minIncubation {
136 panic("egg #" + e.ID + " needs " + strconv.Itoa(int(minIncubation-wait)) + " more blocks to incubate")
137 }
138
139 seed := hashSeed(e.ID, e.Owner.String(), strconv.FormatInt(e.PlantedAt, 10))
140 rarityRoll, speciesRoll := seedBytes(seed)
141 tier := rarityTier(rarityRoll, wait)
142 species := speciesNames[int(speciesRoll)%len(speciesNames)]
143
144 e.Hatched = true
145 e.Species = species
146 e.RarityTier = tier
147 e.Name = species
148 e.Level = 1
149 rarityCounts[tier]++
150 totalHatched++
151
152 return rarityEmoji[tier] + " egg #" + e.ID + " hatched into a " + rarityNames[tier] + " " + species + "!"
153}
154
155// Hatch hatches an incubated egg the caller owns.
156func Hatch(cur realm, id string) string {
157 if !cur.IsCurrent() {
158 panic("spoofed realm")
159 }
160 e, ok := get(id)
161 if !ok {
162 panic("no such egg #" + id)
163 }
164 if e.Owner != cur.Previous().Address() {
165 panic("only the owner can hatch egg #" + id)
166 }
167 return hatch(e, runtime.ChainHeight())
168}
169
170// train is the non-crossing core of Train.
171func train(e *egg) string {
172 if !e.Hatched {
173 panic("egg #" + e.ID + " hasn't hatched yet")
174 }
175 e.XP += 10
176 newLevel := e.XP/50 + 1
177 leveled := newLevel > e.Level
178 e.Level = newLevel
179
180 msg := e.Name + " gained 10 XP (" + strconv.Itoa(e.XP) + " total)"
181 if leveled {
182 msg += " and reached level " + strconv.Itoa(e.Level) + "!"
183 }
184 return msg
185}
186
187// Train gives a hatched creature the caller owns some experience, leveling
188// it up every 50 XP.
189func Train(cur realm, id string) string {
190 if !cur.IsCurrent() {
191 panic("spoofed realm")
192 }
193 e, ok := get(id)
194 if !ok {
195 panic("no such egg #" + id)
196 }
197 if e.Owner != cur.Previous().Address() {
198 panic("only the owner can train egg #" + id)
199 }
200 return train(e)
201}
202
203// rename is the non-crossing core of Rename.
204func rename(e *egg, name string) string {
205 if !e.Hatched {
206 panic("egg #" + e.ID + " hasn't hatched yet — nothing to name")
207 }
208 name = strings.TrimSpace(name)
209 if strings.TrimSpace(markdown.StripBidiAndZeroWidth(name)) == "" {
210 panic("name cannot be empty")
211 }
212 old := e.Name
213 e.Name = name
214 return old + " renamed to " + name
215}
216
217// Rename gives a hatched creature the caller owns a custom name.
218func Rename(cur realm, id string, name string) string {
219 if !cur.IsCurrent() {
220 panic("spoofed realm")
221 }
222 e, ok := get(id)
223 if !ok {
224 panic("no such egg #" + id)
225 }
226 if e.Owner != cur.Previous().Address() {
227 panic("only the owner can rename egg #" + id)
228 }
229 return rename(e, name)
230}
231
232// byIDNumeric orders eggs by their numeric ID, ascending.
233type byIDNumeric []*egg
234
235func (r byIDNumeric) Len() int { return len(r) }
236func (r byIDNumeric) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
237func (r byIDNumeric) Less(i, j int) bool {
238 a, _ := strconv.Atoi(r[i].ID)
239 b, _ := strconv.Atoi(r[j].ID)
240 return a < b
241}
242
243func allEggs() []*egg {
244 var rows []*egg
245 eggs.Iterate("", "", func(_ string, v any) bool {
246 rows = append(rows, v.(*egg))
247 return false
248 })
249 sort.Stable(byIDNumeric(rows))
250 return rows
251}
252
253func renderHome() string {
254 var b strings.Builder
255 b.WriteString("# 🥚 Eggling\n\n")
256 b.WriteString("An on-chain egg incubator. `PlantEgg()` to start one, wait at least " +
257 strconv.Itoa(int(minIncubation)) + " blocks, then `Hatch(id)` — the longer you " +
258 "waited (plus a little sealed-in luck) decides the rarity of the creature you get. " +
259 "`Train(id)` levels a hatched creature up; `Rename(id, name)` gives it a name.\n\n")
260
261 b.WriteString("- Eggs planted: " + strconv.Itoa(totalPlanted) + "\n")
262 b.WriteString("- Creatures hatched: " + strconv.Itoa(totalHatched) + "\n")
263 if totalHatched > 0 {
264 for tier := 3; tier >= 0; tier-- {
265 if rarityCounts[tier] > 0 {
266 b.WriteString(" - " + rarityEmoji[tier] + " " + rarityNames[tier] + ": " +
267 strconv.Itoa(rarityCounts[tier]) + "\n")
268 }
269 }
270 }
271
272 rows := allEggs()
273 b.WriteString("\n## Eggs\n\n")
274 if len(rows) == 0 {
275 b.WriteString("_None planted yet. Be the first!_\n")
276 return b.String()
277 }
278
279 b.WriteString("| ID | Owner | Status | Level |\n")
280 b.WriteString("| :--- | :--- | :--- | ---: |\n")
281 for _, e := range rows {
282 status := "🥚 incubating"
283 level := "-"
284 if e.Hatched {
285 status = rarityEmoji[e.RarityTier] + " " + ui.Inline(e.Name) + " (" + rarityNames[e.RarityTier] + " " + e.Species + ")"
286 level = strconv.Itoa(e.Level)
287 }
288 b.WriteString("| #" + e.ID + " | " + ui.Addr(e.Owner) + " | " + status + " | " + level + " |\n")
289 }
290 b.WriteString("\n_View a single egg at this realm's path plus its ID (e.g. `.../eggling:3`), " +
291 "or one owner's eggs plus their address._\n")
292 return b.String()
293}
294
295func renderEgg(id string, height int64) string {
296 e, ok := get(id)
297 if !ok {
298 return "# Egg #" + ui.Inline(id) + "\n\nNo such egg.\n"
299 }
300
301 var b strings.Builder
302 if !e.Hatched {
303 wait := height - e.PlantedAt
304 b.WriteString("# 🥚 Egg #" + e.ID + "\n\n")
305 b.WriteString("- Owner: `" + e.Owner.String() + "`\n")
306 b.WriteString("- Planted at block: " + strconv.FormatInt(e.PlantedAt, 10) + "\n")
307 if wait < minIncubation {
308 b.WriteString("- Ready to hatch in: " + strconv.FormatInt(minIncubation-wait, 10) + " more blocks\n")
309 } else {
310 b.WriteString("- Ready to hatch now — call `Hatch(\"" + e.ID + "\")`\n")
311 }
312 return b.String()
313 }
314
315 b.WriteString("# " + rarityEmoji[e.RarityTier] + " " + e.Name + "\n\n")
316 b.WriteString("- Owner: `" + e.Owner.String() + "`\n")
317 b.WriteString("- Species: " + e.Species + "\n")
318 b.WriteString("- Rarity: " + rarityNames[e.RarityTier] + "\n")
319 b.WriteString("- Level: " + strconv.Itoa(e.Level) + " (" + strconv.Itoa(e.XP) + " XP)\n")
320 return b.String()
321}
322
323func renderOwner(rawAddr string) string {
324 owner := address(strings.TrimSpace(rawAddr))
325 safe := ui.Inline(owner.String())
326
327 var b strings.Builder
328 b.WriteString("# Eggs owned by " + safe + "\n\n")
329
330 found := false
331 for _, e := range allEggs() {
332 if e.Owner != owner {
333 continue
334 }
335 found = true
336 if e.Hatched {
337 b.WriteString("- #" + e.ID + ": " + rarityEmoji[e.RarityTier] + " " + ui.Inline(e.Name) +
338 " — " + rarityNames[e.RarityTier] + " " + e.Species + ", level " + strconv.Itoa(e.Level) + "\n")
339 } else {
340 b.WriteString("- #" + e.ID + ": 🥚 incubating\n")
341 }
342 }
343 if !found {
344 b.WriteString("_No eggs found for this address._\n")
345 }
346 return b.String()
347}
348
349// Render shows the full egg list at "", a single egg's detail when path is
350// a numeric ID, or one owner's eggs when path is a bech32 address.
351func Render(path string) string {
352 path = strings.TrimPrefix(strings.TrimSpace(path), "/")
353 if path == "" {
354 return renderHome()
355 }
356 if _, err := strconv.Atoi(path); err == nil {
357 return renderEgg(path, runtime.ChainHeight())
358 }
359 return renderOwner(path)
360}