1// Package rpgroom is a tiny single-room on-chain RPG. There is exactly one2// monster in the room at a time. Spawn a hero, take a swing, and see what3// happens: land the killing blow and the room's monster gets replaced by a4// tougher one while your hero gains XP, gold, and (eventually) a level; get5// hit hard enough and your hero falls until it's revived. A room-wide6// leaderboard tracks who has climbed the highest.7package rpgroom89import (10 "sort"11 "strconv"12 "strings"1314 "chain"15 "chain/runtime"1617 "gno.land/p/nt/avl/v0"18 "gno.land/p/nt/markdown/sanitize/v0"19)2021// Hero is one player's on-chain character in the room.22type Hero struct {23 Name string24 Level int25 XP int26 HP int27 MaxHP int28 Attack int29 Kills int30 Gold int31 Alive bool32}3334// Monster is the room's current resident.35type Monster struct {36 Name string37 Level int38 HP int39 MaxHP int40 Attack int41}4243var (44 heroes avl.Tree // address string -> *Hero45 monster *Monster46 roomLevel int47 totalKills int48)4950var monsterNames = []string{51 "Slime", "Giant Rat", "Goblin", "Skeleton", "Cave Bat",52 "Orc Grunt", "Wraith", "Stone Troll", "Dread Wolf", "Young Dragon",53}5455func init() {56 roomLevel = 157 monster = spawnMonster(roomLevel)58}5960func spawnMonster(level int) *Monster {61 name := monsterNames[(level-1)%len(monsterNames)]62 hp := 20 + level*1563 return &Monster{64 Name: name,65 Level: level,66 HP: hp,67 MaxHP: hp,68 Attack: 3 + level*2,69 }70}7172// Spawn creates the caller's hero in the room. Each address gets exactly one73// 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}99100// Attack swings the caller's hero at the room's current monster. Killing it101// grants XP and gold and replaces it with a tougher one; otherwise the102// 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 }116117 dmg := hero.Attack118 if runtime.ChainHeight()%7 == 0 {119 dmg *= 2 // occasional critical hit, tied to block height parity120 }121 monster.HP -= dmg122 chain.Emit("HeroAttacked", "addr", addr, "damage", strconv.Itoa(dmg), "monsterHP", strconv.Itoa(monster.HP))123124 if monster.HP <= 0 {125 reward := monster.Level * 10126 gained := monster.Level * 20127 hero.Gold += reward128 hero.XP += gained129 hero.Kills++130 levelUp(hero)131 chain.Emit("MonsterSlain", "addr", addr, "monster", monster.Name, "level", strconv.Itoa(monster.Level))132133 totalKills++134 roomLevel++135 monster = spawnMonster(roomLevel)136 return137 }138139 hero.HP -= monster.Attack140 if hero.HP <= 0 {141 hero.HP = 0142 hero.Alive = false143 chain.Emit("HeroDied", "addr", addr, "monster", monster.Name)144 }145}146147// 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 / 4165 if heal < 1 {166 heal = 1167 }168 hero.HP += heal169 if hero.HP > hero.MaxHP {170 hero.HP = hero.MaxHP171 }172 chain.Emit("HeroRested", "addr", addr, "hp", strconv.Itoa(hero.HP))173}174175// 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 = true190 hero.HP = hero.MaxHP / 2191 if hero.HP < 1 {192 hero.HP = 1193 }194 chain.Emit("HeroRevived", "addr", addr, "hp", strconv.Itoa(hero.HP))195}196197// levelUp applies every level-up a hero's current XP qualifies for. The198// 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 * 50202 h.Level++203 h.MaxHP += 10204 h.HP = h.MaxHP205 h.Attack += 3206 }207}208209// byRank orders heroes for the leaderboard: highest level first, then XP,210// then kills, as a tiebreaker.211type byRank []*Hero212213func (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].Level218 }219 if r[i].XP != r[j].XP {220 return r[i].XP > r[j].XP221 }222 return r[i].Kills > r[j].Kills223}224225func collectHeroes() []*Hero {226 var out []*Hero227 heroes.Iterate("", "", func(key string, value any) bool {228 out = append(out, value.(*Hero))229 return false230 })231 return out232}233234func hpBar(hp, maxHP int) string {235 if maxHP <= 0 {236 maxHP = 1237 }238 filled := hp * 10 / maxHP239 if filled < 0 {240 filled = 0241 }242 if filled > 10 {243 filled = 10244 }245 bar := strings.Repeat("#", filled) + strings.Repeat("-", 10-filled)246 return "`" + bar + "` " + strconv.Itoa(hp) + "/" + strconv.Itoa(maxHP) + " HP"247}248249// Render shows the room overview and leaderboard at path "", or a single250// 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}258259func renderRoom() string {260 var b strings.Builder261 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")265266 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")271272 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 = 20284 }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}297298func 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.Builder305 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}317Attack(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
Render(path string) string
Rest(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
Revive(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
Spawn(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, name string)
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.