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) <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" {
|
||||
|
||||
13
game/turn.go
13
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user