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:
2026-03-25 15:02:44 +09:00
parent 8ef3d9dd13
commit 22ebeb1d48
5 changed files with 157 additions and 0 deletions

47
entity/elite.go Normal file
View 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
View 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())
}
}

View File

@@ -49,6 +49,8 @@ type Monster struct {
HP, MaxHP int
ATK, DEF int
IsBoss bool
IsElite bool
ElitePrefix ElitePrefixType
TauntTarget bool
TauntTurns int
Pattern BossPattern

View File

@@ -90,6 +90,9 @@ func (s *GameSession) spawnMonsters() {
m.MaxHP = m.HP
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
}

View File

@@ -379,6 +379,17 @@ func (s *GameSession) resolveMonsterActions() {
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0)
p.TakeDamage(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() {
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
}