Search Apps Documentation Source Content File Folder Download Copy Actions Download 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}