From f85775dd3e5bdfb219d0417ba63cc01d3ecf6745 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 13:08:52 +0900 Subject: [PATCH] feat: replace all hardcoded constants with config values Replace hardcoded game constants with values from the config system: - GameSession now receives *config.Config from Lobby - TurnTimeout, MaxFloors, SkillUses, InventoryLimit use config values - combat.AttemptFlee accepts fleeChance param - combat.ResolveAttacks accepts coopBonus param - entity.NewMonster accepts scaling param - Solo HP/DEF reduction uses config SoloHPReduction - Lobby JoinRoom uses config MaxPlayers Co-Authored-By: Claude Opus 4.6 (1M context) --- combat/combat.go | 8 ++++---- combat/combat_test.go | 10 +++++----- entity/monster.go | 4 ++-- entity/monster_test.go | 12 ++++++------ game/event.go | 18 +++++++++--------- game/lobby.go | 6 +++++- game/session.go | 7 +++++-- game/session_test.go | 19 +++++++++++++------ game/turn.go | 13 ++++++------- ui/model.go | 2 +- 10 files changed, 56 insertions(+), 43 deletions(-) diff --git a/combat/combat.go b/combat/combat.go index 78873dc..cd0ae8b 100644 --- a/combat/combat.go +++ b/combat/combat.go @@ -31,7 +31,7 @@ type AttackResult struct { IsAoE bool } -func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster) []AttackResult { +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 { @@ -63,7 +63,7 @@ func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster) []Attack 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)) + dmg = int(math.Round(float64(dmg) * (1.0 + coopBonus))) coopApplied = true } m.TakeDamage(dmg) @@ -77,8 +77,8 @@ func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster) []Attack return results } -func AttemptFlee() bool { - return rand.Float64() < 0.5 +func AttemptFlee(fleeChance float64) bool { + return rand.Float64() < fleeChance } func MonsterAI(m *entity.Monster, players []*entity.Player, turnNumber int) (targetIdx int, isAoE bool) { diff --git a/combat/combat_test.go b/combat/combat_test.go index ee9c47a..4a2f6f6 100644 --- a/combat/combat_test.go +++ b/combat/combat_test.go @@ -25,7 +25,7 @@ func TestCoopBonus(t *testing.T) { {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)}) + results := ResolveAttacks(attackers, []*entity.Monster{entity.NewMonster(entity.MonsterSlime, 1, 1.15)}, 0.10) if !results[1].CoopApplied { t.Error("Second attacker should get co-op bonus") } @@ -37,10 +37,10 @@ func TestAoENoCoopBonus(t *testing.T) { {PlayerATK: 20, TargetIdx: -1, Multiplier: 0.8, IsAoE: true}, } monsters := []*entity.Monster{ - entity.NewMonster(entity.MonsterSlime, 1), - entity.NewMonster(entity.MonsterSlime, 1), + entity.NewMonster(entity.MonsterSlime, 1, 1.15), + entity.NewMonster(entity.MonsterSlime, 1, 1.15), } - results := ResolveAttacks(attackers, monsters) + results := ResolveAttacks(attackers, monsters, 0.10) if results[0].CoopApplied { t.Error("AoE should not trigger co-op bonus") } @@ -68,7 +68,7 @@ func TestMonsterAITauntDeadWarrior(t *testing.T) { func TestFleeChance(t *testing.T) { successes := 0 for i := 0; i < 100; i++ { - if AttemptFlee() { + if AttemptFlee(0.50) { successes++ } } diff --git a/entity/monster.go b/entity/monster.go index d2155df..962a2d1 100644 --- a/entity/monster.go +++ b/entity/monster.go @@ -54,11 +54,11 @@ type Monster struct { Pattern BossPattern } -func NewMonster(mt MonsterType, floor int) *Monster { +func NewMonster(mt MonsterType, floor int, scaling float64) *Monster { base := monsterDefs[mt] scale := 1.0 if !base.IsBoss && floor > base.MinFloor { - scale = math.Pow(1.15, float64(floor-base.MinFloor)) + scale = math.Pow(scaling, float64(floor-base.MinFloor)) } hp := int(math.Round(float64(base.HP) * scale)) atk := int(math.Round(float64(base.ATK) * scale)) diff --git a/entity/monster_test.go b/entity/monster_test.go index 604cd7c..ea49b0c 100644 --- a/entity/monster_test.go +++ b/entity/monster_test.go @@ -6,11 +6,11 @@ import ( ) func TestMonsterScaling(t *testing.T) { - slime := NewMonster(MonsterSlime, 1) + slime := NewMonster(MonsterSlime, 1, 1.15) if slime.HP != 20 || slime.ATK != 5 { t.Errorf("Slime floor 1: got HP=%d ATK=%d, want HP=20 ATK=5", slime.HP, slime.ATK) } - slimeF3 := NewMonster(MonsterSlime, 3) + slimeF3 := NewMonster(MonsterSlime, 3, 1.15) expectedHP := int(math.Round(20 * math.Pow(1.15, 2))) if slimeF3.HP != expectedHP { t.Errorf("Slime floor 3: got HP=%d, want %d", slimeF3.HP, expectedHP) @@ -18,7 +18,7 @@ func TestMonsterScaling(t *testing.T) { } func TestBossStats(t *testing.T) { - boss := NewMonster(MonsterBoss5, 5) + boss := NewMonster(MonsterBoss5, 5, 1.15) if boss.HP != 150 || boss.ATK != 15 || boss.DEF != 8 { t.Errorf("Boss5: got HP=%d ATK=%d DEF=%d, want 150/15/8", boss.HP, boss.ATK, boss.DEF) } @@ -26,12 +26,12 @@ func TestBossStats(t *testing.T) { func TestMonsterDEFScaling(t *testing.T) { // Slime base DEF=1, minFloor=1. At floor 5, scale = 1.15^4 ≈ 1.749 - m := NewMonster(MonsterSlime, 5) + m := NewMonster(MonsterSlime, 5, 1.15) if m.DEF <= 1 { t.Errorf("Slime DEF at floor 5 should be scaled above base 1, got %d", m.DEF) } // Boss DEF should NOT scale - boss := NewMonster(MonsterBoss5, 5) + boss := NewMonster(MonsterBoss5, 5, 1.15) if boss.DEF != 8 { t.Errorf("Boss5 DEF should be base 8, got %d", boss.DEF) } @@ -51,7 +51,7 @@ func TestTickTaunt(t *testing.T) { func TestMonsterAtMinFloor(t *testing.T) { // Slime at floor 1 (minFloor=1) should have base stats - m := NewMonster(MonsterSlime, 1) + m := NewMonster(MonsterSlime, 1, 1.15) if m.HP != 20 || m.ATK != 5 || m.DEF != 1 { t.Errorf("Slime at min floor should be base stats, got HP=%d ATK=%d DEF=%d", m.HP, m.ATK, m.DEF) } diff --git a/game/event.go b/game/event.go index 8157354..2314fb9 100644 --- a/game/event.go +++ b/game/event.go @@ -81,21 +81,21 @@ func (s *GameSession) spawnMonsters() { for i := 0; i < count; i++ { mt := valid[rand.Intn(len(valid))] - m := entity.NewMonster(mt, floor) + m := entity.NewMonster(mt, floor, s.cfg.Combat.MonsterScaling) if s.state.SoloMode { - m.HP = m.HP / 2 + m.HP = int(float64(m.HP) * s.cfg.Combat.SoloHPReduction) if m.HP < 1 { m.HP = 1 } m.MaxHP = m.HP - m.DEF = m.DEF / 2 + m.DEF = int(float64(m.DEF) * s.cfg.Combat.SoloHPReduction) } s.state.Monsters[i] = m } // Reset skill uses for all players at combat start for _, p := range s.state.Players { - p.SkillUses = 3 + p.SkillUses = s.cfg.Game.SkillUses } } @@ -113,7 +113,7 @@ func (s *GameSession) spawnBoss() { default: mt = entity.MonsterBoss5 } - boss := entity.NewMonster(mt, s.state.FloorNum) + boss := entity.NewMonster(mt, s.state.FloorNum, s.cfg.Combat.MonsterScaling) switch mt { case entity.MonsterBoss5: boss.Pattern = entity.PatternAoE @@ -125,22 +125,22 @@ func (s *GameSession) spawnBoss() { boss.Pattern = entity.PatternHeal } if s.state.SoloMode { - boss.HP = boss.HP / 2 + boss.HP = int(float64(boss.HP) * s.cfg.Combat.SoloHPReduction) boss.MaxHP = boss.HP - boss.DEF = boss.DEF / 2 + boss.DEF = int(float64(boss.DEF) * s.cfg.Combat.SoloHPReduction) } s.state.Monsters = []*entity.Monster{boss} // Reset skill uses for all players at combat start for _, p := range s.state.Players { - p.SkillUses = 3 + p.SkillUses = s.cfg.Game.SkillUses } } func (s *GameSession) grantTreasure() { floor := s.state.FloorNum for _, p := range s.state.Players { - if len(p.Inventory) >= 10 { + if len(p.Inventory) >= s.cfg.Game.InventoryLimit { s.addLog(fmt.Sprintf("%s's inventory is full!", p.Name)) continue } diff --git a/game/lobby.go b/game/lobby.go index a2609a0..bfb5c47 100644 --- a/game/lobby.go +++ b/game/lobby.go @@ -53,6 +53,10 @@ func NewLobby(cfg *config.Config) *Lobby { } } +func (l *Lobby) Cfg() *config.Config { + return l.cfg +} + func (l *Lobby) RegisterSession(fingerprint, roomCode string) { l.mu.Lock() defer l.mu.Unlock() @@ -144,7 +148,7 @@ func (l *Lobby) JoinRoom(code, playerName, fingerprint string) error { if !ok { return fmt.Errorf("room %s not found", code) } - if len(room.Players) >= 4 { + if len(room.Players) >= l.cfg.Game.MaxPlayers { return fmt.Errorf("room %s is full", code) } if room.Status != RoomWaiting { diff --git a/game/session.go b/game/session.go index 5cd15f2..9082abc 100644 --- a/game/session.go +++ b/game/session.go @@ -5,6 +5,7 @@ import ( "sync" "time" + "github.com/tolelom/catacombs/config" "github.com/tolelom/catacombs/dungeon" "github.com/tolelom/catacombs/entity" ) @@ -71,6 +72,7 @@ func (s *GameSession) clearLog() { type GameSession struct { mu sync.Mutex + cfg *config.Config state GameState started bool actions map[string]PlayerAction // playerName -> action @@ -85,8 +87,9 @@ type playerActionMsg struct { Action PlayerAction } -func NewGameSession() *GameSession { +func NewGameSession(cfg *config.Config) *GameSession { return &GameSession{ + cfg: cfg, state: GameState{ FloorNum: 1, }, @@ -340,7 +343,7 @@ func (s *GameSession) BuyItem(playerID string, itemIdx int) bool { item := s.state.ShopItems[itemIdx] for _, p := range s.state.Players { if p.Fingerprint == playerID && p.Gold >= item.Price { - if len(p.Inventory) >= 10 { + if len(p.Inventory) >= s.cfg.Game.InventoryLimit { return false } p.Gold -= item.Price diff --git a/game/session_test.go b/game/session_test.go index 952053a..7687531 100644 --- a/game/session_test.go +++ b/game/session_test.go @@ -4,11 +4,18 @@ import ( "testing" "time" + "github.com/tolelom/catacombs/config" "github.com/tolelom/catacombs/entity" ) +func testCfg(t *testing.T) *config.Config { + t.Helper() + cfg, _ := config.Load("") + return cfg +} + func TestGetStateNoRace(t *testing.T) { - s := NewGameSession() + s := NewGameSession(testCfg(t)) p := entity.NewPlayer("Racer", entity.ClassWarrior) p.Fingerprint = "test-fp" s.AddPlayer(p) @@ -40,7 +47,7 @@ func TestGetStateNoRace(t *testing.T) { } func TestSessionTurnTimeout(t *testing.T) { - s := NewGameSession() + s := NewGameSession(testCfg(t)) p := entity.NewPlayer("test", entity.ClassWarrior) p.Fingerprint = "test-fp" s.AddPlayer(p) @@ -62,7 +69,7 @@ func TestSessionTurnTimeout(t *testing.T) { } func TestRevealNextLog(t *testing.T) { - s := NewGameSession() + s := NewGameSession(testCfg(t)) // No logs to reveal if s.RevealNextLog() { @@ -95,7 +102,7 @@ func TestRevealNextLog(t *testing.T) { } func TestDeepCopyIndependence(t *testing.T) { - s := NewGameSession() + s := NewGameSession(testCfg(t)) p := entity.NewPlayer("Test", entity.ClassWarrior) p.Fingerprint = "fp-test" p.Inventory = append(p.Inventory, entity.Item{Name: "Sword", Type: entity.ItemWeapon, Bonus: 5}) @@ -118,7 +125,7 @@ func TestDeepCopyIndependence(t *testing.T) { } func TestBuyItemInventoryFull(t *testing.T) { - s := NewGameSession() + s := NewGameSession(testCfg(t)) p := entity.NewPlayer("Buyer", entity.ClassWarrior) p.Fingerprint = "fp-buyer" p.Gold = 1000 @@ -141,7 +148,7 @@ func TestBuyItemInventoryFull(t *testing.T) { } func TestSendChat(t *testing.T) { - s := NewGameSession() + s := NewGameSession(testCfg(t)) s.SendChat("Alice", "hello") st := s.GetState() if len(st.CombatLog) != 1 || st.CombatLog[0] != "[Alice] hello" { diff --git a/game/turn.go b/game/turn.go index 795739d..ada3622 100644 --- a/game/turn.go +++ b/game/turn.go @@ -10,8 +10,6 @@ import ( "github.com/tolelom/catacombs/entity" ) -const TurnTimeout = 5 * time.Second - func (s *GameSession) RunTurn() { s.mu.Lock() s.state.TurnNum++ @@ -28,9 +26,10 @@ func (s *GameSession) RunTurn() { s.mu.Unlock() // Collect actions with timeout - timer := time.NewTimer(TurnTimeout) + turnTimeout := time.Duration(s.cfg.Game.TurnTimeoutSec) * time.Second + timer := time.NewTimer(turnTimeout) s.mu.Lock() - s.state.TurnDeadline = time.Now().Add(TurnTimeout) + s.state.TurnDeadline = time.Now().Add(turnTimeout) s.mu.Unlock() collected := 0 @@ -180,7 +179,7 @@ func (s *GameSession) resolvePlayerActions() { s.addLog(fmt.Sprintf("%s has no items to use!", p.Name)) } case ActionFlee: - if combat.AttemptFlee() { + if combat.AttemptFlee(s.cfg.Combat.FleeChance) { s.addLog(fmt.Sprintf("%s fled from battle!", p.Name)) s.state.FleeSucceeded = true if s.state.SoloMode { @@ -215,7 +214,7 @@ func (s *GameSession) resolvePlayerActions() { } if len(intents) > 0 && len(s.state.Monsters) > 0 { - results := combat.ResolveAttacks(intents, s.state.Monsters) + results := combat.ResolveAttacks(intents, s.state.Monsters, s.cfg.Game.CoopBonus) for i, r := range results { owner := intentOwners[i] if r.IsAoE { @@ -287,7 +286,7 @@ func (s *GameSession) resolvePlayerActions() { } func (s *GameSession) advanceFloor() { - if s.state.FloorNum >= 20 { + if s.state.FloorNum >= s.cfg.Game.MaxFloors { s.state.Phase = PhaseResult s.state.Victory = true s.state.GameOver = true diff --git a/ui/model.go b/ui/model.go index 9340363..91cf75c 100644 --- a/ui/model.go +++ b/ui/model.go @@ -405,7 +405,7 @@ func (m Model) updateClassSelect(msg tea.Msg) (tea.Model, tea.Cmd) { room := m.lobby.GetRoom(m.roomCode) if room != nil { if room.Session == nil { - room.Session = game.NewGameSession() + room.Session = game.NewGameSession(m.lobby.Cfg()) } m.session = room.Session player := entity.NewPlayer(m.playerName, selectedClass)