Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions 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}