Files
Catacombs/docs/superpowers/plans/2026-03-24-catacombs-fixes.md
tolelom ecf6ee64d0 Add fix plan for spec compliance issues
7 tasks: quick fixes, relic effects, target selection, turn timer,
StartGame race fix, BSP tile map, lobby join-by-code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 00:48:58 +09:00

16 KiB

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:

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:

// 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:

if m.fingerprint != "" {
    m.store.SaveProfile(m.fingerprint, m.playerName)
}
  • Step 5: Build and run tests
/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
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:

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)
/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():

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:

// 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:

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
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./entity/ ./game/ -v -timeout 15s
  • Step 6: Commit
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:

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:

case "tab":
    if len(m.gameState.Monsters) > 0 {
        m.targetCursor = (m.targetCursor + 1) % len(m.gameState.Monsters)
    }

Update attack/skill to use m.targetCursor:

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:

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:

// 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:

sb.WriteString(actionStyle.Render("[1]Attack  [2]Skill  [3]Item  [4]Flee  [5]Wait  [Tab]Target"))
  • Step 6: Build and test
/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
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:

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:

s.state.TurnDeadline = time.Now().Add(TurnTimeout)

After turn resolves (in resolve section):

s.state.TurnDeadline = time.Time{} // clear deadline
  • Step 3: Display countdown in HUD

In ui/game_view.go renderHUD, when in combat phase:

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
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
  • Step 5: Commit
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:

type GameSession struct {
    // ...
    started bool
}

Guard StartGame():

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:

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
/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
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:

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:

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:

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:

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:

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

/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
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:

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:

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:

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
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
  • Step 5: Commit
git add ui/model.go ui/lobby_view.go
git commit -m "feat: lobby join-by-code with J key and 4-char input"