Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}