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:
2026-03-23 23:48:30 +09:00
parent e7b12bae08
commit 4fdd7a1ad0
2 changed files with 171 additions and 0 deletions

112
combat/combat.go Normal file
View 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
View 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)
}
}