rpgroom.gno
8.00 Kb · 316 lines
1// Package rpgroom is a tiny single-room on-chain RPG. There is exactly one
2// monster in the room at a time. Spawn a hero, take a swing, and see what
3// happens: land the killing blow and the room's monster gets replaced by a
4// tougher one while your hero gains XP, gold, and (eventually) a level; get
5// hit hard enough and your hero falls until it's revived. A room-wide
6// leaderboard tracks who has climbed the highest.
7package rpgroom
8
9import (
10 "sort"
11 "strconv"
12 "strings"
13
14 "chain"
15 "chain/runtime"
16
17 "gno.land/p/nt/avl/v0"
18 "gno.land/p/nt/markdown/sanitize/v0"
19)
20
21// Hero is one player's on-chain character in the room.
22type Hero struct {
23 Name string
24 Level int
25 XP int
26 HP int
27 MaxHP int
28 Attack int
29 Kills int
30 Gold int
31 Alive bool
32}
33
34// Monster is the room's current resident.
35type Monster struct {
36 Name string
37 Level int
38 HP int
39 MaxHP int
40 Attack int
41}
42
43var (
44 heroes avl.Tree // address string -> *Hero
45 monster *Monster
46 roomLevel int
47 totalKills int
48)
49
50var monsterNames = []string{
51 "Slime", "Giant Rat", "Goblin", "Skeleton", "Cave Bat",
52 "Orc Grunt", "Wraith", "Stone Troll", "Dread Wolf", "Young Dragon",
53}
54
55func init() {
56 roomLevel = 1
57 monster = spawnMonster(roomLevel)
58}
59
60func spawnMonster(level int) *Monster {
61 name := monsterNames[(level-1)%len(monsterNames)]
62 hp := 20 + level*15
63 return &Monster{
64 Name: name,
65 Level: level,
66 HP: hp,
67 MaxHP: hp,
68 Attack: 3 + level*2,
69 }
70}
71
72// Spawn creates the caller's hero in the room. Each address gets exactly one
73// hero; call Revive (not Spawn again) if it dies.
74func Spawn(cur realm, name string) {
75 if !cur.Previous().IsUserCall() {
76 panic("only a direct EOA call can spawn a hero")
77 }
78 name = strings.TrimSpace(name)
79 if name == "" {
80 panic("name required")
81 }
82 if len(name) > 24 {
83 panic("name too long (max 24 chars)")
84 }
85 addr := cur.Previous().Address().String()
86 if heroes.Has(addr) {
87 panic("you already have a hero in this room")
88 }
89 heroes.Set(addr, &Hero{
90 Name: name,
91 Level: 1,
92 HP: 30,
93 MaxHP: 30,
94 Attack: 5,
95 Alive: true,
96 })
97 chain.Emit("HeroSpawned", "addr", addr, "name", name)
98}
99
100// Attack swings the caller's hero at the room's current monster. Killing it
101// grants XP and gold and replaces it with a tougher one; otherwise the
102// monster strikes back.
103func Attack(cur realm) {
104 if !cur.Previous().IsUserCall() {
105 panic("only a direct EOA call can attack")
106 }
107 addr := cur.Previous().Address().String()
108 v := heroes.Get(addr)
109 if v == nil {
110 panic("no hero found; call Spawn first")
111 }
112 hero := v.(*Hero)
113 if !hero.Alive {
114 panic("your hero is dead; call Revive first")
115 }
116
117 dmg := hero.Attack
118 if runtime.ChainHeight()%7 == 0 {
119 dmg *= 2 // occasional critical hit, tied to block height parity
120 }
121 monster.HP -= dmg
122 chain.Emit("HeroAttacked", "addr", addr, "damage", strconv.Itoa(dmg), "monsterHP", strconv.Itoa(monster.HP))
123
124 if monster.HP <= 0 {
125 reward := monster.Level * 10
126 gained := monster.Level * 20
127 hero.Gold += reward
128 hero.XP += gained
129 hero.Kills++
130 levelUp(hero)
131 chain.Emit("MonsterSlain", "addr", addr, "monster", monster.Name, "level", strconv.Itoa(monster.Level))
132
133 totalKills++
134 roomLevel++
135 monster = spawnMonster(roomLevel)
136 return
137 }
138
139 hero.HP -= monster.Attack
140 if hero.HP <= 0 {
141 hero.HP = 0
142 hero.Alive = false
143 chain.Emit("HeroDied", "addr", addr, "monster", monster.Name)
144 }
145}
146
147// Rest heals the caller's hero for a quarter of its max HP.
148func Rest(cur realm) {
149 if !cur.Previous().IsUserCall() {
150 panic("only a direct EOA call can rest")
151 }
152 addr := cur.Previous().Address().String()
153 v := heroes.Get(addr)
154 if v == nil {
155 panic("no hero found; call Spawn first")
156 }
157 hero := v.(*Hero)
158 if !hero.Alive {
159 panic("your hero is dead; call Revive first")
160 }
161 if hero.HP >= hero.MaxHP {
162 panic("already at full health")
163 }
164 heal := hero.MaxHP / 4
165 if heal < 1 {
166 heal = 1
167 }
168 hero.HP += heal
169 if hero.HP > hero.MaxHP {
170 hero.HP = hero.MaxHP
171 }
172 chain.Emit("HeroRested", "addr", addr, "hp", strconv.Itoa(hero.HP))
173}
174
175// Revive brings a fallen hero back at half max HP.
176func Revive(cur realm) {
177 if !cur.Previous().IsUserCall() {
178 panic("only a direct EOA call can revive")
179 }
180 addr := cur.Previous().Address().String()
181 v := heroes.Get(addr)
182 if v == nil {
183 panic("no hero found; call Spawn first")
184 }
185 hero := v.(*Hero)
186 if hero.Alive {
187 panic("your hero is not dead")
188 }
189 hero.Alive = true
190 hero.HP = hero.MaxHP / 2
191 if hero.HP < 1 {
192 hero.HP = 1
193 }
194 chain.Emit("HeroRevived", "addr", addr, "hp", strconv.Itoa(hero.HP))
195}
196
197// levelUp applies every level-up a hero's current XP qualifies for. The
198// threshold to reach level N+1 from level N is N*50 XP.
199func levelUp(h *Hero) {
200 for h.XP >= h.Level*50 {
201 h.XP -= h.Level * 50
202 h.Level++
203 h.MaxHP += 10
204 h.HP = h.MaxHP
205 h.Attack += 3
206 }
207}
208
209// byRank orders heroes for the leaderboard: highest level first, then XP,
210// then kills, as a tiebreaker.
211type byRank []*Hero
212
213func (r byRank) Len() int { return len(r) }
214func (r byRank) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
215func (r byRank) Less(i, j int) bool {
216 if r[i].Level != r[j].Level {
217 return r[i].Level > r[j].Level
218 }
219 if r[i].XP != r[j].XP {
220 return r[i].XP > r[j].XP
221 }
222 return r[i].Kills > r[j].Kills
223}
224
225func collectHeroes() []*Hero {
226 var out []*Hero
227 heroes.Iterate("", "", func(key string, value any) bool {
228 out = append(out, value.(*Hero))
229 return false
230 })
231 return out
232}
233
234func hpBar(hp, maxHP int) string {
235 if maxHP <= 0 {
236 maxHP = 1
237 }
238 filled := hp * 10 / maxHP
239 if filled < 0 {
240 filled = 0
241 }
242 if filled > 10 {
243 filled = 10
244 }
245 bar := strings.Repeat("#", filled) + strings.Repeat("-", 10-filled)
246 return "`" + bar + "` " + strconv.Itoa(hp) + "/" + strconv.Itoa(maxHP) + " HP"
247}
248
249// Render shows the room overview and leaderboard at path "", or a single
250// hero's detail page when path is a bech32 address.
251func Render(path string) string {
252 path = strings.TrimSpace(path)
253 if path != "" {
254 return renderHero(path)
255 }
256 return renderRoom()
257}
258
259func renderRoom() string {
260 var b strings.Builder
261 b.WriteString("# RPG Room\n\n")
262 b.WriteString("A single dungeon room with one monster in it at a time. ")
263 b.WriteString("Call `Spawn(\"name\")` to enter, `Attack()` to fight the resident monster, ")
264 b.WriteString("`Rest()` to heal up, and `Revive()` if you fall.\n\n")
265
266 b.WriteString("## Current monster\n\n")
267 b.WriteString("**" + sanitize.InlineText(monster.Name) + "** — level " + strconv.Itoa(monster.Level) + "\n\n")
268 b.WriteString(hpBar(monster.HP, monster.MaxHP) + "\n\n")
269 b.WriteString("- Attack: " + strconv.Itoa(monster.Attack) + "\n")
270 b.WriteString("- Monsters cleared so far: " + strconv.Itoa(totalKills) + "\n\n")
271
272 b.WriteString("## Leaderboard\n\n")
273 all := collectHeroes()
274 if len(all) == 0 {
275 b.WriteString("_No heroes yet. Be the first: `Spawn(\"name\")`._\n")
276 return b.String()
277 }
278 sort.Sort(byRank(all))
279 b.WriteString("| # | Hero | Lvl | HP | Kills | Gold | Status |\n")
280 b.WriteString("|---|---|---|---|---|---|---|\n")
281 limit := len(all)
282 if limit > 20 {
283 limit = 20
284 }
285 for i := 0; i < limit; i++ {
286 h := all[i]
287 status := "alive"
288 if !h.Alive {
289 status = "fallen"
290 }
291 b.WriteString("| " + strconv.Itoa(i+1) + " | " + sanitize.InlineText(h.Name) + " | " +
292 strconv.Itoa(h.Level) + " | " + strconv.Itoa(h.HP) + "/" + strconv.Itoa(h.MaxHP) + " | " +
293 strconv.Itoa(h.Kills) + " | " + strconv.Itoa(h.Gold) + " | " + status + " |\n")
294 }
295 return b.String()
296}
297
298func renderHero(addr string) string {
299 v := heroes.Get(addr)
300 if v == nil {
301 return "> [!WARNING]\n> No hero found for `" + sanitize.InlineText(addr) + "`.\n"
302 }
303 h := v.(*Hero)
304 var b strings.Builder
305 b.WriteString("# " + sanitize.InlineText(h.Name) + "\n\n")
306 b.WriteString(hpBar(h.HP, h.MaxHP) + "\n\n")
307 b.WriteString("- Level: " + strconv.Itoa(h.Level) + "\n")
308 b.WriteString("- XP: " + strconv.Itoa(h.XP) + " (next level at " + strconv.Itoa(h.Level*50) + ")\n")
309 b.WriteString("- Attack: " + strconv.Itoa(h.Attack) + "\n")
310 b.WriteString("- Kills: " + strconv.Itoa(h.Kills) + "\n")
311 b.WriteString("- Gold: " + strconv.Itoa(h.Gold) + "\n")
312 if !h.Alive {
313 b.WriteString("- Status: fallen — call `Revive()` to return\n")
314 }
315 return b.String()
316}