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

tamagotchi.gno

8.27 Kb · 305 lines
  1// Package tamagotchi is an on-chain virtual pet. Hatch one, then keep it
  2// alive by Feeding, Playing and letting it Sleep — hunger, happiness and
  3// energy all decay with block height, and neglect too long kills the pet.
  4// Hatching again after a death starts a fresh pet but keeps your lifetime
  5// stats, so the leaderboard rewards long-term care, not just luck.
  6package tamagotchi
  7
  8import (
  9	"sort"
 10	"strconv"
 11
 12	"chain/runtime"
 13
 14	"gno.land/p/nt/avl/v0"
 15)
 16
 17// pet is the persisted record for one owner's tamagotchi.
 18type pet struct {
 19	owner      address
 20	name       string
 21	born       int64
 22	lastUpdate int64
 23	hunger     int // 0 (full) .. 100 (starving)
 24	happiness  int // 0 (miserable) .. 100 (joyful)
 25	energy     int // 0 (exhausted) .. 100 (energetic)
 26	alive      bool
 27	deaths     int
 28	feeds      int
 29	plays      int
 30}
 31
 32var pets avl.Tree // owner address string -> *pet
 33
 34func clamp(v, lo, hi int) int {
 35	if v < lo {
 36		return lo
 37	}
 38	if v > hi {
 39		return hi
 40	}
 41	return v
 42}
 43
 44func get(owner address) (*pet, bool) {
 45	v := pets.Get(owner.String())
 46	if v == nil {
 47		return nil, false
 48	}
 49	return v.(*pet), true
 50}
 51
 52// tick applies stat decay for blocks elapsed since the pet's last update and
 53// kills it if any stat has bottomed/topped out. Mutates and persists.
 54func (p *pet) tick(height int64) {
 55	if !p.alive {
 56		return
 57	}
 58	elapsed := int(height - p.lastUpdate)
 59	if elapsed <= 0 {
 60		return
 61	}
 62	p.hunger = clamp(p.hunger+elapsed, 0, 100)
 63	p.happiness = clamp(p.happiness-elapsed/2, 0, 100)
 64	p.energy = clamp(p.energy-elapsed/3, 0, 100)
 65	p.lastUpdate = height
 66	if p.hunger >= 100 || p.happiness <= 0 || p.energy <= 0 {
 67		p.alive = false
 68		p.deaths++
 69	}
 70}
 71
 72// project computes display stats as of height without mutating the pet, so
 73// Render can show a live-looking view without writing state on a query.
 74func project(p *pet, height int64) (hunger, happiness, energy int, alive bool) {
 75	if !p.alive {
 76		return p.hunger, p.happiness, p.energy, false
 77	}
 78	elapsed := int(height - p.lastUpdate)
 79	if elapsed < 0 {
 80		elapsed = 0
 81	}
 82	hunger = clamp(p.hunger+elapsed, 0, 100)
 83	happiness = clamp(p.happiness-elapsed/2, 0, 100)
 84	energy = clamp(p.energy-elapsed/3, 0, 100)
 85	alive = hunger < 100 && happiness > 0 && energy > 0
 86	return
 87}
 88
 89func ageStageName(born, height int64) string {
 90	switch age := height - born; {
 91	case age < 10:
 92		return "🥚 Egg"
 93	case age < 50:
 94		return "🐣 Baby"
 95	case age < 200:
 96		return "🐤 Teen"
 97	default:
 98		return "🐓 Adult"
 99	}
100}
101
102// requireLivingPet ticks and fetches the caller's pet, panicking with a
103// friendly message if there is none or it has died.
104func requireLivingPet(owner address, height int64) *pet {
105	p, ok := get(owner)
106	if !ok {
107		panic("you don't have a pet yet — call Hatch(name) first")
108	}
109	p.tick(height)
110	if !p.alive {
111		panic(p.name + " has died of neglect. Call Hatch(name) to start over.")
112	}
113	return p
114}
115
116// hatch is the non-crossing core of Hatch, kept separate so unit tests can
117// exercise its panics directly without going through a realm-crossing call.
118func hatch(owner address, name string, height int64) string {
119	if name == "" {
120		panic("name cannot be empty")
121	}
122
123	deaths, feeds, plays := 0, 0, 0
124	if existing, ok := get(owner); ok {
125		if existing.alive {
126			existing.tick(height)
127		}
128		if existing.alive {
129			panic("you already have a living pet: " + existing.name)
130		}
131		deaths, feeds, plays = existing.deaths, existing.feeds, existing.plays
132	}
133
134	pets.Set(owner.String(), &pet{
135		owner:      owner,
136		name:       name,
137		born:       height,
138		lastUpdate: height,
139		hunger:     20,
140		happiness:  80,
141		energy:     80,
142		alive:      true,
143		deaths:     deaths,
144		feeds:      feeds,
145		plays:      plays,
146	})
147	return "🥚 " + name + " has hatched!"
148}
149
150// Hatch creates a new pet for the caller. Lifetime feeds/plays/deaths carry
151// over from any previous pet so the leaderboard tracks long-term care.
152func Hatch(cur realm, name string) string {
153	if !cur.IsCurrent() {
154		panic("spoofed realm")
155	}
156	return hatch(cur.Previous().Address(), name, runtime.ChainHeight())
157}
158
159// feed is the non-crossing core of Feed.
160func feed(owner address, height int64) string {
161	p := requireLivingPet(owner, height)
162	p.hunger = clamp(p.hunger-30, 0, 100)
163	p.energy = clamp(p.energy+5, 0, 100)
164	p.feeds++
165	return p.name + " munches happily. Hunger: " + strconv.Itoa(p.hunger) + "/100"
166}
167
168// Feed reduces hunger and gives a small energy boost.
169func Feed(cur realm) string {
170	if !cur.IsCurrent() {
171		panic("spoofed realm")
172	}
173	return feed(cur.Previous().Address(), runtime.ChainHeight())
174}
175
176// play is the non-crossing core of Play.
177func play(owner address, height int64) string {
178	p := requireLivingPet(owner, height)
179	if p.energy < 10 {
180		return p.name + " is too tired to play — try Sleep() first."
181	}
182	p.happiness = clamp(p.happiness+20, 0, 100)
183	p.energy = clamp(p.energy-10, 0, 100)
184	p.hunger = clamp(p.hunger+5, 0, 100)
185	p.plays++
186	return p.name + " had a blast! Happiness: " + strconv.Itoa(p.happiness) + "/100"
187}
188
189// Play boosts happiness at the cost of energy and a bit of hunger. Refuses
190// to run a tired pet into the ground — rest first.
191func Play(cur realm) string {
192	if !cur.IsCurrent() {
193		panic("spoofed realm")
194	}
195	return play(cur.Previous().Address(), runtime.ChainHeight())
196}
197
198// sleep is the non-crossing core of Sleep.
199func sleep(owner address, height int64) string {
200	p := requireLivingPet(owner, height)
201	p.energy = clamp(p.energy+40, 0, 100)
202	p.hunger = clamp(p.hunger+5, 0, 100)
203	return p.name + " takes a nap. Energy: " + strconv.Itoa(p.energy) + "/100"
204}
205
206// Sleep restores energy at the cost of a little hunger.
207func Sleep(cur realm) string {
208	if !cur.IsCurrent() {
209		panic("spoofed realm")
210	}
211	return sleep(cur.Previous().Address(), runtime.ChainHeight())
212}
213
214// byAliveThenOwner ranks living pets before dead ones, then orders each
215// group by owner address so the leaderboard is fully deterministic.
216type byAliveThenOwner []*pet
217
218func (r byAliveThenOwner) Len() int      { return len(r) }
219func (r byAliveThenOwner) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
220func (r byAliveThenOwner) Less(i, j int) bool {
221	if r[i].alive != r[j].alive {
222		return r[i].alive
223	}
224	return r[i].owner.String() < r[j].owner.String()
225}
226
227func bar(v int) string {
228	filled := v / 10
229	out := ""
230	for i := 0; i < 10; i++ {
231		if i < filled {
232			out += "█"
233		} else {
234			out += "░"
235		}
236	}
237	return out
238}
239
240func shortAddr(a address) string {
241	s := a.String()
242	if len(s) > 12 {
243		return s[:8] + "…" + s[len(s)-4:]
244	}
245	return s
246}
247
248func renderCard(p *pet, height int64) string {
249	hunger, happiness, energy, alive := project(p, height)
250	out := "## " + p.name + " — owned by `" + shortAddr(p.owner) + "`\n\n"
251	if !alive {
252		out += "💀 **Deceased.** Owner can `Hatch(name)` a new pet.\n\n"
253		return out
254	}
255	out += ageStageName(p.born, height) + " · age **" + strconv.Itoa(int(height-p.born)) + "** blocks\n\n"
256	out += "- Hunger:    " + bar(100-hunger) + " (" + strconv.Itoa(hunger) + "/100 — lower is better)\n"
257	out += "- Happiness: " + bar(happiness) + " (" + strconv.Itoa(happiness) + "/100)\n"
258	out += "- Energy:    " + bar(energy) + " (" + strconv.Itoa(energy) + "/100)\n\n"
259	return out
260}
261
262// Render shows either every pet ever hatched (path == "") or a single pet's
263// detail card when path is a bech32 owner address.
264func Render(path string) string {
265	height := runtime.ChainHeight()
266
267	out := "# 🐣 Tamagotchi\n\n"
268	out += "A tiny on-chain pet. `Hatch(\"name\")` to start, then keep it alive with " +
269		"`Feed()`, `Play()` and `Sleep()` — stats decay every block, so neglect kills it.\n\n"
270
271	if path != "" {
272		owner := address(path)
273		p, ok := get(owner)
274		if !ok {
275			return out + "_No pet found for `" + path + "`._\n"
276		}
277		return out + renderCard(p, height)
278	}
279
280	var rows []*pet
281	pets.Iterate("", "", func(_ string, v any) bool {
282		rows = append(rows, v.(*pet))
283		return false
284	})
285	if len(rows) == 0 {
286		out += "_No pets hatched yet. Be the first!_\n"
287		return out
288	}
289	sort.Stable(byAliveThenOwner(rows))
290
291	out += "| Pet | Owner | Status | Feeds | Plays | Deaths |\n"
292	out += "| :--- | :--- | :--- | ---: | ---: | ---: |\n"
293	for _, p := range rows {
294		_, _, _, alive := project(p, height)
295		status := "🐤 alive"
296		if !alive {
297			status = "💀 dead"
298		}
299		out += "| " + p.name + " | `" + shortAddr(p.owner) + "` | " + status +
300			" | " + strconv.Itoa(p.feeds) + " | " + strconv.Itoa(p.plays) +
301			" | " + strconv.Itoa(p.deaths) + " |\n"
302	}
303	out += "\n_View a single pet at `?<owner-address>`._\n"
304	return out
305}