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.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}