Skip to content

Commit 1a52206

Browse files
authored
Merge pull request #73 from IamMrCupp/feat/rpg-hp
feat(idlerpg): hit points + downed state (#67)
2 parents 6648d11 + 42b0ebe commit 1a52206

6 files changed

Lines changed: 143 additions & 7 deletions

File tree

docs/idlerpg.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ the NAMES list — you don't have to rejoin or speak to resume progress.
4646
(a big swing either way).
4747
- **Alignment** (`!rpg align good|neutral|evil`). Good fights at +11% power; evil
4848
crits twice as often; neutral is baseline.
49+
- **HP.** Derived from CON, level, and your class hit die. Losing a fight (and,
50+
later, monsters) deals damage; at 0 HP you're **downed** — no progress until you
51+
heal back, which happens a little each tick. `!rpg sheet` shows `HP cur/max`.
4952
- **Class** (`!rpg class <fighter|ranger|rogue|cleric|bard|wizard>`). Mechanical:
5053
each keys off a primary ability (fighter STR, wizard INT, rogue/ranger DEX,
5154
cleric WIS, bard CHA) whose modifier is added to your attack power, and whose

internal/idlerpg/hp.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package idlerpg
2+
3+
import "context"
4+
5+
// Hit points give combat stakes. We store damage *taken* (the "dmg" field) rather
6+
// than current HP, so an unset field (0) naturally means full health — no sentinel
7+
// needed. Current HP = maxHP − dmg. At 0 a character is "downed": they can't
8+
// progress until they heal back up (a little each tick). idlerpg.org has no HP at
9+
// all; this is core D&D feel.
10+
11+
const baseHP = 8
12+
13+
// maxHP derives the hit-point ceiling from level, CON, and the class hit die.
14+
func maxHP(sheet map[string]int64, class string) int64 {
15+
die := int64(8) // unclassed characters use a d8
16+
if c, ok := classOf(class); ok {
17+
die = c.HitDie
18+
}
19+
perLevel := die/2 + 1 + abilityMod(sheet["con"]) // average die + CON modifier
20+
if perLevel < 1 {
21+
perLevel = 1
22+
}
23+
return baseHP + perLevel*(sheet["level"]+1)
24+
}
25+
26+
// curHP returns current hit points (clamped at 0).
27+
func curHP(sheet map[string]int64, class string) int64 {
28+
hp := maxHP(sheet, class) - sheet["dmg"]
29+
if hp < 0 {
30+
return 0
31+
}
32+
return hp
33+
}
34+
35+
// isDowned reports whether the character is at 0 HP.
36+
func isDowned(sheet map[string]int64, class string) bool {
37+
return curHP(sheet, class) <= 0
38+
}
39+
40+
// damage applies amt damage and returns the new damage total.
41+
func (m *Manager) damage(ctx context.Context, key string, amt int64) int64 {
42+
d, _ := m.store.HIncr(ctx, sheetKey(key), "dmg", amt)
43+
return d
44+
}
45+
46+
// heal removes amt damage, clamped so a character never goes below zero damage.
47+
func (m *Manager) heal(ctx context.Context, key string, amt int64) {
48+
if d, _ := m.store.HIncr(ctx, sheetKey(key), "dmg", -amt); d < 0 {
49+
_ = m.store.HSet(ctx, sheetKey(key), "dmg", 0)
50+
}
51+
}
52+
53+
// tickHP applies natural healing for one tick and reports whether the character is
54+
// still downed afterward (so the caller can freeze their progress).
55+
func (m *Manager) tickHP(ctx context.Context, key string) bool {
56+
sheet, err := m.store.HGetAll(ctx, sheetKey(key))
57+
if err != nil {
58+
return false
59+
}
60+
class, _ := m.store.GetStr(ctx, classKey(key))
61+
dmg := sheet["dmg"]
62+
if dmg > 0 {
63+
regen := sheet["level"]/2 + abilityMod(sheet["con"]) + 1
64+
if regen < 1 {
65+
regen = 1
66+
}
67+
m.heal(ctx, key, regen)
68+
if dmg -= regen; dmg < 0 {
69+
dmg = 0
70+
}
71+
}
72+
return maxHP(sheet, class)-dmg <= 0
73+
}

internal/idlerpg/idlerpg.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -370,11 +370,13 @@ func (m *Manager) sheet(msg engine.Message, fields []string) string {
370370
}
371371
m.ensureAbilities(ctx, pkey)
372372
sheet, _ := m.store.HGetAll(ctx, sheetKey(pkey))
373+
class, _ := m.store.GetStr(ctx, classKey(pkey))
373374
desc := alignName(sheet["align"])
374-
if class, _ := m.store.GetStr(ctx, classKey(pkey)); class != "" {
375+
if class != "" {
375376
desc += " " + class
376377
}
377-
return fmt.Sprintf("%s the %s (lvl %d) — %s", name, desc, sheet["level"], abilityLine(sheet))
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))
378380
}
379381

380382
// adminVerb handles the privileged !rpg commands (pause/resume/push/hog). They are
@@ -558,6 +560,9 @@ func (m *Manager) Tick() {
558560
ctx := context.Background()
559561
key := sheetKey(p.key)
560562
m.moveOnMap(ctx, p.key) // wander the world map (cosmetic; happens every tick)
563+
if m.tickHP(ctx, p.key) {
564+
continue // downed and recovering — no progress this tick
565+
}
561566
ttl, err := m.store.HIncr(ctx, key, "ttl", -step)
562567
if err != nil {
563568
continue
@@ -597,9 +602,8 @@ func (m *Manager) battle(ctx context.Context, p player, level int64) {
597602
effPow = myPow * 111 / 100
598603
}
599604
// Class: your primary ability's modifier sharpens (or dulls) your attack.
600-
if cls, _ := m.store.GetStr(ctx, classKey(p.key)); cls != "" {
601-
effPow += classAttackMod(mine, cls)
602-
}
605+
myClass, _ := m.store.GetStr(ctx, classKey(p.key))
606+
effPow += classAttackMod(mine, myClass)
603607
if effPow < 0 {
604608
effPow = 0
605609
}
@@ -615,8 +619,17 @@ func (m *Manager) battle(ctx context.Context, p player, level int64) {
615619
}
616620

617621
verb, dir, sign := "won", "sooner", int64(-1)
622+
downed := false
618623
if !win {
619624
verb, dir, sign = "lost", "later", int64(1)
625+
// Losing the bout also costs blood — and can leave you downed.
626+
hurt := int64(m.roll(int(level)+3) + 2)
627+
if crit {
628+
hurt *= 2
629+
}
630+
m.damage(ctx, p.key, hurt)
631+
after, _ := m.store.HGetAll(ctx, sheetKey(p.key))
632+
downed = isDowned(after, myClass)
620633
}
621634
_, _ = m.store.HIncr(ctx, sheetKey(p.key), "ttl", sign*amt)
622635

@@ -626,6 +639,9 @@ func (m *Manager) battle(ctx context.Context, p player, level int64) {
626639
}
627640
m.out.Say(p.network, p.channel, fmt.Sprintf("🗡️ %s [%d] challenged %s [%d] in combat and %s%s — %ds %s.",
628641
p.nick, myPow, opp.nick, oppPow, verb, critStr, amt, dir))
642+
if downed {
643+
m.out.Say(p.network, p.channel, fmt.Sprintf("💢 %s is beaten down to 0 HP and must recover before pressing on.", p.nick))
644+
}
629645
}
630646

631647
const eventOdds = 6 // ~1-in-N chance an event fires each tick

internal/idlerpg/idlerpg_test.go

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,45 @@ func TestStatusCommand(t *testing.T) {
512512
}
513513
}
514514

515+
func TestMaxHP(t *testing.T) {
516+
sheet := map[string]int64{"level": 2, "con": 14} // CON 14 → +2
517+
if got := maxHP(sheet, "fighter"); got != 32 { // d10: (5+1+2)*3 + 8
518+
t.Fatalf("fighter L2 CON14 maxHP = %d; want 32", got)
519+
}
520+
if got := maxHP(sheet, ""); got != 29 { // unclassed d8: (4+1+2)*3 + 8
521+
t.Fatalf("unclassed L2 CON14 maxHP = %d; want 29", got)
522+
}
523+
}
524+
525+
func TestDamageDownedAndHeal(t *testing.T) {
526+
m, _, st := newMgr()
527+
ctx := context.Background()
528+
m.Handle(chanMsg("alice", "!rpg"))
529+
m.damage(ctx, "net|alice", 1000)
530+
if s, _ := st.HGetAll(ctx, sheetKey("net|alice")); !isDowned(s, "") {
531+
t.Fatal("massive damage should leave the character downed")
532+
}
533+
m.heal(ctx, "net|alice", 1000)
534+
s, _ := st.HGetAll(ctx, sheetKey("net|alice"))
535+
if isDowned(s, "") || s["dmg"] != 0 {
536+
t.Fatalf("a full heal should clear downed and clamp dmg to 0, got dmg=%d", s["dmg"])
537+
}
538+
}
539+
540+
func TestDownedFreezesProgress(t *testing.T) {
541+
m, r, st := newMgr() // 1s tick/ttl: a healthy player would level
542+
ctx := context.Background()
543+
m.Handle(chanMsg("alice", "!rpg"))
544+
m.damage(ctx, "net|alice", 1000) // down alice
545+
m.Tick()
546+
if r.has("attained level") {
547+
t.Fatal("a downed player must not level up")
548+
}
549+
if s, _ := st.HGetAll(ctx, sheetKey("net|alice")); s["level"] != 0 {
550+
t.Fatalf("downed level = %d; want 0", s["level"])
551+
}
552+
}
553+
515554
func TestClassMustBeCanonical(t *testing.T) {
516555
m, r, _ := newMgr()
517556
m.Handle(chanMsg("alice", "!rpg"))
@@ -564,8 +603,8 @@ func TestSheetCommand(t *testing.T) {
564603
m, r, _ := newMgr()
565604
m.Handle(chanMsg("alice", "!rpg"))
566605
m.Handle(chanMsg("alice", "!rpg sheet"))
567-
if !strings.Contains(r.last(), "STR") || !strings.Contains(r.last(), "CHA") {
568-
t.Fatalf("!rpg sheet should show the ability block, got %q", r.last())
606+
if !strings.Contains(r.last(), "STR") || !strings.Contains(r.last(), "CHA") || !strings.Contains(r.last(), "HP") {
607+
t.Fatalf("!rpg sheet should show HP + the ability block, got %q", r.last())
569608
}
570609
}
571610

internal/idlerpg/read.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type CharView struct {
2626
Key string // canonical character key (network|nick, or a linked account)
2727
Name string // display name: the key with any "network|" prefix stripped
2828
Level int64 // current level
29+
HP int64 // current hit points
30+
MaxHP int64 // hit-point ceiling
2931
TTL int64 // seconds to the next level
3032
Power int64 // total equipment power (sum of item levels)
3133
Align string // "good" / "neutral" / "evil"
@@ -138,6 +140,8 @@ func readChar(ctx context.Context, store state.Store, key string) CharView {
138140
Key: key,
139141
Name: displayName(key),
140142
Level: sheet["level"],
143+
HP: curHP(sheet, class),
144+
MaxHP: maxHP(sheet, class),
141145
TTL: sheet["ttl"],
142146
Power: itemSum(sheet),
143147
Align: alignName(sheet["align"]),

internal/rpgweb/rpgweb.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ const charTmpl = `<!doctype html>
246246
247247
<table>
248248
<tr><td class="k">level</td><td class="lvl">{{.Level}}</td></tr>
249+
<tr><td class="k">hp</td><td>{{.HP}} <span class="muted">/ {{.MaxHP}}</span></td></tr>
249250
<tr><td class="k">time to next</td><td class="muted">{{dur .TTL}}</td></tr>
250251
<tr><td class="k">power</td><td>{{.Power}}</td></tr>
251252
</table>

0 commit comments

Comments
 (0)