diff --git a/docs/superpowers/plans/2026-03-24-bugfix-and-spec-alignment.md b/docs/superpowers/plans/2026-03-24-bugfix-and-spec-alignment.md new file mode 100644 index 0000000..a80c2ce --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-bugfix-and-spec-alignment.md @@ -0,0 +1,1208 @@ +# Catacombs Bug Fix & Spec Alignment Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix 14 confirmed bugs and spec divergences in the Catacombs codebase — covering race conditions, game logic errors, session lifecycle leaks, and missing spec features. + +**Architecture:** Incremental patches to existing files. No new packages or major restructuring. Each task is independently shippable and testable. + +**Tech Stack:** Go 1.25+, charmbracelet/wish, charmbracelet/bubbletea, charmbracelet/lipgloss, go.etcd.io/bbolt + +**Spec:** `docs/superpowers/specs/2026-03-23-catacombs-design.md` + +**Go binary:** `go` (in PATH) + +--- + +## File Map + +``` +entity/monster.go — Task 1: add DEF scaling +entity/monster_test.go — Task 1: test DEF scaling +game/session.go — Task 3: deep-copy GetState; Task 11: add Stop(); Task 12: cleanup +game/session_test.go — Task 3: race test +ui/model.go — Task 4: fix SaveRun; Task 7: set RoomPlaying; Task 8: clamp targetCursor; + Task 10: use fingerprint as key; Task 11: session cleanup on result exit +game/lobby.go — Task 7: add status transition helpers; Task 12: room removal +game/lobby_test.go — Task 7: test status transition +combat/combat.go — Task 5: fix taunt fallthrough +combat/combat_test.go — Task 5: test dead warrior taunt +game/turn.go — Task 6: multiplayer flee effect; Task 9: event log forwarding +game/event.go — Task 9: add log messages for events +game/turn_test.go — Task 6: test flee +server/ssh.go — Task 13: disconnect detection +``` + +--- + +### Task 1: Monster DEF Floor Scaling + +**Files:** +- Modify: `entity/monster.go:46-63` +- Test: `entity/monster_test.go` + +- [ ] **Step 1: Write failing test for DEF scaling** + +Add to `entity/monster_test.go`: +```go +func TestMonsterDEFScaling(t *testing.T) { + // Slime base DEF=1, minFloor=1. At floor 5, scale = 1.15^4 ≈ 1.749 + m := NewMonster(MonsterSlime, 5) + 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) + if boss.DEF != 8 { + t.Errorf("Boss5 DEF should be base 8, got %d", boss.DEF) + } +} +``` + +- [ ] **Step 2: Run test (should fail)** + +```bash +go test ./entity/ -run TestMonsterDEFScaling -v +``` + +- [ ] **Step 3: Add DEF scaling to NewMonster** + +In `entity/monster.go`, change line 60 from: +```go +DEF: base.DEF, +``` +to: +```go +DEF: int(math.Round(float64(base.DEF) * scale)), +``` + +- [ ] **Step 4: Run test (should pass)** + +```bash +go test ./entity/ -run TestMonsterDEFScaling -v +``` + +- [ ] **Step 5: Run all tests** + +```bash +go test ./... -timeout 30s +``` + +- [ ] **Step 6: Commit** + +```bash +git add entity/monster.go entity/monster_test.go +git commit -m "fix: scale monster DEF with floor level like HP/ATK" +``` + +--- + +### Task 2: (Skipped — Boss AoE timing confirmed correct) + +CombatTurn starts at 0, `RunTurn()` increments to 1 before use. First AoE at turn 3. Correct per spec. + +--- + +### Task 3: Fix GetState Race Condition — Deep Copy + +**Files:** +- Modify: `game/session.go:155-159` +- Test: `game/session_test.go` + +The root cause: `GetState()` returns the `GameState` struct by value, but `Players`, `Monsters`, and `Floor` are pointer/slice fields that still reference the same underlying data as the session. The combatLoop goroutine modifies these concurrently. + +- [ ] **Step 1: Write race detection test** + +Add to `game/session_test.go`: +```go +func TestGetStateNoRace(t *testing.T) { + s := NewGameSession() + p := entity.NewPlayer("Racer", entity.ClassWarrior) + s.AddPlayer(p) + s.StartGame() + + // Concurrently read state while combat might modify it + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 100; i++ { + st := s.GetState() + // Access player fields to trigger race detector + for _, p := range st.Players { + _ = p.HP + _ = p.Gold + } + for _, m := range st.Monsters { + _ = m.HP + } + } + }() + + // Submit actions to trigger state changes + for i := 0; i < 10; i++ { + s.SubmitAction("Racer", PlayerAction{Type: ActionWait}) + time.Sleep(10 * time.Millisecond) + } + <-done +} +``` + +- [ ] **Step 2: Run with race detector (should detect race)** + +```bash +go test ./game/ -run TestGetStateNoRace -race -timeout 30s -v +``` + +- [ ] **Step 3: Implement deep copy in GetState** + +In `game/session.go`, replace `GetState()`: + +```go +func (s *GameSession) GetState() GameState { + s.mu.Lock() + defer s.mu.Unlock() + + // Deep copy players + players := make([]*entity.Player, len(s.state.Players)) + for i, p := range s.state.Players { + cp := *p + cp.Inventory = make([]entity.Item, len(p.Inventory)) + copy(cp.Inventory, p.Inventory) + cp.Relics = make([]entity.Relic, len(p.Relics)) + copy(cp.Relics, p.Relics) + players[i] = &cp + } + + // Deep copy monsters + monsters := make([]*entity.Monster, len(s.state.Monsters)) + for i, m := range s.state.Monsters { + cm := *m + monsters[i] = &cm + } + + // Shallow copy floor (rooms are read-only from UI side, only Visited/Cleared change under lock) + var floorCopy *dungeon.Floor + if s.state.Floor != nil { + fc := *s.state.Floor + fc.Rooms = make([]*dungeon.Room, len(s.state.Floor.Rooms)) + for i, r := range s.state.Floor.Rooms { + rc := *r + rc.Neighbors = make([]int, len(r.Neighbors)) + copy(rc.Neighbors, r.Neighbors) + fc.Rooms[i] = &rc + } + // Tiles are not mutated after generation — share the slice + floorCopy = &fc + } + + // Copy combat log + logCopy := make([]string, len(s.state.CombatLog)) + copy(logCopy, s.state.CombatLog) + + return GameState{ + Floor: floorCopy, + Players: players, + Monsters: monsters, + Phase: s.state.Phase, + FloorNum: s.state.FloorNum, + TurnNum: s.state.TurnNum, + CombatTurn: s.state.CombatTurn, + SoloMode: s.state.SoloMode, + GameOver: s.state.GameOver, + Victory: s.state.Victory, + ShopItems: append([]entity.Item{}, s.state.ShopItems...), + CombatLog: logCopy, + TurnDeadline: s.state.TurnDeadline, + } +} +``` + +- [ ] **Step 4: Run race test (should pass)** + +```bash +go test ./game/ -run TestGetStateNoRace -race -timeout 30s -v +``` + +- [ ] **Step 5: Run all tests** + +```bash +go test ./... -timeout 30s +``` + +- [ ] **Step 6: Commit** + +```bash +git add game/session.go game/session_test.go +git commit -m "fix: deep-copy GameState in GetState to prevent data race" +``` + +--- + +### Task 4: Fix Duplicate SaveRun on Game Over + +**Files:** +- Modify: `ui/model.go:282-298` + +- [ ] **Step 1: Add `rankingSaved` flag to Model** + +In `ui/model.go`, add field to `Model` struct (after `moveCursor`): +```go +rankingSaved bool +``` + +- [ ] **Step 2: Guard SaveRun with flag** + +In `updateGame`, replace the game-over block (lines 288-297): +```go + if m.gameState.GameOver { + if m.store != nil && !m.rankingSaved { + score := 0 + for _, p := range m.gameState.Players { + score += p.Gold + } + m.store.SaveRun(m.playerName, m.gameState.FloorNum, score) + m.rankingSaved = true + } + m.screen = screenResult + return m, nil + } +``` + +- [ ] **Step 3: Reset flag when returning to lobby** + +In `updateResult`, after `m.screen = screenLobby`, add: +```go +m.rankingSaved = false +``` + +- [ ] **Step 4: Build** + +```bash +go build ./... +``` + +- [ ] **Step 5: Commit** + +```bash +git add ui/model.go +git commit -m "fix: prevent duplicate SaveRun calls on game over" +``` + +--- + +### Task 5: Fix Taunt Targeting Dead Warrior + +**Files:** +- Modify: `combat/combat.go:84-94` +- Test: `combat/combat_test.go` + +When a warrior dies but `TauntTarget` is still true on monsters, the monster searches for a living warrior, finds none, but doesn't clear the taunt — it just falls through to random targeting. This is mostly harmless but the taunt flag stays dirty. The real issue: if there's no warrior at all and `TauntTarget` was somehow set, the search wastes cycles. Fix: clear taunt if warrior not found. + +- [ ] **Step 1: Write test for dead warrior taunt fallthrough** + +Add to `combat/combat_test.go`: +```go +func TestMonsterAITauntDeadWarrior(t *testing.T) { + warrior := entity.NewPlayer("Tank", entity.ClassWarrior) + warrior.TakeDamage(warrior.HP) // kill warrior + mage := entity.NewPlayer("Mage", entity.ClassMage) + + m := &entity.Monster{Name: "Orc", HP: 50, ATK: 10, DEF: 5, TauntTarget: true, TauntTurns: 2} + idx, isAoE := MonsterAI(m, []*entity.Player{warrior, mage}, 1) + + if isAoE { + t.Error("should not AoE") + } + // Should target mage (index 1) since warrior is dead + if idx != 1 { + t.Errorf("expected target mage at index 1, got %d", idx) + } + // TauntTarget should be cleared since no living warrior + if m.TauntTarget { + t.Error("TauntTarget should be cleared when warrior is dead") + } +} +``` + +- [ ] **Step 2: Run test (should fail on TauntTarget assertion)** + +```bash +go test ./combat/ -run TestMonsterAITauntDeadWarrior -v +``` + +- [ ] **Step 3: Fix MonsterAI taunt handling** + +In `combat/combat.go`, replace the taunt block (lines 88-94): +```go + 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 + } +``` + +- [ ] **Step 4: Run test (should pass)** + +```bash +go test ./combat/ -run TestMonsterAITauntDeadWarrior -v +``` + +- [ ] **Step 5: Run all combat tests** + +```bash +go test ./combat/ -v +``` + +- [ ] **Step 6: Commit** + +```bash +git add combat/combat.go combat/combat_test.go +git commit -m "fix: clear monster taunt when warrior is dead" +``` + +--- + +### Task 6: Multiplayer Flee — Mark Player as Fled + +**Files:** +- Modify: `entity/player.go` — add `Fled` field +- Modify: `game/turn.go:145-154` — set Fled on success +- Modify: `game/turn.go:78-80` — skip fled players like dead players +- Test: `game/turn_test.go` + +In multiplayer, a successful flee currently does nothing. Fix: mark the player as "fled" so they're skipped for the rest of this combat (like being dead, but without the death penalty). Reset fled status when combat ends. + +- [ ] **Step 1: Add Fled field to Player** + +In `entity/player.go`, add to `Player` struct: +```go +Fled bool +``` + +- [ ] **Step 2: Update IsDead check comment and add IsOut helper** + +In `entity/player.go`, add method: +```go +// IsOut returns true if the player cannot act in combat (dead or fled) +func (p *Player) IsOut() bool { + return p.Dead || p.Fled +} +``` + +- [ ] **Step 3: Update turn.go to use IsOut for action collection** + +In `game/turn.go`, replace all `p.IsDead()` checks in `RunTurn()` and `resolvePlayerActions()` with `p.IsOut()`: + +Line 23: `if !p.IsOut() {` (alive count) +Line 54-55: `if !p.IsOut() {` (default action) +Line 79: `if p.IsOut() {` (skip dead/fled in resolve) +Line 189: `if !p.IsOut() {` (gold award) +Line 259: `if !p.IsOut() {` (boss relic) + +In `resolveMonsterActions()`: +Line 278: `if !p.IsOut() {` (AoE targets) +Line 287: `if !p.IsOut() {` (single target validation) +Line 298: `if !p.IsOut() {` (all-dead check) + +- [ ] **Step 4: Set Fled on successful flee** + +In `game/turn.go`, replace the ActionFlee case (lines 145-154): +```go + case ActionFlee: + if combat.AttemptFlee() { + s.addLog(fmt.Sprintf("%s fled from battle!", p.Name)) + if s.state.SoloMode { + s.state.Phase = PhaseExploring + return + } + p.Fled = true + } else { + s.addLog(fmt.Sprintf("%s failed to flee!", p.Name)) + } +``` + +- [ ] **Step 5: Check if all remaining players fled — end combat** + +In `resolvePlayerActions()`, after the action loop (before intents resolution), add: +```go + // Check if all alive players have fled + allFled := true + for _, p := range s.state.Players { + if !p.IsDead() && !p.Fled { + allFled = false + break + } + } + if allFled && !s.state.SoloMode { + s.state.Phase = PhaseExploring + s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true + s.addLog("All players fled!") + // Reset fled status + for _, p := range s.state.Players { + p.Fled = false + } + return + } +``` + +- [ ] **Step 6: Reset Fled when combat ends** + +In `resolvePlayerActions()`, where combat ends (line 220-228, the `len(s.state.Monsters) == 0` block), add before phase change: +```go + for _, p := range s.state.Players { + p.Fled = false + } +``` + +Also in `advanceFloor()` (line 244-248), add: +```go + for _, p := range s.state.Players { + p.Fled = false + } +``` + +- [ ] **Step 7: Build and run all tests** + +```bash +go build ./... +go test ./... -timeout 30s +``` + +- [ ] **Step 8: Commit** + +```bash +git add entity/player.go game/turn.go +git commit -m "feat: multiplayer flee marks player as out for current combat" +``` + +--- + +### Task 7: Set Room Status to Playing on Game Start + +**Files:** +- Modify: `ui/model.go:251-267` (class select → game start) +- Modify: `game/lobby.go` — add `StartRoom` helper +- Test: `game/lobby_test.go` + +- [ ] **Step 1: Write test for room status transition** + +Add to `game/lobby_test.go`: +```go +func TestRoomStatusTransition(t *testing.T) { + l := NewLobby() + code := l.CreateRoom("Test") + l.JoinRoom(code, "Alice") + r := l.GetRoom(code) + if r.Status != RoomWaiting { + t.Errorf("new room should be Waiting, got %d", r.Status) + } + l.StartRoom(code) + r = l.GetRoom(code) + if r.Status != RoomPlaying { + t.Errorf("started room should be Playing, got %d", r.Status) + } + // Joining a Playing room should fail + err := l.JoinRoom(code, "Bob") + if err == nil { + t.Error("should not be able to join a Playing room") + } +} +``` + +- [ ] **Step 2: Run test (should fail — StartRoom doesn't exist)** + +```bash +go test ./game/ -run TestRoomStatusTransition -v +``` + +- [ ] **Step 3: Add StartRoom to lobby.go** + +```go +func (l *Lobby) StartRoom(code string) { + l.mu.Lock() + defer l.mu.Unlock() + if room, ok := l.rooms[code]; ok { + room.Status = RoomPlaying + } +} +``` + +- [ ] **Step 4: Call StartRoom when game starts** + +In `ui/model.go` `updateClassSelect`, after `m.session.StartGame()` (line 263), add: +```go + m.lobby.StartRoom(m.roomCode) +``` + +- [ ] **Step 5: Run test (should pass)** + +```bash +go test ./game/ -run TestRoomStatusTransition -v +``` + +- [ ] **Step 6: Commit** + +```bash +git add game/lobby.go game/lobby_test.go ui/model.go +git commit -m "fix: set room status to Playing when game starts" +``` + +--- + +### Task 8: Clamp Target Cursor After Monster Death + +**Files:** +- Modify: `ui/model.go:282-286` + +- [ ] **Step 1: Add cursor clamping in updateGame** + +In `updateGame`, right after `m.gameState = m.session.GetState()` (line 285), add: +```go + // Clamp target cursor to valid range after monsters die + if len(m.gameState.Monsters) > 0 { + if m.targetCursor >= len(m.gameState.Monsters) { + m.targetCursor = len(m.gameState.Monsters) - 1 + } + } else { + m.targetCursor = 0 + } +``` + +- [ ] **Step 2: Build** + +```bash +go build ./... +``` + +- [ ] **Step 3: Commit** + +```bash +git add ui/model.go +git commit -m "fix: clamp target cursor when monsters die" +``` + +--- + +### Task 9: Add Event Room Log Messages + +**Files:** +- Modify: `game/event.go:132-148` + +- [ ] **Step 1: Add log messages to triggerEvent** + +Replace `triggerEvent()` in `game/event.go`: +```go +func (s *GameSession) triggerEvent() { + for _, p := range s.state.Players { + if p.IsDead() { + continue + } + if rand.Float64() < 0.5 { + dmg := 10 + rand.Intn(11) + p.TakeDamage(dmg) + s.addLog(fmt.Sprintf("Trap! %s takes %d damage", p.Name, dmg)) + } else { + heal := 15 + rand.Intn(11) + before := p.HP + p.Heal(heal) + s.addLog(fmt.Sprintf("Blessing! %s heals %d HP", p.Name, p.HP-before)) + } + } +} +``` + +Also add log for treasure in `grantTreasure()`: +```go +func (s *GameSession) grantTreasure() { + for _, p := range s.state.Players { + if rand.Float64() < 0.5 { + item := entity.Item{ + Name: "Iron Sword", Type: entity.ItemWeapon, Bonus: 3 + rand.Intn(6), + } + p.Inventory = append(p.Inventory, item) + s.addLog(fmt.Sprintf("%s found %s (ATK+%d)", p.Name, item.Name, item.Bonus)) + } else { + item := entity.Item{ + Name: "Iron Shield", Type: entity.ItemArmor, Bonus: 2 + rand.Intn(4), + } + p.Inventory = append(p.Inventory, item) + s.addLog(fmt.Sprintf("%s found %s (DEF+%d)", p.Name, item.Name, item.Bonus)) + } + } +} +``` + +- [ ] **Step 2: Show combat log on game screen even outside combat** + +In `ui/model.go` `updateGame`, in the `PhaseExploring` branch, after `m.session.EnterRoom(roomIdx)`, `m.gameState` is already refreshed. The log will render via `renderCombatLog` which already shows `state.CombatLog`. No change needed — the log displays on the game view regardless of phase. + +- [ ] **Step 3: Build** + +```bash +go build ./... +``` + +- [ ] **Step 4: Commit** + +```bash +git add game/event.go +git commit -m "feat: show log messages for trap, blessing, and treasure events" +``` + +--- + +### Task 10: Use Fingerprint as Action Key (Prevent Name Collision) + +**Depends on:** Task 6 (for `IsOut()` method) + +**Files:** +- Modify: `game/session.go` — change `actions` map key +- Modify: `game/turn.go` — use fingerprint in action lookup +- Modify: `ui/model.go` — submit with fingerprint + +The `actions` map uses `playerName` as the key. If two players share a name, one overwrites the other. Fix: use `Fingerprint` as the key. + +- [ ] **Step 1: Change SubmitAction to use fingerprint** + +In `game/session.go`, rename `playerActionMsg.PlayerName` to `PlayerID`: +```go +type playerActionMsg struct { + PlayerID string + Action PlayerAction +} +``` + +Update `SubmitAction`: +```go +func (s *GameSession) SubmitAction(playerID string, action PlayerAction) { + s.actionCh <- playerActionMsg{PlayerID: playerID, Action: action} +} +``` + +- [ ] **Step 2: Update RunTurn action collection** + +In `game/turn.go`, line 39: +```go + s.actions[msg.PlayerID] = msg.Action +``` + +- [ ] **Step 3: Update resolvePlayerActions to match by fingerprint** + +In `game/turn.go` `resolvePlayerActions()`, change the action lookup (line 82): +```go + action, ok := s.actions[p.Fingerprint] +``` + +- [ ] **Step 4: Update default action loop** + +In `game/turn.go`, change the default-action loop (lines 54-59): +```go + for _, p := range s.state.Players { + if !p.IsOut() { + if _, ok := s.actions[p.Fingerprint]; !ok { + s.actions[p.Fingerprint] = PlayerAction{Type: ActionWait} + } + } + } +``` + +- [ ] **Step 5: Generate fallback fingerprint for keyless SSH users** + +In `ui/model.go` `updateTitle()`, when `m.fingerprint` is empty (password auth, no public key), generate a unique fallback ID so keyless players don't collide on `""`: +```go + if m.fingerprint == "" { + m.fingerprint = fmt.Sprintf("anon-%d", time.Now().UnixNano()) + } +``` +Add this right after the `m.playerName` is set (before `m.screen = screenLobby`). + +Also update the `NewPlayer` call in `updateClassSelect` to ensure fingerprint is set: +```go + player := entity.NewPlayer(m.playerName, selectedClass) + player.Fingerprint = m.fingerprint +``` +(This line already exists, so no change needed here — just verify.) + +- [ ] **Step 6: Update UI to submit with fingerprint** + +In `ui/model.go`, change all `m.session.SubmitAction(m.playerName, ...)` calls to `m.session.SubmitAction(m.fingerprint, ...)`. + +Lines 358-366: replace `m.playerName` → `m.fingerprint` in all 5 SubmitAction calls. + +- [ ] **Step 7: Update BuyItem to use fingerprint** + +In `game/session.go` `BuyItem()`, change `playerName` parameter to `playerID` and match by fingerprint: +```go +func (s *GameSession) BuyItem(playerID 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.Fingerprint == playerID && p.Gold >= item.Price { + p.Gold -= item.Price + p.Inventory = append(p.Inventory, item) + return true + } + } + return false +} +``` + +In `ui/model.go` `updateShop()`, change: +```go +m.session.BuyItem(m.fingerprint, idx) +``` + +- [ ] **Step 8: Update dead-player check in updateGame** + +In `ui/model.go` `updateGame()`, change the dead player check (lines 340-344) to use fingerprint: +```go + isPlayerDead := false + for _, p := range m.gameState.Players { + if p.Fingerprint == m.fingerprint && p.IsDead() { + isPlayerDead = true + break + } + } +``` + +- [ ] **Step 9: Build and run all tests** + +```bash +go build ./... +go test ./... -timeout 30s +``` + +Some tests may need updating if they call `SubmitAction` with a name — update to use fingerprint. Check `game/session_test.go` — the test player will need `Fingerprint` set. + +- [ ] **Step 10: Commit** + +```bash +git add game/session.go game/turn.go ui/model.go +git commit -m "fix: use fingerprint as player ID to prevent name collision" +``` + +--- + +### Task 11: Session Cleanup & Goroutine Leak Fix + +**Files:** +- Modify: `game/session.go` — add `Stop()` method and done channel +- Modify: `ui/model.go` — call Stop on result exit + +- [ ] **Step 1: Add done channel and Stop method** + +In `game/session.go`, add `done` channel to `GameSession`: +```go +type GameSession struct { + mu sync.Mutex + state GameState + started bool + actions map[string]PlayerAction + actionCh chan playerActionMsg + combatSignal chan struct{} + done chan struct{} +} +``` + +Update `NewGameSession`: +```go +func NewGameSession() *GameSession { + return &GameSession{ + state: GameState{ + FloorNum: 1, + }, + actions: make(map[string]PlayerAction), + actionCh: make(chan playerActionMsg, 4), + combatSignal: make(chan struct{}, 1), + done: make(chan struct{}), + } +} +``` + +Add `Stop()`: +```go +func (s *GameSession) Stop() { + select { + case <-s.done: + // already stopped + default: + close(s.done) + } +} +``` + +- [ ] **Step 2: Update combatLoop to respect done channel** + +Replace `combatLoop()`: +```go +func (s *GameSession) combatLoop() { + for { + select { + case <-s.done: + return + default: + } + + s.mu.Lock() + phase := s.state.Phase + gameOver := s.state.GameOver + s.mu.Unlock() + + if gameOver { + return + } + + if phase == PhaseCombat { + s.RunTurn() + } else { + select { + case <-s.combatSignal: + case <-s.done: + return + } + } + } +} +``` + +Also update `RunTurn` action collection to respect done: + +In `game/turn.go`, update the select in the action collection loop (lines 36-44): +```go + for collected < aliveCount { + select { + case msg := <-s.actionCh: + s.mu.Lock() + s.actions[msg.PlayerID] = msg.Action + s.mu.Unlock() + collected++ + case <-timer.C: + goto resolve + case <-s.done: + timer.Stop() + return + } + } +``` + +- [ ] **Step 3: Clean up session on result exit** + +In `ui/model.go` `updateResult()`, when going back to lobby: +```go + if isEnter(key) { + if m.session != nil { + m.session.Stop() + m.session = nil + } + m.rankingSaved = false + m.screen = screenLobby + m = m.withRefreshedLobby() + } +``` + +- [ ] **Step 4: Build and run all tests** + +```bash +go build ./... +go test ./... -timeout 30s +``` + +- [ ] **Step 5: Commit** + +```bash +git add game/session.go game/turn.go ui/model.go +git commit -m "fix: stop combatLoop goroutine on session exit to prevent leak" +``` + +--- + +### Task 12: Remove Zombie Lobby Rooms on Game End + +**Depends on:** Task 11 (extends `updateResult` from Task 11) + +**Files:** +- Modify: `ui/model.go` — remove room from lobby on game end + +- [ ] **Step 1: Remove lobby room on result exit** + +In `ui/model.go` `updateResult()`, update the enter handler (extending Task 11's change): +```go + if isEnter(key) { + if m.session != nil { + m.session.Stop() + m.session = nil + } + if m.lobby != nil && m.roomCode != "" { + m.lobby.RemoveRoom(m.roomCode) + } + m.roomCode = "" + m.rankingSaved = false + m.screen = screenLobby + m = m.withRefreshedLobby() + } +``` + +- [ ] **Step 2: Build** + +```bash +go build ./... +``` + +- [ ] **Step 3: Commit** + +```bash +git add ui/model.go +git commit -m "fix: remove lobby room when game session ends" +``` + +--- + +### Task 13: Disconnect Handling — Auto-Defend & Timeout + +**Depends on:** Task 10 (fingerprint-based IDs), Task 11 (done channel) + +**Files:** +- Modify: `game/session.go` — add `lastActivity` tracking, timeout check in combatLoop +- Modify: `game/event.go` — update lastActivity on EnterRoom +- Modify: `ui/model.go` — update lastActivity on exploration actions + +**Design:** Track `lastActivity` timestamp per player (by fingerprint). Update it on both `SubmitAction` (combat) and `EnterRoom` (exploration). In `combatLoop`, check for 60-second inactive players and remove them. Auto-defend already works via `ActionWait` default. No SSH-layer changes needed. + +- [ ] **Step 1: Add lastActivity to GameSession** + +In `game/session.go`, add to `GameSession` struct: +```go + lastActivity map[string]time.Time // fingerprint -> last activity time +``` + +Initialize in `NewGameSession`: +```go + lastActivity: make(map[string]time.Time), +``` + +- [ ] **Step 2: Update lastActivity in SubmitAction** + +```go +func (s *GameSession) SubmitAction(playerID string, action PlayerAction) { + s.mu.Lock() + s.lastActivity[playerID] = time.Now() + s.mu.Unlock() + s.actionCh <- playerActionMsg{PlayerID: playerID, Action: action} +} +``` + +- [ ] **Step 3: Update lastActivity in EnterRoom** + +In `game/event.go` `EnterRoom()`, add at the top (after lock, before setting CurrentRoom): +```go + // Update activity for all connected players (exploration is party-wide) + now := time.Now() + for _, p := range s.state.Players { + if p.Fingerprint != "" { + s.lastActivity[p.Fingerprint] = now + } + } +``` + +- [ ] **Step 4: Add TouchActivity method for UI heartbeat** + +In `game/session.go`, add: +```go +func (s *GameSession) TouchActivity(fingerprint string) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastActivity[fingerprint] = time.Now() +} +``` + +- [ ] **Step 5: Call TouchActivity from UI on any key press** + +In `ui/model.go` `updateGame()`, add at the top (before the session GetState call): +```go + if m.session != nil && m.fingerprint != "" { + m.session.TouchActivity(m.fingerprint) + } +``` + +- [ ] **Step 6: Initialize lastActivity in StartGame** + +```go +func (s *GameSession) StartGame() { + s.mu.Lock() + if s.started { + s.mu.Unlock() + return + } + s.started = true + s.state.SoloMode = len(s.state.Players) == 1 + now := time.Now() + for _, p := range s.state.Players { + s.lastActivity[p.Fingerprint] = now + } + s.mu.Unlock() + s.StartFloor() + go s.combatLoop() +} +``` + +- [ ] **Step 7: Add timeout check to combatLoop** + +In `combatLoop`, add this block right after the `gameOver` check: +```go + // Remove players inactive for >60 seconds + s.mu.Lock() + now := time.Now() + changed := false + remaining := make([]*entity.Player, 0, len(s.state.Players)) + for _, p := range s.state.Players { + if p.Fingerprint != "" && !p.IsOut() { + if last, ok := s.lastActivity[p.Fingerprint]; ok { + if now.Sub(last) > 60*time.Second { + s.addLog(fmt.Sprintf("%s removed (disconnected)", p.Name)) + changed = true + continue + } + } + } + remaining = append(remaining, p) + } + if changed { + s.state.Players = remaining + if len(s.state.Players) <= 1 { + s.state.SoloMode = true + } + if len(s.state.Players) == 0 { + s.state.GameOver = true + s.mu.Unlock() + return + } + } + s.mu.Unlock() +``` + +- [ ] **Step 8: Build and run all tests** + +```bash +go build ./... +go test ./... -timeout 30s +``` + +- [ ] **Step 9: Commit** + +```bash +git add game/session.go game/event.go ui/model.go +git commit -m "feat: remove inactive players after 60s disconnect timeout" +``` + +--- + +### Task 14: Chat System (/ key) + +**Files:** +- Modify: `game/session.go` — add chat method +- Modify: `ui/model.go` — add chat input mode +- Modify: `ui/game_view.go` — render chat input + +- [ ] **Step 1: Add SendChat to GameSession** + +In `game/session.go`, add: +```go +func (s *GameSession) SendChat(playerName, message string) { + s.mu.Lock() + defer s.mu.Unlock() + s.addLog(fmt.Sprintf("[%s] %s", playerName, message)) +} +``` + +- [ ] **Step 2: Add chat mode to Model** + +In `ui/model.go`, add to `Model` struct: +```go + chatting bool + chatInput string +``` + +- [ ] **Step 3: Handle `/` key and chat input in updateGame** + +In `updateGame`, add chat handling at the top of the `tea.KeyMsg` section (before phase switch): +```go + if key, ok := msg.(tea.KeyMsg); ok { + // Chat mode + if m.chatting { + if isEnter(key) && len(m.chatInput) > 0 { + if m.session != nil { + m.session.SendChat(m.playerName, m.chatInput) + m.gameState = m.session.GetState() + } + m.chatting = false + m.chatInput = "" + } else if isKey(key, "esc") || key.Type == tea.KeyEsc { + m.chatting = false + m.chatInput = "" + } else if key.Type == tea.KeyBackspace && len(m.chatInput) > 0 { + m.chatInput = m.chatInput[:len(m.chatInput)-1] + } else if len(key.Runes) == 1 && len(m.chatInput) < 40 { + m.chatInput += string(key.Runes) + } + if m.gameState.Phase == game.PhaseCombat { + return m, m.pollState() + } + return m, nil + } + + if isKey(key, "/") { + m.chatting = true + m.chatInput = "" + if m.gameState.Phase == game.PhaseCombat { + return m, m.pollState() + } + return m, nil + } + + // existing phase switch... +``` + +- [ ] **Step 4: Show chat input in HUD** + +In `ui/game_view.go`, update `renderGame` to accept and show chat state. Add parameters: +```go +func renderGame(state game.GameState, width, height int, targetCursor int, moveCursor int, chatting bool, chatInput string) string { +``` + +At the bottom of `renderGame`, before return: +```go + if chatting { + chatStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("117")) + chatView := chatStyle.Render(fmt.Sprintf("> %s_", chatInput)) + return lipgloss.JoinVertical(lipgloss.Left, mapView, hudView, logView, chatView) + } +``` + +Update call in `ui/model.go` `View()`: +```go + case screenGame: + return renderGame(m.gameState, m.width, m.height, m.targetCursor, m.moveCursor, m.chatting, m.chatInput) +``` + +- [ ] **Step 5: Build and run all tests** + +```bash +go build ./... +go test ./... -timeout 30s +``` + +- [ ] **Step 6: Commit** + +```bash +git add game/session.go ui/model.go ui/game_view.go +git commit -m "feat: add in-game chat with / key" +```