From 4fdd7a1ad03ca9d7f4db95d898d0a64a4eb2e2af Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Mon, 23 Mar 2026 23:48:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20combat=20system=20=E2=80=94=20damage=20?= =?UTF-8?q?calc,=20co-op=20bonus,=20flee,=20monster=20AI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- combat/combat.go | 112 ++++++++++++++++++++++++++++++++++++++++++ combat/combat_test.go | 59 ++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 combat/combat.go create mode 100644 combat/combat_test.go diff --git a/combat/combat.go b/combat/combat.go new file mode 100644 index 0000000..e759acb --- /dev/null +++ b/combat/combat.go @@ -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 +} diff --git a/combat/combat_test.go b/combat/combat_test.go new file mode 100644 index 0000000..f56e75b --- /dev/null +++ b/combat/combat_test.go @@ -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) + } +}