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