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