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}