Files
Catacombs/combat/combat.go
tolelom 1563091de1 fix: 13 bugs found via systematic code review and testing
Multiplayer:
- Add WaitingScreen between class select and game start; previously
  selecting a class immediately started the game and locked the room,
  preventing other players from joining
- Add periodic lobby room list refresh (2s interval)
- Add LeaveRoom method for backing out of waiting room

Combat & mechanics:
- Mark invalid attack targets with TargetIdx=-1 to suppress misleading
  "0 dmg" combat log entries
- Make Freeze effect actually skip frozen player's action (was purely
  cosmetic before - expired during tick before action processing)
- Implement Life Siphon relic heal-on-damage effect (was defined but
  never applied in combat)
- Fix combo matching to track used actions and prevent reuse

Game modes:
- Wire up weekly mutations to GameSession via ApplyWeeklyMutation()
- Implement 3 mutation runtime effects: no_shop, glass_cannon, elite_flood
- Pass HardMode toggle from lobby UI through Context to GameSession
- Apply HardMode difficulty multipliers (1.5x monsters, 2x shop, 0.5x heal)

Polish:
- Set starting room (index 0) to always be Empty (safe start)
- Distinguish shop purchase errors: "Not enough gold" vs "Inventory full"
- Record random events in codex for discovery tracking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:45:56 +09:00

120 lines
2.8 KiB
Go

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, coopBonus float64) []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) {
results[i] = AttackResult{TargetIdx: -1} // mark as invalid
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.0 + coopBonus)))
coopApplied = true
}
m.TakeDamage(dmg)
results[i] = AttackResult{
TargetIdx: intent.TargetIdx,
Damage: dmg,
CoopApplied: coopApplied,
}
}
}
return results
}
func AttemptFlee(fleeChance float64) bool {
return rand.Float64() < fleeChance
}
func MonsterAI(m *entity.Monster, players []*entity.Player, turnNumber int) (targetIdx int, isAoE bool) {
if (m.IsBoss || m.IsMiniBoss) && turnNumber > 0 && turnNumber%3 == 0 {
return -1, true // AoE every 3 turns for all bosses and mini-bosses
}
if m.TauntTarget {
for i, p := range players {
if !p.IsDead() && p.Class == entity.ClassWarrior {
return i, false
}
}
// No living warrior found — clear taunt
m.TauntTarget = false
m.TauntTurns = 0
}
if rand.Float64() < 0.3 {
minHP := int(^uint(0) >> 1)
minIdx := -1
for i, p := range players {
if !p.IsDead() && p.HP < minHP {
minHP = p.HP
minIdx = i
}
}
if minIdx >= 0 {
return minIdx, false
}
// Fall through to default targeting if no alive player found
}
for i, p := range players {
if !p.IsDead() {
return i, false
}
}
return 0, false
}