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}