diff --git a/docs/superpowers/plans/2026-03-24-catacombs-fixes.md b/docs/superpowers/plans/2026-03-24-catacombs-fixes.md new file mode 100644 index 0000000..5304829 --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-catacombs-fixes.md @@ -0,0 +1,652 @@ +# Catacombs Bug Fixes & Missing Features 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 critical bugs and implement missing features identified in the spec-vs-implementation review. + +**Architecture:** Incremental fixes to existing codebase. No major restructuring — patch what's broken, add what's missing. Each task is independently shippable. + +**Tech Stack:** Go 1.22+, charmbracelet/wish, charmbracelet/bubbletea, charmbracelet/lipgloss, go.etcd.io/bbolt + +**Spec:** `docs/superpowers/specs/2026-03-23-catacombs-design.md` +**Review:** See "Critical Issues Summary" in conversation history. + +**Go binary path:** `/c/Users/98kim/sdk/go1.25.1/bin/go.exe` (not in PATH) + +--- + +## File Map (files touched in this plan) + +``` +ui/model.go — remove debug, add nickname input, target selection, dead player guard, save ranking +ui/game_view.go — turn timer display, target selection indicator +game/session.go — fix StartGame race, add TurnTimeLeft to GameState, relic effects +game/turn.go — relic effect application on kill, individual flee +game/event.go — overlapping monster floor ranges +entity/player.go — relic-aware EffectiveATK/EffectiveDEF +dungeon/generator.go — BSP dungeon generation with 2D tile map +dungeon/room.go — tile-based room rendering data +dungeon/render.go — NEW: ASCII tile map renderer +store/db.go — no changes needed (SaveRun already exists) +``` + +--- + +### Task 1: Quick Fixes (debug removal, ranking save, nickname, dead player guard) + +**Files:** +- Modify: `ui/model.go` + +- [ ] **Step 1: Remove debug lines from View()** + +Remove the `debug` variable and all `debug +` prefixes from `View()`. Remove `lastKey` field from Model struct. + +- [ ] **Step 2: Save ranking on game over** + +In `updateGame`, when `m.gameState.GameOver` is detected and screen transitions to result, call: +```go +if m.store != nil { + score := 0 + for _, p := range m.gameState.Players { + score += p.Gold + } + m.store.SaveRun(m.playerName, m.gameState.FloorNum, score) +} +``` + +- [ ] **Step 3: Block dead player actions** + +In `updateGame` combat key handling, add guard: +```go +// Find this player and check if dead +isPlayerDead := false +for _, p := range m.gameState.Players { + if p.Name == m.playerName && p.IsDead() { + isPlayerDead = true + break + } +} +if isPlayerDead { + return m, m.pollState() // spectator mode: just keep watching +} +``` + +- [ ] **Step 4: Save nickname on first connection** + +In `updateTitle`, after setting `m.playerName = "Adventurer"` (when profile not found), add: +```go +if m.fingerprint != "" { + m.store.SaveProfile(m.fingerprint, m.playerName) +} +``` + +- [ ] **Step 5: Build and run tests** + +```bash +/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./... +/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./ui/ -v -timeout 15s +``` + +- [ ] **Step 6: Commit** + +```bash +git add ui/model.go +git commit -m "fix: remove debug, save rankings, block dead actions, save nickname" +``` + +--- + +### Task 2: Relic Effects + +**Files:** +- Modify: `entity/player.go`, `game/turn.go` +- Test: `entity/player_test.go` + +- [ ] **Step 1: Write relic effect test** + +Add to `entity/player_test.go`: +```go +func TestRelicEffects(t *testing.T) { + p := NewPlayer("test", ClassWarrior) + p.Relics = append(p.Relics, Relic{Name: "Power Amulet", Effect: RelicATKBoost, Value: 3}) + if p.EffectiveATK() != 15 { // 12 base + 3 relic + t.Errorf("ATK with relic: got %d, want 15", p.EffectiveATK()) + } + + p.Relics = append(p.Relics, Relic{Name: "Iron Ward", Effect: RelicDEFBoost, Value: 2}) + if p.EffectiveDEF() != 10 { // 8 base + 2 relic + t.Errorf("DEF with relic: got %d, want 10", p.EffectiveDEF()) + } +} +``` + +- [ ] **Step 2: Run test (should fail)** + +```bash +/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./entity/ -run TestRelicEffects -v +``` + +- [ ] **Step 3: Add relic bonuses to EffectiveATK/EffectiveDEF** + +In `entity/player.go`, update `EffectiveATK()`: +```go +func (p *Player) EffectiveATK() int { + atk := p.ATK + for _, item := range p.Inventory { + if item.Type == ItemWeapon { + atk += item.Bonus + } + } + for _, r := range p.Relics { + if r.Effect == RelicATKBoost { + atk += r.Value + } + } + return atk +} +``` + +Update `EffectiveDEF()` similarly for `RelicDEFBoost`. + +- [ ] **Step 4: Add HealOnKill relic trigger in turn.go** + +In `resolvePlayerActions()`, in the "Award gold" loop where `m.IsDead() && aliveBeforeTurn[i]`, add: +```go +// Apply RelicHealOnKill +for _, p := range s.state.Players { + if !p.IsDead() { + for _, r := range p.Relics { + if r.Effect == entity.RelicHealOnKill { + p.Heal(r.Value) + } + } + } +} +``` + +And in the gold award loop, apply `RelicGoldBoost`: +```go +for _, p := range s.state.Players { + if !p.IsDead() { + bonus := 0 + for _, r := range p.Relics { + if r.Effect == entity.RelicGoldBoost { + bonus += r.Value + } + } + p.Gold += goldReward + bonus + } +} +``` + +- [ ] **Step 5: Run all tests** + +```bash +/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./entity/ ./game/ -v -timeout 15s +``` + +- [ ] **Step 6: Commit** + +```bash +git add entity/player.go entity/player_test.go game/turn.go +git commit -m "feat: apply relic passive effects (ATK/DEF boost, heal on kill, gold boost)" +``` + +--- + +### Task 3: Combat Target Selection & Individual Flee + +**Files:** +- Modify: `ui/model.go`, `ui/game_view.go`, `game/turn.go` + +- [ ] **Step 1: Add target cursor to Model** + +In `ui/model.go`, add field to Model: +```go +targetCursor int // selected enemy index during combat +``` + +- [ ] **Step 2: Add target selection keys** + +In `updateGame` combat phase, add left/right or tab for target cycling: +```go +case "tab": + if len(m.gameState.Monsters) > 0 { + m.targetCursor = (m.targetCursor + 1) % len(m.gameState.Monsters) + } +``` + +Update attack/skill to use `m.targetCursor`: +```go +case "1": + m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionAttack, TargetIdx: m.targetCursor}) +``` + +- [ ] **Step 3: Show target indicator in HUD** + +In `ui/game_view.go` `renderHUD`, add arrow marker to selected target: +```go +marker := " " +if i == targetCursor { + marker = "> " +} +sb.WriteString(enemyStyle.Render(fmt.Sprintf("%s[%d] %s ...", marker, i, m.Name))) +``` + +Pass `targetCursor` through `renderGame` and `renderHUD` (add parameter or embed in state). + +- [ ] **Step 4: Change flee to individual** + +In `game/turn.go` `resolvePlayerActions`, replace the unanimous flee block with individual flee: +```go +// Remove the fleeCount/aliveCount block entirely. +// In the per-player action switch, change ActionFlee to: +case ActionFlee: + if combat.AttemptFlee() { + s.addLog(fmt.Sprintf("%s fled from battle!", p.Name)) + // In solo, end combat + if s.state.SoloMode { + s.state.Phase = PhaseExploring + return + } + // In multiplayer, mark player as fled (treat as dead for this combat) + // For simplicity: solo flee ends combat, multiplayer flee wastes turn + } else { + s.addLog(fmt.Sprintf("%s failed to flee!", p.Name)) + } +``` + +- [ ] **Step 5: Add [Tab] hint to action bar** + +In `ui/game_view.go`, update the action line: +```go +sb.WriteString(actionStyle.Render("[1]Attack [2]Skill [3]Item [4]Flee [5]Wait [Tab]Target")) +``` + +- [ ] **Step 6: Build and test** + +```bash +/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./... +/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./... -timeout 15s +``` + +- [ ] **Step 7: Commit** + +```bash +git add ui/model.go ui/game_view.go game/turn.go +git commit -m "feat: target selection with Tab, individual flee in solo" +``` + +--- + +### Task 4: Turn Timer Display + +**Files:** +- Modify: `game/session.go`, `game/turn.go`, `ui/game_view.go` + +- [ ] **Step 1: Add TurnDeadline to GameState** + +In `game/session.go` `GameState` struct: +```go +TurnDeadline time.Time // when current turn times out +``` + +Import `"time"` in session.go. + +- [ ] **Step 2: Set deadline in RunTurn** + +In `game/turn.go` `RunTurn()`, after creating the timer: +```go +s.state.TurnDeadline = time.Now().Add(TurnTimeout) +``` + +After turn resolves (in resolve section): +```go +s.state.TurnDeadline = time.Time{} // clear deadline +``` + +- [ ] **Step 3: Display countdown in HUD** + +In `ui/game_view.go` `renderHUD`, when in combat phase: +```go +if !state.TurnDeadline.IsZero() { + remaining := time.Until(state.TurnDeadline) + if remaining < 0 { + remaining = 0 + } + timerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true) + sb.WriteString(timerStyle.Render(fmt.Sprintf(" Timer: %.1fs", remaining.Seconds()))) + sb.WriteString("\n") +} +``` + +Import `"time"` in game_view.go. + +- [ ] **Step 4: Build and test** + +```bash +/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./... +``` + +- [ ] **Step 5: Commit** + +```bash +git add game/session.go game/turn.go ui/game_view.go +git commit -m "feat: display turn countdown timer in combat HUD" +``` + +--- + +### Task 5: Fix StartGame Race & Overlapping Monster Ranges + +**Files:** +- Modify: `game/session.go`, `game/event.go` + +- [ ] **Step 1: Fix StartGame to only run once** + +Add `started bool` field to `GameSession`: +```go +type GameSession struct { + // ... + started bool +} +``` + +Guard `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 + s.mu.Unlock() + s.StartFloor() + go s.combatLoop() +} +``` + +- [ ] **Step 2: Fix monster floor ranges to overlap** + +In `game/event.go` `spawnMonsters`, replace the exclusive switch with weighted random selection from valid types: +```go +func (s *GameSession) spawnMonsters() { + count := 1 + rand.Intn(5) + floor := s.state.FloorNum + s.state.Monsters = make([]*entity.Monster, count) + + // Build list of valid monster types for this floor + type floorRange struct { + mt entity.MonsterType + minFloor int + maxFloor int + } + ranges := []floorRange{ + {entity.MonsterSlime, 1, 5}, + {entity.MonsterSkeleton, 3, 10}, + {entity.MonsterOrc, 6, 14}, + {entity.MonsterDarkKnight, 12, 20}, + } + var valid []entity.MonsterType + for _, r := range ranges { + if floor >= r.minFloor && floor <= r.maxFloor { + valid = append(valid, r.mt) + } + } + if len(valid) == 0 { + valid = []entity.MonsterType{entity.MonsterSlime} + } + + for i := 0; i < count; i++ { + mt := valid[rand.Intn(len(valid))] + 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 + } +} +``` + +- [ ] **Step 3: Build and test** + +```bash +/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./... +/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./game/ -v -timeout 15s +``` + +- [ ] **Step 4: Commit** + +```bash +git add game/session.go game/event.go +git commit -m "fix: prevent double StartGame, use overlapping monster floor ranges" +``` + +--- + +### Task 6: BSP Dungeon with 2D Tile Map + +**Files:** +- Modify: `dungeon/generator.go`, `dungeon/room.go` +- Create: `dungeon/render.go` +- Modify: `ui/game_view.go` +- Test: `dungeon/generator_test.go` + +This is the biggest task. The goal is to replace the list-based room display with a proper 2D ASCII map. + +- [ ] **Step 1: Update Room struct for tile coordinates** + +In `dungeon/room.go`, ensure Room has proper tile-space bounds: +```go +type Room struct { + Type RoomType + X, Y int // top-left corner in tile space + W, H int // width, height in tiles + CenterX int + CenterY int + Visited bool + Cleared bool + Neighbors []int +} +``` + +- [ ] **Step 2: Rewrite BSP generator** + +Replace `dungeon/generator.go` with proper BSP: +```go +const ( + MapWidth = 60 + MapHeight = 20 + MinRoomW = 8 + MinRoomH = 5 +) + +type bspNode struct { + x, y, w, h int + left, right *bspNode + room *Room +} + +func GenerateFloor(floorNum int) *Floor { + root := &bspNode{x: 0, y: 0, w: MapWidth, h: MapHeight} + splitBSP(root, 0) + + var rooms []*Room + collectRooms(root, &rooms) + + // Ensure 5-8 rooms + for len(rooms) < 5 { + // Re-generate if too few + root = &bspNode{x: 0, y: 0, w: MapWidth, h: MapHeight} + splitBSP(root, 0) + rooms = nil + collectRooms(root, &rooms) + } + if len(rooms) > 8 { + rooms = rooms[:8] + } + + // Assign room types + for i := range rooms { + rooms[i].Type = RandomRoomType() + } + rooms[len(rooms)-1].Type = RoomBoss + + // Connect rooms (sibling nodes + extra) + connectBSP(root, rooms) + + // Build tile map + tiles := buildTileMap(rooms) + + return &Floor{ + Number: floorNum, + Rooms: rooms, + CurrentRoom: 0, + Tiles: tiles, + Width: MapWidth, + Height: MapHeight, + } +} +``` + +Add `Tiles [][]Tile`, `Width`, `Height` fields to `Floor` struct. + +Define `Tile` type: +```go +type Tile int +const ( + TileWall Tile = iota + TileFloor + TileCorridor + TileDoor +) +``` + +Implement `splitBSP`, `collectRooms`, `connectBSP`, `buildTileMap` functions. + +- [ ] **Step 3: Create tile map renderer** + +Create `dungeon/render.go`: +```go +func RenderFloor(floor *Floor, showFog bool) string { + // Render tiles as ASCII: '#' wall, '.' floor, '+' door, '#' corridor + // Mark current room's content + // Apply fog of war: hidden rooms render as space, visited as dim +} +``` + +Characters: +- `#` wall +- `.` floor +- `+` door / corridor +- `@` player position (center of current room) +- `D` monster (in combat rooms) +- `$` treasure, `S` shop, `?` event, `B` boss + +- [ ] **Step 4: Update game_view.go to use tile renderer** + +Replace `renderMap()` in `ui/game_view.go`: +```go +func renderMap(floor *dungeon.Floor) string { + return dungeon.RenderFloor(floor, true) +} +``` + +- [ ] **Step 5: Update generator_test.go** + +Update existing tests to work with new generator. Verify: +- 5-8 rooms +- Exactly 1 boss room +- All rooms connected +- Tile map dimensions are MapWidth x MapHeight +- Player start room (index 0) has floor tiles + +- [ ] **Step 6: Build and test** + +```bash +/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./... +/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./dungeon/ -v +``` + +- [ ] **Step 7: Commit** + +```bash +git add dungeon/ ui/game_view.go +git commit -m "feat: BSP dungeon generation with 2D ASCII tile map rendering" +``` + +--- + +### Task 7: Lobby Join-by-Code + +**Files:** +- Modify: `ui/model.go`, `ui/lobby_view.go` + +- [ ] **Step 1: Add join-by-code input mode to lobby** + +In `ui/model.go`, add to `lobbyState`: +```go +type lobbyState struct { + rooms []roomInfo + input string + cursor int + joining bool // true when typing a code + codeInput string // accumulated code chars +} +``` + +- [ ] **Step 2: Handle 'j' key and code input** + +In `updateLobby`: +```go +if m.lobbyState.joining { + if isEnter(key) && len(m.lobbyState.codeInput) == 4 { + if err := m.lobby.JoinRoom(m.lobbyState.codeInput, m.playerName); err == nil { + m.roomCode = m.lobbyState.codeInput + m.screen = screenClassSelect + } + m.lobbyState.joining = false + m.lobbyState.codeInput = "" + } else if isKey(key, "esc") { + m.lobbyState.joining = false + m.lobbyState.codeInput = "" + } else if len(key.String()) == 1 && len(m.lobbyState.codeInput) < 4 { + m.lobbyState.codeInput += strings.ToUpper(key.String()) + } + return m, nil +} +// existing key handling... +if isKey(key, "j") { + m.lobbyState.joining = true + m.lobbyState.codeInput = "" +} +``` + +- [ ] **Step 3: Show code input in lobby view** + +In `ui/lobby_view.go` `renderLobby`, add: +```go +if state.joining { + inputStr := state.codeInput + strings.Repeat("_", 4-len(state.codeInput)) + sb.WriteString(fmt.Sprintf("\nEnter room code: [%s] (Esc to cancel)", inputStr)) +} +``` + +- [ ] **Step 4: Build** + +```bash +/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./... +``` + +- [ ] **Step 5: Commit** + +```bash +git add ui/model.go ui/lobby_view.go +git commit -m "feat: lobby join-by-code with J key and 4-char input" +```