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

eggling.gno

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