feat: combat system — damage calc, co-op bonus, flee, monster AI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
112
combat/combat.go
Normal file
112
combat/combat.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package combat
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
|
||||
"github.com/tolelom/catacombs/entity"
|
||||
)
|
||||
|
||||
// CalcDamage: max(1, ATK * multiplier - DEF) * random(0.85~1.15)
|
||||
func CalcDamage(atk, def int, multiplier float64) int {
|
||||
base := float64(atk)*multiplier - float64(def)
|
||||
if base < 1 {
|
||||
base = 1
|
||||
}
|
||||
randomFactor := 0.85 + rand.Float64()*0.30
|
||||
return int(math.Round(base * randomFactor))
|
||||
}
|
||||
|
||||
type AttackIntent struct {
|
||||
PlayerATK int
|
||||
TargetIdx int
|
||||
Multiplier float64
|
||||
IsAoE bool
|
||||
}
|
||||
|
||||
type AttackResult struct {
|
||||
TargetIdx int
|
||||
Damage int
|
||||
CoopApplied bool
|
||||
IsAoE bool
|
||||
}
|
||||
|
||||
func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster) []AttackResult {
|
||||
targetCount := make(map[int]int)
|
||||
targetOrder := make(map[int]int)
|
||||
for i, intent := range intents {
|
||||
if !intent.IsAoE {
|
||||
targetCount[intent.TargetIdx]++
|
||||
if _, ok := targetOrder[intent.TargetIdx]; !ok {
|
||||
targetOrder[intent.TargetIdx] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results := make([]AttackResult, len(intents))
|
||||
for i, intent := range intents {
|
||||
if intent.IsAoE {
|
||||
totalDmg := 0
|
||||
for _, m := range monsters {
|
||||
if !m.IsDead() {
|
||||
dmg := CalcDamage(intent.PlayerATK, m.DEF, intent.Multiplier)
|
||||
m.TakeDamage(dmg)
|
||||
totalDmg += dmg
|
||||
}
|
||||
}
|
||||
results[i] = AttackResult{TargetIdx: -1, Damage: totalDmg, IsAoE: true}
|
||||
} else {
|
||||
if intent.TargetIdx < 0 || intent.TargetIdx >= len(monsters) {
|
||||
continue
|
||||
}
|
||||
m := monsters[intent.TargetIdx]
|
||||
dmg := CalcDamage(intent.PlayerATK, m.DEF, intent.Multiplier)
|
||||
coopApplied := false
|
||||
if targetCount[intent.TargetIdx] >= 2 && targetOrder[intent.TargetIdx] != i {
|
||||
dmg = int(math.Round(float64(dmg) * 1.10))
|
||||
coopApplied = true
|
||||
}
|
||||
m.TakeDamage(dmg)
|
||||
results[i] = AttackResult{
|
||||
TargetIdx: intent.TargetIdx,
|
||||
Damage: dmg,
|
||||
CoopApplied: coopApplied,
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func AttemptFlee() bool {
|
||||
return rand.Float64() < 0.5
|
||||
}
|
||||
|
||||
func MonsterAI(m *entity.Monster, players []*entity.Player, turnNumber int) (targetIdx int, isAoE bool) {
|
||||
if m.IsBoss && turnNumber%3 == 0 {
|
||||
return -1, true
|
||||
}
|
||||
if m.TauntTarget {
|
||||
for i, p := range players {
|
||||
if !p.IsDead() && p.Class == entity.ClassWarrior {
|
||||
return i, false
|
||||
}
|
||||
}
|
||||
}
|
||||
if rand.Float64() < 0.3 {
|
||||
minHP := int(^uint(0) >> 1)
|
||||
minIdx := 0
|
||||
for i, p := range players {
|
||||
if !p.IsDead() && p.HP < minHP {
|
||||
minHP = p.HP
|
||||
minIdx = i
|
||||
}
|
||||
}
|
||||
return minIdx, false
|
||||
}
|
||||
for i, p := range players {
|
||||
if !p.IsDead() {
|
||||
return i, false
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
59
combat/combat_test.go
Normal file
59
combat/combat_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package combat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tolelom/catacombs/entity"
|
||||
)
|
||||
|
||||
func TestCalcDamage(t *testing.T) {
|
||||
dmg := CalcDamage(12, 1, 1.0)
|
||||
if dmg < 9 || dmg > 13 {
|
||||
t.Errorf("Damage out of expected range: got %d, want 9~13", dmg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalcDamageMinimum(t *testing.T) {
|
||||
dmg := CalcDamage(1, 100, 1.0)
|
||||
if dmg != 1 {
|
||||
t.Errorf("Minimum damage: got %d, want 1", dmg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoopBonus(t *testing.T) {
|
||||
attackers := []AttackIntent{
|
||||
{PlayerATK: 12, TargetIdx: 0, Multiplier: 1.0, IsAoE: false},
|
||||
{PlayerATK: 15, TargetIdx: 0, Multiplier: 1.0, IsAoE: false},
|
||||
}
|
||||
results := ResolveAttacks(attackers, []*entity.Monster{entity.NewMonster(entity.MonsterSlime, 1)})
|
||||
if !results[1].CoopApplied {
|
||||
t.Error("Second attacker should get co-op bonus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAoENoCoopBonus(t *testing.T) {
|
||||
attackers := []AttackIntent{
|
||||
{PlayerATK: 12, TargetIdx: 0, Multiplier: 1.0, IsAoE: false},
|
||||
{PlayerATK: 20, TargetIdx: -1, Multiplier: 0.8, IsAoE: true},
|
||||
}
|
||||
monsters := []*entity.Monster{
|
||||
entity.NewMonster(entity.MonsterSlime, 1),
|
||||
entity.NewMonster(entity.MonsterSlime, 1),
|
||||
}
|
||||
results := ResolveAttacks(attackers, monsters)
|
||||
if results[0].CoopApplied {
|
||||
t.Error("AoE should not trigger co-op bonus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleeChance(t *testing.T) {
|
||||
successes := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
if AttemptFlee() {
|
||||
successes++
|
||||
}
|
||||
}
|
||||
if successes < 20 || successes > 80 {
|
||||
t.Errorf("Flee success rate suspicious: %d/100", successes)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user