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