Skip to content

Commit 8529eb2

Browse files
authored
Merge pull request #74 from IamMrCupp/feat/rpg-monsters
feat(idlerpg): monster encounters / PvE (#68)
2 parents 1a52206 + 0395f42 commit 8529eb2

6 files changed

Lines changed: 172 additions & 3 deletions

File tree

docs/idlerpg.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ the NAMES list — you don't have to rejoin or speak to resume progress.
4040
**power**. `!rpg items` shows your kit.
4141
- **Battles.** Leveling up pits you against a random online player, weighted by
4242
power, with a chance of a critical hit. Win and your clock speeds up; lose and
43-
it slows.
43+
it slows (and takes HP).
44+
- **Monster encounters.** At random a wandering idler runs into a level-scaled
45+
monster (giant rat → young dragon) and a quick d20 fight resolves — attack rolls
46+
vs AC, damage both ways. **Works solo** (unlike PvP battles). Win → time toward
47+
your next level, **gold**, a kill, maybe loot; lose → bloodied, or downed.
4448
- **Events.** At random the gods intervene — a **godsend** (time forward), a
4549
**calamity** (time back, or an item loses its luster), or the **Hand of God**
4650
(a big swing either way).

internal/idlerpg/idlerpg.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,8 +375,9 @@ func (m *Manager) sheet(msg engine.Message, fields []string) string {
375375
if class != "" {
376376
desc += " " + class
377377
}
378-
return fmt.Sprintf("%s the %s (lvl %d) — HP %d/%d · %s",
379-
name, desc, sheet["level"], curHP(sheet, class), maxHP(sheet, class), abilityLine(sheet))
378+
return fmt.Sprintf("%s the %s (lvl %d) — HP %d/%d · %dg · %d kills · %s",
379+
name, desc, sheet["level"], curHP(sheet, class), maxHP(sheet, class),
380+
sheet["gold"], sheet["kills"], abilityLine(sheet))
380381
}
381382

382383
// adminVerb handles the privileged !rpg commands (pause/resume/push/hog). They are
@@ -555,6 +556,7 @@ func (m *Manager) Tick() {
555556
step = 1
556557
}
557558
m.maybeEvent(context.Background())
559+
m.maybeMonster(context.Background())
558560
m.questTick(context.Background())
559561
for _, p := range m.snapshot() {
560562
ctx := context.Background()

internal/idlerpg/idlerpg_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,3 +687,38 @@ func TestLeaderboard(t *testing.T) {
687687
t.Fatalf("leaderboard should list players, got %q", r.last())
688688
}
689689
}
690+
691+
func TestPickMonsterRespectsLevel(t *testing.T) {
692+
m, _, _ := newMgr()
693+
if mon := m.pickMonster(0); mon.MinLvl > 0 {
694+
t.Fatalf("level 0 picked %q (MinLvl %d)", mon.Name, mon.MinLvl)
695+
}
696+
for i := 0; i < 30; i++ {
697+
if mon := m.pickMonster(5); mon.MinLvl > 5 {
698+
t.Fatalf("level 5 picked too-strong %q (MinLvl %d)", mon.Name, mon.MinLvl)
699+
}
700+
}
701+
}
702+
703+
func TestResolveFightWinRewards(t *testing.T) {
704+
m, r, st := newMgr()
705+
ctx := context.Background()
706+
m.Handle(chanMsg("alice", "!rpg"))
707+
// A level-20 fighter with STR/DEX 18 always hits a giant rat (AC 10) and one
708+
// hit (d8+4 ≥ 5) kills its 4 HP — a deterministic win regardless of seed.
709+
for _, f := range []string{"str", "dex", "con"} {
710+
st.HSet(ctx, sheetKey("net|alice"), f, 18)
711+
}
712+
st.HSet(ctx, sheetKey("net|alice"), "level", 20)
713+
st.SetStr(ctx, classKey("net|alice"), "fighter")
714+
sheet, _ := st.HGetAll(ctx, sheetKey("net|alice"))
715+
p := player{network: "net", nick: "alice", channel: "#chan", key: "net|alice"}
716+
717+
m.resolveFight(ctx, p, sheet, "fighter", bestiary[0]) // a giant rat
718+
if !r.has("slew") {
719+
t.Fatalf("a juggernaut should slay a giant rat, got %v", r.lines)
720+
}
721+
if s, _ := st.HGetAll(ctx, sheetKey("net|alice")); s["kills"] != 1 || s["gold"] < 1 {
722+
t.Fatalf("a kill should bump kills + gold, got kills=%d gold=%d", s["kills"], s["gold"])
723+
}
724+
}

internal/idlerpg/monsters.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package idlerpg
2+
3+
import (
4+
"context"
5+
"fmt"
6+
)
7+
8+
// Monster encounters are the PvE heart of the D&D layer — and, unlike the level-up
9+
// PvP battle, they work with a single player. On some ticks a wandering idler runs
10+
// into a level-scaled monster and a quick d20 fight resolves: attacker rolls
11+
// d20 + attack vs the defender's AC, hits deal damage, repeat until one drops.
12+
// Win → time toward the next level, gold, a kill, maybe loot. Lose → bloodied or
13+
// downed.
14+
15+
const monsterOdds = 5 // ~1-in-N chance a monster appears for a random idler each tick
16+
17+
// monster is one bestiary entry.
18+
type monster struct {
19+
Name string
20+
MinLvl int64 // first level at which it can appear
21+
AC int64 // armor class (target number to hit it)
22+
Atk int64 // its attack bonus
23+
DmgDie int64 // its damage die (dN)
24+
HP int64
25+
Gold int64 // reward on a kill
26+
}
27+
28+
// bestiary, weakest to nastiest.
29+
var bestiary = []monster{
30+
{"a giant rat", 0, 10, 0, 4, 4, 1},
31+
{"a goblin", 1, 12, 2, 6, 7, 3},
32+
{"a kobold warren-scout", 2, 12, 3, 4, 6, 4},
33+
{"an orc", 4, 13, 4, 8, 16, 8},
34+
{"a gnoll pack-hunter", 6, 14, 4, 8, 24, 12},
35+
{"an ogre", 9, 11, 5, 10, 38, 22},
36+
{"a wyvern", 13, 15, 6, 8, 55, 45},
37+
{"a young dragon", 18, 17, 7, 12, 85, 110},
38+
}
39+
40+
// pickMonster chooses a level-appropriate foe.
41+
func (m *Manager) pickMonster(level int64) monster {
42+
var eligible []monster
43+
for _, mon := range bestiary {
44+
if mon.MinLvl <= level {
45+
eligible = append(eligible, mon)
46+
}
47+
}
48+
if len(eligible) == 0 {
49+
return bestiary[0]
50+
}
51+
return eligible[m.roll(len(eligible))]
52+
}
53+
54+
// maybeMonster occasionally throws a monster at a random online idler.
55+
func (m *Manager) maybeMonster(ctx context.Context) {
56+
if m.roll(monsterOdds) != 0 {
57+
return
58+
}
59+
if p, ok := m.randomOnline(); ok {
60+
m.fightMonster(ctx, p)
61+
}
62+
}
63+
64+
// fightMonster picks a scaled foe and resolves an encounter for p.
65+
func (m *Manager) fightMonster(ctx context.Context, p player) {
66+
sheet, _ := m.store.HGetAll(ctx, sheetKey(p.key))
67+
class, _ := m.store.GetStr(ctx, classKey(p.key))
68+
if isDowned(sheet, class) {
69+
return // already down — can't fight
70+
}
71+
m.resolveFight(ctx, p, sheet, class, m.pickMonster(sheet["level"]))
72+
}
73+
74+
// resolveFight runs the round-by-round combat and applies the outcome.
75+
func (m *Manager) resolveFight(ctx context.Context, p player, sheet map[string]int64, class string, mon monster) {
76+
startHP := curHP(sheet, class)
77+
pHP := startHP
78+
pAC := 10 + abilityMod(sheet["dex"])
79+
pAtk := 2 + sheet["level"]/4 + classAttackMod(sheet, class)
80+
pDmgBonus := classAttackMod(sheet, class)
81+
if pDmgBonus < 0 {
82+
pDmgBonus = 0
83+
}
84+
monHP := mon.HP
85+
86+
for round := 0; round < 30 && pHP > 0 && monHP > 0; round++ {
87+
if int64(m.roll(20)+1)+pAtk >= mon.AC { // player swings (weapon d8)
88+
monHP -= int64(m.roll(8)+1) + pDmgBonus
89+
}
90+
if monHP <= 0 {
91+
break
92+
}
93+
if int64(m.roll(20)+1)+mon.Atk >= pAC { // monster swings
94+
pHP -= int64(m.roll(int(mon.DmgDie)) + 1)
95+
}
96+
}
97+
98+
if taken := startHP - pHP; taken > 0 {
99+
m.damage(ctx, p.key, taken)
100+
}
101+
102+
if monHP <= 0 {
103+
reward := m.pctOfTTL(ctx, p.key, 8, 14)
104+
_, _ = m.store.HIncr(ctx, sheetKey(p.key), "ttl", -reward)
105+
_, _ = m.store.HIncr(ctx, sheetKey(p.key), "gold", mon.Gold)
106+
_, _ = m.store.HIncr(ctx, sheetKey(p.key), "kills", 1)
107+
m.out.Say(p.network, p.channel, fmt.Sprintf(
108+
"⚔️ %s slew %s! +%dg, %ds closer to the next level.", p.nick, mon.Name, mon.Gold, reward))
109+
if m.roll(3) == 0 {
110+
m.findItem(ctx, p, sheet["level"])
111+
}
112+
return
113+
}
114+
115+
if pHP <= 0 {
116+
m.out.Say(p.network, p.channel, fmt.Sprintf(
117+
"💀 %s was felled by %s and left for dead — they must recover before pressing on.", p.nick, mon.Name))
118+
return
119+
}
120+
m.out.Say(p.network, p.channel, fmt.Sprintf(
121+
"🩸 %s fled %s, bloodied but alive.", p.nick, mon.Name))
122+
}

internal/idlerpg/read.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ type CharView struct {
2828
Level int64 // current level
2929
HP int64 // current hit points
3030
MaxHP int64 // hit-point ceiling
31+
Gold int64 // coin from monster kills
32+
Kills int64 // monsters slain
3133
TTL int64 // seconds to the next level
3234
Power int64 // total equipment power (sum of item levels)
3335
Align string // "good" / "neutral" / "evil"
@@ -142,6 +144,8 @@ func readChar(ctx context.Context, store state.Store, key string) CharView {
142144
Level: sheet["level"],
143145
HP: curHP(sheet, class),
144146
MaxHP: maxHP(sheet, class),
147+
Gold: sheet["gold"],
148+
Kills: sheet["kills"],
145149
TTL: sheet["ttl"],
146150
Power: itemSum(sheet),
147151
Align: alignName(sheet["align"]),

internal/rpgweb/rpgweb.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,8 @@ const charTmpl = `<!doctype html>
247247
<table>
248248
<tr><td class="k">level</td><td class="lvl">{{.Level}}</td></tr>
249249
<tr><td class="k">hp</td><td>{{.HP}} <span class="muted">/ {{.MaxHP}}</span></td></tr>
250+
<tr><td class="k">gold</td><td>{{.Gold}}</td></tr>
251+
<tr><td class="k">kills</td><td>{{.Kills}}</td></tr>
250252
<tr><td class="k">time to next</td><td class="muted">{{dur .TTL}}</td></tr>
251253
<tr><td class="k">power</td><td>{{.Power}}</td></tr>
252254
</table>

0 commit comments

Comments
 (0)