feat: add elite monsters with 5 prefix types
Elite monsters have ~20% spawn chance with Venomous, Burning, Freezing, Bleeding, or Vampiric prefixes. Each prefix scales HP/ATK and applies on-hit status effects (or life drain for Vampiric). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
47
entity/elite.go
Normal file
47
entity/elite.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import "math/rand"
|
||||||
|
|
||||||
|
type ElitePrefixType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
PrefixVenomous ElitePrefixType = iota // poison on hit
|
||||||
|
PrefixBurning // burn on hit
|
||||||
|
PrefixFreezing // freeze on hit
|
||||||
|
PrefixBleeding // bleed on hit
|
||||||
|
PrefixVampiric // heals self on hit
|
||||||
|
)
|
||||||
|
|
||||||
|
type ElitePrefixDef struct {
|
||||||
|
Name string
|
||||||
|
HPMult float64
|
||||||
|
ATKMult float64
|
||||||
|
OnHit StatusEffect // -1 for vampiric (special)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ElitePrefixDefs is exported so game/turn.go can access for on-hit effects.
|
||||||
|
var ElitePrefixDefs = map[ElitePrefixType]ElitePrefixDef{
|
||||||
|
PrefixVenomous: {"Venomous", 1.3, 1.0, StatusPoison},
|
||||||
|
PrefixBurning: {"Burning", 1.2, 1.1, StatusBurn},
|
||||||
|
PrefixFreezing: {"Freezing", 1.2, 1.0, StatusFreeze},
|
||||||
|
PrefixBleeding: {"Bleeding", 1.1, 1.2, StatusBleed},
|
||||||
|
PrefixVampiric: {"Vampiric", 1.4, 1.1, StatusEffect(-1)},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p ElitePrefixType) String() string {
|
||||||
|
return ElitePrefixDefs[p].Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func RandomPrefix() ElitePrefixType {
|
||||||
|
return ElitePrefixType(rand.Intn(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyPrefix(m *Monster, prefix ElitePrefixType) {
|
||||||
|
def := ElitePrefixDefs[prefix]
|
||||||
|
m.IsElite = true
|
||||||
|
m.ElitePrefix = prefix
|
||||||
|
m.Name = def.Name + " " + m.Name
|
||||||
|
m.HP = int(float64(m.HP) * def.HPMult)
|
||||||
|
m.MaxHP = m.HP
|
||||||
|
m.ATK = int(float64(m.ATK) * def.ATKMult)
|
||||||
|
}
|
||||||
94
entity/elite_test.go
Normal file
94
entity/elite_test.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestApplyPrefix(t *testing.T) {
|
||||||
|
m := &Monster{
|
||||||
|
Name: "Slime",
|
||||||
|
HP: 100,
|
||||||
|
MaxHP: 100,
|
||||||
|
ATK: 10,
|
||||||
|
DEF: 5,
|
||||||
|
}
|
||||||
|
ApplyPrefix(m, PrefixVenomous)
|
||||||
|
|
||||||
|
if !m.IsElite {
|
||||||
|
t.Fatal("expected IsElite to be true")
|
||||||
|
}
|
||||||
|
if m.ElitePrefix != PrefixVenomous {
|
||||||
|
t.Fatalf("expected PrefixVenomous, got %d", m.ElitePrefix)
|
||||||
|
}
|
||||||
|
if m.Name != "Venomous Slime" {
|
||||||
|
t.Fatalf("expected 'Venomous Slime', got %q", m.Name)
|
||||||
|
}
|
||||||
|
// HP should be multiplied by 1.3 => 130
|
||||||
|
if m.HP != 130 {
|
||||||
|
t.Fatalf("expected HP=130, got %d", m.HP)
|
||||||
|
}
|
||||||
|
if m.MaxHP != 130 {
|
||||||
|
t.Fatalf("expected MaxHP=130, got %d", m.MaxHP)
|
||||||
|
}
|
||||||
|
// ATK mult is 1.0 for Venomous, so ATK stays 10
|
||||||
|
if m.ATK != 10 {
|
||||||
|
t.Fatalf("expected ATK=10, got %d", m.ATK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyPrefixVampiric(t *testing.T) {
|
||||||
|
m := &Monster{
|
||||||
|
Name: "Orc",
|
||||||
|
HP: 100,
|
||||||
|
MaxHP: 100,
|
||||||
|
ATK: 20,
|
||||||
|
DEF: 5,
|
||||||
|
}
|
||||||
|
ApplyPrefix(m, PrefixVampiric)
|
||||||
|
|
||||||
|
if !m.IsElite {
|
||||||
|
t.Fatal("expected IsElite to be true")
|
||||||
|
}
|
||||||
|
if m.Name != "Vampiric Orc" {
|
||||||
|
t.Fatalf("expected 'Vampiric Orc', got %q", m.Name)
|
||||||
|
}
|
||||||
|
// HP * 1.4 = 140
|
||||||
|
if m.HP != 140 {
|
||||||
|
t.Fatalf("expected HP=140, got %d", m.HP)
|
||||||
|
}
|
||||||
|
// ATK * 1.1 = 22
|
||||||
|
if m.ATK != 22 {
|
||||||
|
t.Fatalf("expected ATK=22, got %d", m.ATK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRandomPrefix(t *testing.T) {
|
||||||
|
seen := make(map[ElitePrefixType]bool)
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
p := RandomPrefix()
|
||||||
|
if p < 0 || p > 4 {
|
||||||
|
t.Fatalf("RandomPrefix returned out-of-range value: %d", p)
|
||||||
|
}
|
||||||
|
seen[p] = true
|
||||||
|
}
|
||||||
|
// With 200 tries, all 5 prefixes should appear
|
||||||
|
if len(seen) != 5 {
|
||||||
|
t.Fatalf("expected all 5 prefixes to appear, got %d distinct values", len(seen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestElitePrefixString(t *testing.T) {
|
||||||
|
if PrefixVenomous.String() != "Venomous" {
|
||||||
|
t.Fatalf("expected 'Venomous', got %q", PrefixVenomous.String())
|
||||||
|
}
|
||||||
|
if PrefixBurning.String() != "Burning" {
|
||||||
|
t.Fatalf("expected 'Burning', got %q", PrefixBurning.String())
|
||||||
|
}
|
||||||
|
if PrefixFreezing.String() != "Freezing" {
|
||||||
|
t.Fatalf("expected 'Freezing', got %q", PrefixFreezing.String())
|
||||||
|
}
|
||||||
|
if PrefixBleeding.String() != "Bleeding" {
|
||||||
|
t.Fatalf("expected 'Bleeding', got %q", PrefixBleeding.String())
|
||||||
|
}
|
||||||
|
if PrefixVampiric.String() != "Vampiric" {
|
||||||
|
t.Fatalf("expected 'Vampiric', got %q", PrefixVampiric.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,8 @@ type Monster struct {
|
|||||||
HP, MaxHP int
|
HP, MaxHP int
|
||||||
ATK, DEF int
|
ATK, DEF int
|
||||||
IsBoss bool
|
IsBoss bool
|
||||||
|
IsElite bool
|
||||||
|
ElitePrefix ElitePrefixType
|
||||||
TauntTarget bool
|
TauntTarget bool
|
||||||
TauntTurns int
|
TauntTurns int
|
||||||
Pattern BossPattern
|
Pattern BossPattern
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ func (s *GameSession) spawnMonsters() {
|
|||||||
m.MaxHP = m.HP
|
m.MaxHP = m.HP
|
||||||
m.DEF = int(float64(m.DEF) * s.cfg.Combat.SoloHPReduction)
|
m.DEF = int(float64(m.DEF) * s.cfg.Combat.SoloHPReduction)
|
||||||
}
|
}
|
||||||
|
if rand.Float64() < 0.20 {
|
||||||
|
entity.ApplyPrefix(m, entity.RandomPrefix())
|
||||||
|
}
|
||||||
s.state.Monsters[i] = m
|
s.state.Monsters[i] = m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
game/turn.go
11
game/turn.go
@@ -379,6 +379,17 @@ func (s *GameSession) resolveMonsterActions() {
|
|||||||
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0)
|
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0)
|
||||||
p.TakeDamage(dmg)
|
p.TakeDamage(dmg)
|
||||||
s.addLog(fmt.Sprintf("%s attacks %s for %d dmg", m.Name, p.Name, dmg))
|
s.addLog(fmt.Sprintf("%s attacks %s for %d dmg", m.Name, p.Name, dmg))
|
||||||
|
if m.IsElite {
|
||||||
|
def := entity.ElitePrefixDefs[m.ElitePrefix]
|
||||||
|
if def.OnHit >= 0 {
|
||||||
|
p.AddEffect(entity.ActiveEffect{Type: def.OnHit, Duration: 2, Value: 3})
|
||||||
|
s.addLog(fmt.Sprintf("%s's %s effect afflicts %s!", m.Name, def.Name, p.Name))
|
||||||
|
} else if m.ElitePrefix == entity.PrefixVampiric {
|
||||||
|
heal := dmg / 4
|
||||||
|
m.HP = min(m.HP+heal, m.MaxHP)
|
||||||
|
s.addLog(fmt.Sprintf("%s drains life from %s! (+%d HP)", m.Name, p.Name, heal))
|
||||||
|
}
|
||||||
|
}
|
||||||
if p.IsDead() {
|
if p.IsDead() {
|
||||||
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
|
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user