diff --git a/game/event.go b/game/event.go new file mode 100644 index 0000000..529d2c1 --- /dev/null +++ b/game/event.go @@ -0,0 +1,138 @@ +package game + +import ( + "math/rand" + + "github.com/tolelom/catacombs/dungeon" + "github.com/tolelom/catacombs/entity" +) + +func (s *GameSession) EnterRoom(roomIdx int) { + s.mu.Lock() + defer s.mu.Unlock() + + s.state.Floor.CurrentRoom = roomIdx + dungeon.UpdateVisibility(s.state.Floor) + room := s.state.Floor.Rooms[roomIdx] + + if room.Cleared { + return + } + + switch room.Type { + case dungeon.RoomCombat: + s.spawnMonsters() + s.state.Phase = PhaseCombat + s.state.CombatTurn = 0 + case dungeon.RoomBoss: + s.spawnBoss() + s.state.Phase = PhaseCombat + s.state.CombatTurn = 0 + case dungeon.RoomShop: + s.generateShopItems() + s.state.Phase = PhaseShop + case dungeon.RoomTreasure: + s.grantTreasure() + room.Cleared = true + case dungeon.RoomEvent: + s.triggerEvent() + room.Cleared = true + case dungeon.RoomEmpty: + room.Cleared = true + } +} + +func (s *GameSession) spawnMonsters() { + count := 1 + rand.Intn(5) // 1~5 monsters + floor := s.state.FloorNum + s.state.Monsters = make([]*entity.Monster, count) + + // Pick appropriate monster type for floor + var mt entity.MonsterType + switch { + case floor <= 5: + mt = entity.MonsterSlime + case floor <= 10: + mt = entity.MonsterSkeleton + case floor <= 14: + mt = entity.MonsterOrc + default: + mt = entity.MonsterDarkKnight + } + + // Solo mode: 50% HP + for i := 0; i < count; i++ { + m := entity.NewMonster(mt, floor) + if s.state.SoloMode { + m.HP = m.HP / 2 + if m.HP < 1 { + m.HP = 1 + } + m.MaxHP = m.HP + } + s.state.Monsters[i] = m + } +} + +func (s *GameSession) spawnBoss() { + var mt entity.MonsterType + switch s.state.FloorNum { + case 5: + mt = entity.MonsterBoss5 + case 10: + mt = entity.MonsterBoss10 + case 15: + mt = entity.MonsterBoss15 + case 20: + mt = entity.MonsterBoss20 + default: + mt = entity.MonsterBoss5 + } + boss := entity.NewMonster(mt, s.state.FloorNum) + if s.state.SoloMode { + boss.HP = boss.HP / 2 + boss.MaxHP = boss.HP + } + s.state.Monsters = []*entity.Monster{boss} +} + +func (s *GameSession) grantTreasure() { + // Random item for each player + for _, p := range s.state.Players { + if rand.Float64() < 0.5 { + p.Inventory = append(p.Inventory, entity.Item{ + Name: "Iron Sword", Type: entity.ItemWeapon, Bonus: 3 + rand.Intn(6), + }) + } else { + p.Inventory = append(p.Inventory, entity.Item{ + Name: "Iron Shield", Type: entity.ItemArmor, Bonus: 2 + rand.Intn(4), + }) + } + } +} + +func (s *GameSession) generateShopItems() { + s.state.ShopItems = []entity.Item{ + {Name: "HP Potion", Type: entity.ItemConsumable, Bonus: 30, Price: 20}, + {Name: "Iron Sword", Type: entity.ItemWeapon, Bonus: 3 + rand.Intn(6), Price: 40 + rand.Intn(41)}, + {Name: "Iron Shield", Type: entity.ItemArmor, Bonus: 2 + rand.Intn(4), Price: 30 + rand.Intn(31)}, + } +} + +func (s *GameSession) triggerEvent() { + // Random event: 50% trap, 50% blessing + for _, p := range s.state.Players { + if p.IsDead() { + continue + } + if rand.Float64() < 0.5 { + // Trap: 10~20 damage + dmg := 10 + rand.Intn(11) + p.TakeDamage(dmg) + } else { + // Blessing: heal 15~25 + heal := 15 + rand.Intn(11) + p.Heal(heal) + } + } +} diff --git a/game/lobby.go b/game/lobby.go new file mode 100644 index 0000000..af03b81 --- /dev/null +++ b/game/lobby.go @@ -0,0 +1,94 @@ +package game + +import ( + "fmt" + "math/rand" + "sync" +) + +type RoomStatus int + +const ( + RoomWaiting RoomStatus = iota + RoomPlaying +) + +type LobbyRoom struct { + Code string + Name string + Players []string + Status RoomStatus + Session *GameSession +} + +type Lobby struct { + mu sync.RWMutex + rooms map[string]*LobbyRoom +} + +func NewLobby() *Lobby { + return &Lobby{rooms: make(map[string]*LobbyRoom)} +} + +func (l *Lobby) CreateRoom(name string) string { + l.mu.Lock() + defer l.mu.Unlock() + code := generateCode() + for l.rooms[code] != nil { + code = generateCode() + } + l.rooms[code] = &LobbyRoom{ + Code: code, + Name: name, + Status: RoomWaiting, + } + return code +} + +func (l *Lobby) JoinRoom(code, playerName string) error { + l.mu.Lock() + defer l.mu.Unlock() + room, ok := l.rooms[code] + if !ok { + return fmt.Errorf("room %s not found", code) + } + if len(room.Players) >= 4 { + return fmt.Errorf("room %s is full", code) + } + if room.Status != RoomWaiting { + return fmt.Errorf("room %s already in progress", code) + } + room.Players = append(room.Players, playerName) + return nil +} + +func (l *Lobby) GetRoom(code string) *LobbyRoom { + l.mu.RLock() + defer l.mu.RUnlock() + return l.rooms[code] +} + +func (l *Lobby) ListRooms() []*LobbyRoom { + l.mu.RLock() + defer l.mu.RUnlock() + result := make([]*LobbyRoom, 0, len(l.rooms)) + for _, r := range l.rooms { + result = append(result, r) + } + return result +} + +func (l *Lobby) RemoveRoom(code string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.rooms, code) +} + +func generateCode() string { + const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ" + b := make([]byte, 4) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} diff --git a/game/lobby_test.go b/game/lobby_test.go new file mode 100644 index 0000000..25b6b7d --- /dev/null +++ b/game/lobby_test.go @@ -0,0 +1,40 @@ +package game + +import "testing" + +func TestCreateRoom(t *testing.T) { + lobby := NewLobby() + code := lobby.CreateRoom("Test Room") + if len(code) != 4 { + t.Errorf("Room code length: got %d, want 4", len(code)) + } + rooms := lobby.ListRooms() + if len(rooms) != 1 { + t.Errorf("Room count: got %d, want 1", len(rooms)) + } +} + +func TestJoinRoom(t *testing.T) { + lobby := NewLobby() + code := lobby.CreateRoom("Test Room") + err := lobby.JoinRoom(code, "player1") + if err != nil { + t.Errorf("Join failed: %v", err) + } + room := lobby.GetRoom(code) + if len(room.Players) != 1 { + t.Errorf("Player count: got %d, want 1", len(room.Players)) + } +} + +func TestJoinRoomFull(t *testing.T) { + lobby := NewLobby() + code := lobby.CreateRoom("Test Room") + for i := 0; i < 4; i++ { + lobby.JoinRoom(code, "player") + } + err := lobby.JoinRoom(code, "player5") + if err == nil { + t.Error("Should reject 5th player") + } +} diff --git a/game/session.go b/game/session.go new file mode 100644 index 0000000..ee6712c --- /dev/null +++ b/game/session.go @@ -0,0 +1,133 @@ +package game + +import ( + "sync" + + "github.com/tolelom/catacombs/dungeon" + "github.com/tolelom/catacombs/entity" +) + +type GamePhase int + +const ( + PhaseExploring GamePhase = iota + PhaseCombat + PhaseShop + PhaseResult +) + +type PlayerAction struct { + Type ActionType + TargetIdx int +} + +type ActionType int + +const ( + ActionAttack ActionType = iota + ActionSkill + ActionItem + ActionFlee + ActionWait +) + +type GameState struct { + Floor *dungeon.Floor + Players []*entity.Player + Monsters []*entity.Monster + Phase GamePhase + FloorNum int + TurnNum int + CombatTurn int // reset per combat encounter + SoloMode bool + GameOver bool + Victory bool + ShopItems []entity.Item +} + +type GameSession struct { + mu sync.Mutex + state GameState + actions map[string]PlayerAction // playerName -> action + actionCh chan playerActionMsg +} + +type playerActionMsg struct { + PlayerName string + Action PlayerAction +} + +func NewGameSession() *GameSession { + return &GameSession{ + state: GameState{ + FloorNum: 1, + }, + actions: make(map[string]PlayerAction), + actionCh: make(chan playerActionMsg, 4), + } +} + +// StartGame determines solo mode from actual player count at game start +func (s *GameSession) StartGame() { + s.mu.Lock() + s.state.SoloMode = len(s.state.Players) == 1 + s.mu.Unlock() + s.StartFloor() +} + +func (s *GameSession) AddPlayer(p *entity.Player) { + s.mu.Lock() + defer s.mu.Unlock() + s.state.Players = append(s.state.Players, p) +} + +func (s *GameSession) StartFloor() { + s.mu.Lock() + defer s.mu.Unlock() + s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum) + s.state.Phase = PhaseExploring + s.state.TurnNum = 0 + + // Revive dead players at 30% HP + for _, p := range s.state.Players { + if p.IsDead() { + p.Revive(0.30) + } + } +} + +func (s *GameSession) GetState() GameState { + s.mu.Lock() + defer s.mu.Unlock() + return s.state +} + +func (s *GameSession) SubmitAction(playerName string, action PlayerAction) { + s.actionCh <- playerActionMsg{PlayerName: playerName, Action: action} +} + +// BuyItem handles shop purchases +func (s *GameSession) BuyItem(playerName string, itemIdx int) bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.state.Phase != PhaseShop || itemIdx < 0 || itemIdx >= len(s.state.ShopItems) { + return false + } + item := s.state.ShopItems[itemIdx] + for _, p := range s.state.Players { + if p.Name == playerName && p.Gold >= item.Price { + p.Gold -= item.Price + p.Inventory = append(p.Inventory, item) + return true + } + } + return false +} + +// LeaveShop exits the shop phase +func (s *GameSession) LeaveShop() { + s.mu.Lock() + defer s.mu.Unlock() + s.state.Phase = PhaseExploring + s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true +} diff --git a/game/session_test.go b/game/session_test.go new file mode 100644 index 0000000..0a4630b --- /dev/null +++ b/game/session_test.go @@ -0,0 +1,29 @@ +package game + +import ( + "testing" + "time" + + "github.com/tolelom/catacombs/entity" +) + +func TestSessionTurnTimeout(t *testing.T) { + s := NewGameSession() + p := entity.NewPlayer("test", entity.ClassWarrior) + s.AddPlayer(p) + s.StartFloor() + + // Don't submit any action, wait for timeout + done := make(chan struct{}) + go func() { + s.RunTurn() + close(done) + }() + + select { + case <-done: + // Turn completed via timeout + case <-time.After(7 * time.Second): + t.Error("Turn did not timeout within 7 seconds") + } +} diff --git a/game/turn.go b/game/turn.go new file mode 100644 index 0000000..b4c0973 --- /dev/null +++ b/game/turn.go @@ -0,0 +1,271 @@ +package game + +import ( + "math/rand" + "time" + + "github.com/tolelom/catacombs/combat" + "github.com/tolelom/catacombs/dungeon" + "github.com/tolelom/catacombs/entity" +) + +const TurnTimeout = 5 * time.Second + +func (s *GameSession) RunTurn() { + s.mu.Lock() + s.state.TurnNum++ + s.state.CombatTurn++ + s.actions = make(map[string]PlayerAction) + aliveCount := 0 + for _, p := range s.state.Players { + if !p.IsDead() { + aliveCount++ + } + } + s.mu.Unlock() + + // Collect actions with timeout + timer := time.NewTimer(TurnTimeout) + collected := 0 + for collected < aliveCount { + select { + case msg := <-s.actionCh: + s.mu.Lock() + s.actions[msg.PlayerName] = msg.Action + s.mu.Unlock() + collected++ + case <-timer.C: + goto resolve + } + } + timer.Stop() + +resolve: + s.mu.Lock() + defer s.mu.Unlock() + + // Default action for players who didn't submit: Wait + for _, p := range s.state.Players { + if !p.IsDead() { + if _, ok := s.actions[p.Name]; !ok { + s.actions[p.Name] = PlayerAction{Type: ActionWait} + } + } + } + + s.resolvePlayerActions() + s.resolveMonsterActions() +} + +func (s *GameSession) resolvePlayerActions() { + var intents []combat.AttackIntent + + // Track which monsters were alive before this turn (for gold awards) + aliveBeforeTurn := make(map[int]bool) + for i, m := range s.state.Monsters { + if !m.IsDead() { + aliveBeforeTurn[i] = true + } + } + + // Check if ALL alive players chose flee — only then the party flees + fleeCount := 0 + aliveCount := 0 + for _, p := range s.state.Players { + if p.IsDead() { + continue + } + aliveCount++ + if action, ok := s.actions[p.Name]; ok && action.Type == ActionFlee { + fleeCount++ + } + } + if fleeCount == aliveCount && aliveCount > 0 { + if combat.AttemptFlee() { + s.state.Phase = PhaseExploring + return + } + // Flee failed — all fleeing players waste their turn, continue to monster phase + return + } + + for _, p := range s.state.Players { + if p.IsDead() { + continue + } + action, ok := s.actions[p.Name] + if !ok { + continue + } + + switch action.Type { + case ActionAttack: + intents = append(intents, combat.AttackIntent{ + PlayerATK: p.EffectiveATK(), + TargetIdx: action.TargetIdx, + Multiplier: 1.0, + IsAoE: false, + }) + case ActionSkill: + switch p.Class { + case entity.ClassWarrior: + // Taunt: mark all monsters to target this warrior + for _, m := range s.state.Monsters { + if !m.IsDead() { + m.TauntTarget = true + m.TauntTurns = 2 + } + } + case entity.ClassMage: + intents = append(intents, combat.AttackIntent{ + PlayerATK: p.EffectiveATK(), + TargetIdx: -1, + Multiplier: 0.8, + IsAoE: true, + }) + case entity.ClassHealer: + if action.TargetIdx >= 0 && action.TargetIdx < len(s.state.Players) { + s.state.Players[action.TargetIdx].Heal(30) + } + case entity.ClassRogue: + // Scout: reveal neighboring rooms + currentRoom := s.state.Floor.Rooms[s.state.Floor.CurrentRoom] + for _, neighborIdx := range currentRoom.Neighbors { + s.state.Floor.Rooms[neighborIdx].Visited = true + } + } + case ActionItem: + // Use first consumable from inventory + for i, item := range p.Inventory { + if item.Type == entity.ItemConsumable { + p.Heal(item.Bonus) + p.Inventory = append(p.Inventory[:i], p.Inventory[i+1:]...) + break + } + } + case ActionFlee: + // Individual flee does nothing if not unanimous (already handled above) + case ActionWait: + // Defensive stance — no action + } + } + + if len(intents) > 0 && len(s.state.Monsters) > 0 { + combat.ResolveAttacks(intents, s.state.Monsters) + } + + // Award gold only for monsters that JUST died this turn + for i, m := range s.state.Monsters { + if m.IsDead() && aliveBeforeTurn[i] { + goldReward := 5 + s.state.FloorNum + if goldReward > 15 { + goldReward = 15 + } + for _, p := range s.state.Players { + if !p.IsDead() { + p.Gold += goldReward + } + } + // Boss kill: drop relic + if m.IsBoss { + s.grantBossRelic() + } + } + } + + // Filter out dead monsters + alive := make([]*entity.Monster, 0) + for _, m := range s.state.Monsters { + if !m.IsDead() { + alive = append(alive, m) + } + } + s.state.Monsters = alive + + // Check if combat is over + if len(s.state.Monsters) == 0 { + s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true + // Check if this was the boss room -> advance floor + if s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Type == dungeon.RoomBoss { + s.advanceFloor() + } else { + s.state.Phase = PhaseExploring + } + } +} + +func (s *GameSession) advanceFloor() { + if s.state.FloorNum >= 20 { + s.state.Phase = PhaseResult + s.state.Victory = true + s.state.GameOver = true + return + } + s.state.FloorNum++ + s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum) + s.state.Phase = PhaseExploring + s.state.CombatTurn = 0 + // Revive dead players at 30% HP + for _, p := range s.state.Players { + if p.IsDead() { + p.Revive(0.30) + } + } +} + +func (s *GameSession) grantBossRelic() { + relics := []entity.Relic{ + {Name: "Vampiric Ring", Effect: entity.RelicHealOnKill, Value: 5, Price: 100}, + {Name: "Power Amulet", Effect: entity.RelicATKBoost, Value: 3, Price: 120}, + {Name: "Iron Ward", Effect: entity.RelicDEFBoost, Value: 2, Price: 100}, + {Name: "Gold Charm", Effect: entity.RelicGoldBoost, Value: 5, Price: 150}, + } + for _, p := range s.state.Players { + if !p.IsDead() { + r := relics[rand.Intn(len(relics))] + p.Relics = append(p.Relics, r) + } + } +} + +func (s *GameSession) resolveMonsterActions() { + if s.state.Phase != PhaseCombat { + return + } + for _, m := range s.state.Monsters { + if m.IsDead() { + continue + } + targetIdx, isAoE := combat.MonsterAI(m, s.state.Players, s.state.CombatTurn) + if isAoE { + // Boss AoE: 0.5x damage to all + for _, p := range s.state.Players { + if !p.IsDead() { + dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 0.5) + p.TakeDamage(dmg) + } + } + } else { + if targetIdx >= 0 && targetIdx < len(s.state.Players) { + p := s.state.Players[targetIdx] + if !p.IsDead() { + dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0) + p.TakeDamage(dmg) + } + } + } + m.TickTaunt() + } + + // Check party wipe + allPlayersDead := true + for _, p := range s.state.Players { + if !p.IsDead() { + allPlayersDead = false + break + } + } + if allPlayersDead { + s.state.Phase = PhaseResult + } +}