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>
This commit is contained in:
2026-03-24 00:48:58 +09:00
parent 4e76e48588
commit ecf6ee64d0

View File

@@ -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"
```