# Catacombs Implementation 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:** Build Catacombs, a multiplayer co-op roguelike terminal game accessible via SSH, using Go and the Charm stack. **Architecture:** Single Go binary monolith. Wish handles SSH connections, each spawning a Bubble Tea TUI program. A central GameSession goroutine per party owns all game state and broadcasts updates to connected players via channels. BoltDB stores rankings and player identities. **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` --- ## File Structure ``` catacombs/ ├── main.go # Entrypoint: starts SSH server ├── server/ │ └── ssh.go # Wish SSH config, session creation, key-based identity ├── game/ │ ├── lobby.go # Room listing, creation (4-char code), join, start │ ├── session.go # GameSession goroutine: state ownership, broadcast, turn loop │ ├── turn.go # Turn timer (5s), input collection, action resolution │ └── event.go # Room event dispatch: combat, shop, treasure, random events ├── dungeon/ │ ├── generator.go # BSP dungeon generation per floor │ ├── room.go # Room types, content placement, probability table │ └── fov.go # Fog of war: visited/visible/hidden states ├── entity/ │ ├── player.go # Player struct, class stats, inventory, gold │ ├── monster.go # Monster definitions, stat scaling, AI targeting │ └── item.go # Items: weapons, armor, consumables, relics ├── combat/ │ └── combat.go # Damage calc, co-op bonus, flee, AoE, boss patterns ├── ui/ │ ├── model.go # Root Bubble Tea model: state machine, input routing │ ├── title.go # Title screen view │ ├── lobby_view.go # Lobby view: room list, create/join │ ├── game_view.go # Dungeon map + HUD + combat UI rendering │ └── result_view.go # Game over / victory screen, ranking display ├── store/ │ └── db.go # BoltDB: player profiles (key fingerprint → nickname), rankings ├── go.mod ├── go.sum ├── Dockerfile └── docker-compose.yml ``` --- ### Task 1: Project Scaffold & SSH Server **Files:** - Create: `main.go`, `server/ssh.go`, `go.mod`, `Dockerfile`, `docker-compose.yml` - [ ] **Step 1: Initialize Go module** ```bash cd E:/projects/catacombs go mod init github.com/tolelom/catacombs ``` - [ ] **Step 2: Install dependencies** ```bash go get github.com/charmbracelet/wish@latest go get github.com/charmbracelet/bubbletea@latest go get github.com/charmbracelet/lipgloss@latest go get go.etcd.io/bbolt@latest ``` - [ ] **Step 3: Write SSH server** `server/ssh.go` — Wish SSH server that accepts all connections, extracts public key fingerprint, creates a Bubble Tea program per session. ```go package server import ( "context" "fmt" "log" "time" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" "github.com/charmbracelet/wish/bubbletea" "github.com/tolelom/catacombs/ui" ) func Start(host string, port int) error { s, err := wish.NewServer( wish.WithAddress(fmt.Sprintf("%s:%d", host, port)), wish.WithHostKeyPath(".ssh/catacombs_host_key"), wish.WithPublicKeyAuth(func(_ ssh.Context, _ ssh.PublicKey) bool { return true // accept all keys }), wish.WithMiddleware( bubbletea.Middleware(func(s ssh.Session) (bubbletea.Model, []bubbletea.ProgramOption) { pty, _, _ := s.Pty() fingerprint := "" if s.PublicKey() != nil { fingerprint = ssh.FingerprintSHA256(s.PublicKey()) } m := ui.NewModel(pty.Window.Width, pty.Window.Height, fingerprint) return m, []bubbletea.ProgramOption{bubbletea.WithAltScreen()} }), ), ) if err != nil { return fmt.Errorf("could not create server: %w", err) } log.Printf("Starting SSH server on %s:%d", host, port) return s.ListenAndServe() } ``` - [ ] **Step 4: Write entrypoint** `main.go`: ```go package main import ( "log" "github.com/tolelom/catacombs/server" ) func main() { if err := server.Start("0.0.0.0", 2222); err != nil { log.Fatal(err) } } ``` - [ ] **Step 5: Write minimal UI model (placeholder)** `ui/model.go` — just enough to verify SSH works: ```go package ui import ( tea "github.com/charmbracelet/bubbletea" ) type screen int const ( screenTitle screen = iota ) type Model struct { width int height int fingerprint string screen screen } func NewModel(width, height int, fingerprint string) Model { return Model{ width: width, height: height, fingerprint: fingerprint, screen: screenTitle, } } func (m Model) Init() tea.Cmd { return nil } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: if msg.String() == "q" || msg.String() == "ctrl+c" { return m, tea.Quit } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height } return m, nil } func (m Model) View() string { return "Welcome to Catacombs!\n\nPress q to quit." } ``` - [ ] **Step 6: Write Dockerfile** ```dockerfile FROM golang:1.22-alpine AS build WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -o catacombs . FROM alpine:latest WORKDIR /app COPY --from=build /app/catacombs . EXPOSE 2222 CMD ["./catacombs"] ``` - [ ] **Step 7: Write docker-compose.yml** ```yaml services: catacombs: build: . ports: - "2222:2222" volumes: - catacombs-data:/app/data restart: unless-stopped volumes: catacombs-data: ``` - [ ] **Step 8: Build and verify** ```bash go build -o catacombs . ``` Expected: binary compiles without errors. - [ ] **Step 9: Commit** ```bash git add -A git commit -m "feat: project scaffold with SSH server and placeholder TUI" ``` --- ### Task 2: Entity Definitions (Player, Monster, Item) **Files:** - Create: `entity/player.go`, `entity/monster.go`, `entity/item.go` - Test: `entity/player_test.go`, `entity/monster_test.go`, `entity/item_test.go` - [ ] **Step 1: Write item types first** (player depends on Item/Relic types) `entity/item.go`: ```go package entity type ItemType int const ( ItemWeapon ItemType = iota ItemArmor ItemConsumable ) type Item struct { Name string Type ItemType Bonus int // ATK bonus for weapons, DEF bonus for armor, HP restore for consumables Price int } type RelicEffect int const ( RelicHealOnKill RelicEffect = iota RelicATKBoost RelicDEFBoost RelicGoldBoost ) type Relic struct { Name string Effect RelicEffect Value int Price int } func NewHPPotion() Item { return Item{Name: "HP Potion", Type: ItemConsumable, Bonus: 30, Price: 20} } ``` - [ ] **Step 2: Write player test** `entity/player_test.go`: ```go package entity import "testing" func TestNewPlayer(t *testing.T) { p := NewPlayer("testuser", ClassWarrior) if p.HP != 120 || p.MaxHP != 120 { t.Errorf("Warrior HP: got %d, want 120", p.HP) } if p.ATK != 12 { t.Errorf("Warrior ATK: got %d, want 12", p.ATK) } if p.DEF != 8 { t.Errorf("Warrior DEF: got %d, want 8", p.DEF) } if p.Gold != 0 { t.Errorf("Initial gold: got %d, want 0", p.Gold) } } func TestAllClasses(t *testing.T) { tests := []struct { class Class hp, atk, def int }{ {ClassWarrior, 120, 12, 8}, {ClassMage, 70, 20, 3}, {ClassHealer, 90, 8, 5}, {ClassRogue, 85, 15, 4}, } for _, tt := range tests { p := NewPlayer("test", tt.class) if p.HP != tt.hp || p.ATK != tt.atk || p.DEF != tt.def { t.Errorf("Class %v: got HP=%d ATK=%d DEF=%d, want HP=%d ATK=%d DEF=%d", tt.class, p.HP, p.ATK, p.DEF, tt.hp, tt.atk, tt.def) } } } func TestPlayerTakeDamage(t *testing.T) { p := NewPlayer("test", ClassWarrior) p.TakeDamage(30) if p.HP != 90 { t.Errorf("HP after 30 dmg: got %d, want 90", p.HP) } p.TakeDamage(200) if p.HP != 0 { t.Errorf("HP should not go below 0: got %d", p.HP) } if !p.IsDead() { t.Error("Player should be dead") } } ``` - [ ] **Step 3: Run test to verify it fails** ```bash cd E:/projects/catacombs && go test ./entity/ -v ``` Expected: FAIL — Player type not defined. - [ ] **Step 4: Implement player** `entity/player.go`: ```go package entity type Class int const ( ClassWarrior Class = iota ClassMage ClassHealer ClassRogue ) func (c Class) String() string { return [...]string{"Warrior", "Mage", "Healer", "Rogue"}[c] } type classStats struct { HP, ATK, DEF int } var classBaseStats = map[Class]classStats{ ClassWarrior: {120, 12, 8}, ClassMage: {70, 20, 3}, ClassHealer: {90, 8, 5}, ClassRogue: {85, 15, 4}, } type Player struct { Name string Fingerprint string Class Class HP, MaxHP int ATK, DEF int Gold int Inventory []Item Relics []Relic Dead bool } func NewPlayer(name string, class Class) *Player { stats := classBaseStats[class] return &Player{ Name: name, Class: class, HP: stats.HP, MaxHP: stats.HP, ATK: stats.ATK, DEF: stats.DEF, } } func (p *Player) TakeDamage(dmg int) { p.HP -= dmg if p.HP <= 0 { p.HP = 0 p.Dead = true } } func (p *Player) Heal(amount int) { p.HP += amount if p.HP > p.MaxHP { p.HP = p.MaxHP } } func (p *Player) IsDead() bool { return p.Dead } func (p *Player) Revive(hpPercent float64) { p.Dead = false p.HP = int(float64(p.MaxHP) * hpPercent) if p.HP < 1 { p.HP = 1 } } func (p *Player) EffectiveATK() int { atk := p.ATK for _, item := range p.Inventory { if item.Type == ItemWeapon { atk += item.Bonus } } return atk } func (p *Player) EffectiveDEF() int { def := p.DEF for _, item := range p.Inventory { if item.Type == ItemArmor { def += item.Bonus } } return def } ``` - [ ] **Step 5: Run player tests** ```bash go test ./entity/ -run TestNewPlayer -v && go test ./entity/ -run TestAllClasses -v && go test ./entity/ -run TestPlayerTakeDamage -v ``` Expected: all PASS. - [ ] **Step 6: Write monster test** `entity/monster_test.go`: ```go package entity import ( "testing" "math" ) func TestMonsterScaling(t *testing.T) { slime := NewMonster(MonsterSlime, 1) if slime.HP != 20 || slime.ATK != 5 { t.Errorf("Slime floor 1: got HP=%d ATK=%d, want HP=20 ATK=5", slime.HP, slime.ATK) } slimeF3 := NewMonster(MonsterSlime, 3) expectedHP := int(math.Round(20 * math.Pow(1.15, 2))) if slimeF3.HP != expectedHP { t.Errorf("Slime floor 3: got HP=%d, want %d", slimeF3.HP, expectedHP) } } func TestBossStats(t *testing.T) { boss := NewMonster(MonsterBoss5, 5) if boss.HP != 150 || boss.ATK != 15 || boss.DEF != 8 { t.Errorf("Boss5: got HP=%d ATK=%d DEF=%d, want 150/15/8", boss.HP, boss.ATK, boss.DEF) } } ``` - [ ] **Step 7: Implement monster** `entity/monster.go`: ```go package entity import "math" type MonsterType int const ( MonsterSlime MonsterType = iota MonsterSkeleton MonsterOrc MonsterDarkKnight MonsterBoss5 MonsterBoss10 MonsterBoss15 MonsterBoss20 ) type monsterBase struct { Name string HP, ATK, DEF int MinFloor int IsBoss bool } var monsterDefs = map[MonsterType]monsterBase{ MonsterSlime: {"Slime", 20, 5, 1, 1, false}, MonsterSkeleton: {"Skeleton", 35, 10, 4, 3, false}, MonsterOrc: {"Orc", 55, 14, 6, 6, false}, MonsterDarkKnight: {"Dark Knight", 80, 18, 10, 12, false}, MonsterBoss5: {"Guardian", 150, 15, 8, 5, true}, MonsterBoss10: {"Warden", 250, 22, 12, 10, true}, MonsterBoss15: {"Overlord", 400, 30, 16, 15, true}, MonsterBoss20: {"Archlich", 600, 40, 20, 20, true}, } type Monster struct { Name string Type MonsterType HP, MaxHP int ATK, DEF int IsBoss bool TauntTarget bool // is being taunted TauntTurns int } func NewMonster(mt MonsterType, floor int) *Monster { base := monsterDefs[mt] scale := 1.0 if !base.IsBoss && floor > base.MinFloor { scale = math.Pow(1.15, float64(floor-base.MinFloor)) } hp := int(math.Round(float64(base.HP) * scale)) atk := int(math.Round(float64(base.ATK) * scale)) return &Monster{ Name: base.Name, Type: mt, HP: hp, MaxHP: hp, ATK: atk, DEF: base.DEF, IsBoss: base.IsBoss, } } func (m *Monster) TakeDamage(dmg int) { m.HP -= dmg if m.HP < 0 { m.HP = 0 } } func (m *Monster) IsDead() bool { return m.HP <= 0 } func (m *Monster) TickTaunt() { if m.TauntTurns > 0 { m.TauntTurns-- if m.TauntTurns == 0 { m.TauntTarget = false } } } ``` - [ ] **Step 8: Run all entity tests** ```bash go test ./entity/ -v ``` Expected: all PASS. - [ ] **Step 9: Commit** ```bash git add entity/ git commit -m "feat: entity definitions — player classes, monsters, items" ``` --- ### Task 3: Combat System **Files:** - Create: `combat/combat.go` - Test: `combat/combat_test.go` - [ ] **Step 1: Write combat test** `combat/combat_test.go`: ```go package combat import ( "testing" "github.com/tolelom/catacombs/entity" ) func TestCalcDamage(t *testing.T) { // Warrior ATK=12, skill multiplier 1.0, vs Slime DEF=1 // Expected: max(1, 12*1.0 - 1) * rand(0.85~1.15) = 11 * (0.85~1.15) // Range: 9~12 (with rounding) dmg := CalcDamage(12, 1, 1.0) if dmg < 9 || dmg > 13 { t.Errorf("Damage out of expected range: got %d, want 9~13", dmg) } } func TestCalcDamageMinimum(t *testing.T) { // ATK=1 vs DEF=100 → max(1, 1-100) = 1 * rand = 1 dmg := CalcDamage(1, 100, 1.0) if dmg != 1 { t.Errorf("Minimum damage: got %d, want 1", dmg) } } func TestCoopBonus(t *testing.T) { // Two players attacking same target: 2nd gets 10% bonus attackers := []AttackIntent{ {PlayerATK: 12, TargetIdx: 0, Multiplier: 1.0, IsAoE: false}, {PlayerATK: 15, TargetIdx: 0, Multiplier: 1.0, IsAoE: false}, } results := ResolveAttacks(attackers, []*entity.Monster{entity.NewMonster(entity.MonsterSlime, 1)}) // Second attacker should have co-op bonus if !results[1].CoopApplied { t.Error("Second attacker should get co-op bonus") } } func TestAoENoCoopBonus(t *testing.T) { attackers := []AttackIntent{ {PlayerATK: 12, TargetIdx: 0, Multiplier: 1.0, IsAoE: false}, {PlayerATK: 20, TargetIdx: -1, Multiplier: 0.8, IsAoE: true}, // fireball } monsters := []*entity.Monster{ entity.NewMonster(entity.MonsterSlime, 1), entity.NewMonster(entity.MonsterSlime, 1), } results := ResolveAttacks(attackers, monsters) // First attacker should NOT get co-op from AoE if results[0].CoopApplied { t.Error("AoE should not trigger co-op bonus") } } func TestFleeChance(t *testing.T) { // Run 100 flee attempts, expect roughly 50% success successes := 0 for i := 0; i < 100; i++ { if AttemptFlee() { successes++ } } if successes < 20 || successes > 80 { t.Errorf("Flee success rate suspicious: %d/100", successes) } } ``` - [ ] **Step 2: Run test to verify it fails** ```bash go test ./combat/ -v ``` Expected: FAIL — package not defined. - [ ] **Step 3: Implement combat** `combat/combat.go`: ```go package combat import ( "math" "math/rand" "github.com/tolelom/catacombs/entity" ) // CalcDamage: max(1, ATK * multiplier - DEF) * random(0.85~1.15) func CalcDamage(atk, def int, multiplier float64) int { base := float64(atk)*multiplier - float64(def) if base < 1 { base = 1 } randomFactor := 0.85 + rand.Float64()*0.30 // 0.85 ~ 1.15 return int(math.Round(base * randomFactor)) } type AttackIntent struct { PlayerATK int TargetIdx int // -1 for AoE Multiplier float64 IsAoE bool } type AttackResult struct { TargetIdx int Damage int CoopApplied bool IsAoE bool } func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster) []AttackResult { // Count single-target attacks per target (for co-op bonus) targetCount := make(map[int]int) targetOrder := make(map[int]int) // which attacker index was first for i, intent := range intents { if !intent.IsAoE { targetCount[intent.TargetIdx]++ if _, ok := targetOrder[intent.TargetIdx]; !ok { targetOrder[intent.TargetIdx] = i } } } results := make([]AttackResult, len(intents)) for i, intent := range intents { if intent.IsAoE { // AoE hits all monsters, no co-op bonus totalDmg := 0 for _, m := range monsters { if !m.IsDead() { dmg := CalcDamage(intent.PlayerATK, m.DEF, intent.Multiplier) m.TakeDamage(dmg) totalDmg += dmg } } results[i] = AttackResult{TargetIdx: -1, Damage: totalDmg, IsAoE: true} } else { if intent.TargetIdx < 0 || intent.TargetIdx >= len(monsters) { continue } m := monsters[intent.TargetIdx] dmg := CalcDamage(intent.PlayerATK, m.DEF, intent.Multiplier) // Co-op bonus: 2+ single-target attackers on same target, 2nd+ gets 10% coopApplied := false if targetCount[intent.TargetIdx] >= 2 && targetOrder[intent.TargetIdx] != i { dmg = int(math.Round(float64(dmg) * 1.10)) coopApplied = true } m.TakeDamage(dmg) results[i] = AttackResult{ TargetIdx: intent.TargetIdx, Damage: dmg, CoopApplied: coopApplied, } } } return results } func AttemptFlee() bool { return rand.Float64() < 0.5 } // MonsterAI picks a target for a monster func MonsterAI(m *entity.Monster, players []*entity.Player, turnNumber int) (targetIdx int, isAoE bool) { // Boss AoE every 3 turns if m.IsBoss && turnNumber%3 == 0 { return -1, true } // If taunted, attack the taunter if m.TauntTarget { for i, p := range players { if !p.IsDead() && p.Class == entity.ClassWarrior { return i, false } } } // 30% chance to target lowest HP player if rand.Float64() < 0.3 { minHP := int(^uint(0) >> 1) minIdx := 0 for i, p := range players { if !p.IsDead() && p.HP < minHP { minHP = p.HP minIdx = i } } return minIdx, false } // Default: first alive player (closest) for i, p := range players { if !p.IsDead() { return i, false } } return 0, false } ``` - [ ] **Step 4: Run combat tests** ```bash go test ./combat/ -v ``` Expected: all PASS. - [ ] **Step 5: Commit** ```bash git add combat/ git commit -m "feat: combat system — damage calc, co-op bonus, flee, monster AI" ``` --- ### Task 4: Dungeon Generation **Files:** - Create: `dungeon/generator.go`, `dungeon/room.go`, `dungeon/fov.go` - Test: `dungeon/generator_test.go`, `dungeon/room_test.go` - [ ] **Step 1: Write room types** `dungeon/room.go`: ```go package dungeon import "math/rand" type RoomType int const ( RoomCombat RoomType = iota RoomTreasure RoomShop RoomEvent RoomEmpty RoomBoss ) func (r RoomType) String() string { return [...]string{"Combat", "Treasure", "Shop", "Event", "Empty", "Boss"}[r] } type Room struct { Type RoomType X, Y int // position in grid Width, Height int Visited bool Cleared bool Neighbors []int // indices of connected rooms } // RandomRoomType returns a room type based on probability table // Combat: 45%, Treasure: 15%, Shop: 10%, Event: 15%, Empty: 15% func RandomRoomType() RoomType { r := rand.Float64() * 100 switch { case r < 45: return RoomCombat case r < 60: return RoomTreasure case r < 70: return RoomShop case r < 85: return RoomEvent default: return RoomEmpty } } ``` - [ ] **Step 2: Write generator test** `dungeon/generator_test.go`: ```go package dungeon import "testing" func TestGenerateFloor(t *testing.T) { floor := GenerateFloor(1) if len(floor.Rooms) < 5 || len(floor.Rooms) > 8 { t.Errorf("Room count: got %d, want 5~8", len(floor.Rooms)) } // Must have exactly one boss room bossCount := 0 for _, r := range floor.Rooms { if r.Type == RoomBoss { bossCount++ } } if bossCount != 1 { t.Errorf("Boss rooms: got %d, want 1", bossCount) } // All rooms must be connected (reachable from room 0) visited := make(map[int]bool) var dfs func(int) dfs = func(idx int) { if visited[idx] { return } visited[idx] = true for _, n := range floor.Rooms[idx].Neighbors { dfs(n) } } dfs(0) if len(visited) != len(floor.Rooms) { t.Errorf("Not all rooms connected: reachable %d / %d", len(visited), len(floor.Rooms)) } } func TestRoomTypeProbability(t *testing.T) { counts := make(map[RoomType]int) n := 10000 for i := 0; i < n; i++ { counts[RandomRoomType()]++ } // Combat should be ~45% (allow 40-50%) combatPct := float64(counts[RoomCombat]) / float64(n) * 100 if combatPct < 40 || combatPct > 50 { t.Errorf("Combat room probability: got %.1f%%, want ~45%%", combatPct) } } ``` - [ ] **Step 3: Run test to verify it fails** ```bash go test ./dungeon/ -v ``` Expected: FAIL. - [ ] **Step 4: Implement BSP generator** `dungeon/generator.go`: ```go package dungeon import "math/rand" type Floor struct { Number int Rooms []*Room CurrentRoom int } func GenerateFloor(floorNum int) *Floor { numRooms := 5 + rand.Intn(4) // 5~8 rooms rooms := make([]*Room, numRooms) // Place rooms in a simple grid layout for BSP-like connectivity for i := 0; i < numRooms; i++ { rt := RandomRoomType() rooms[i] = &Room{ Type: rt, X: (i % 3) * 20, Y: (i / 3) * 10, Width: 12 + rand.Intn(6), Height: 6 + rand.Intn(4), Neighbors: []int{}, } } // Last room is always the boss room rooms[numRooms-1].Type = RoomBoss // Connect rooms linearly, then add some extra edges for variety for i := 0; i < numRooms-1; i++ { rooms[i].Neighbors = append(rooms[i].Neighbors, i+1) rooms[i+1].Neighbors = append(rooms[i+1].Neighbors, i) } // Add 1-2 extra connections for loops extras := 1 + rand.Intn(2) for e := 0; e < extras; e++ { a := rand.Intn(numRooms) b := rand.Intn(numRooms) if a != b && !hasNeighbor(rooms[a], b) { rooms[a].Neighbors = append(rooms[a].Neighbors, b) rooms[b].Neighbors = append(rooms[b].Neighbors, a) } } return &Floor{ Number: floorNum, Rooms: rooms, CurrentRoom: 0, } } func hasNeighbor(r *Room, idx int) bool { for _, n := range r.Neighbors { if n == idx { return true } } return false } ``` - [ ] **Step 5: Implement FOV** `dungeon/fov.go`: ```go package dungeon type Visibility int const ( Hidden Visibility = iota Visited // dimmed Visible // fully visible ) func UpdateVisibility(floor *Floor) { for i, room := range floor.Rooms { if i == floor.CurrentRoom { room.Visited = true } } } func GetRoomVisibility(floor *Floor, roomIdx int) Visibility { if roomIdx == floor.CurrentRoom { return Visible } if floor.Rooms[roomIdx].Visited { return Visited } return Hidden } ``` - [ ] **Step 6: Run dungeon tests** ```bash go test ./dungeon/ -v ``` Expected: all PASS. - [ ] **Step 7: Commit** ```bash git add dungeon/ git commit -m "feat: dungeon generation — BSP rooms, room types, fog of war" ``` --- ### Task 5: Game Session & Turn System **Files:** - Create: `game/session.go`, `game/turn.go`, `game/event.go`, `game/lobby.go` - Test: `game/session_test.go`, `game/turn_test.go`, `game/lobby_test.go` - [ ] **Step 1: Write lobby test** `game/lobby_test.go`: ```go 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") } } ``` - [ ] **Step 2: Implement lobby** `game/lobby.go`: ```go 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) } ``` - [ ] **Step 3: Write session/turn test** `game/session_test.go`: ```go 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") } } ``` - [ ] **Step 4: Implement session and turn** `game/session.go`: ```go 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} } ``` `game/turn.go`: ```go 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) } } } // 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 } 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 } } ``` `game/event.go`: ```go 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) } } } ``` - [ ] **Step 5: Run lobby tests** ```bash go test ./game/ -run TestCreate -v && go test ./game/ -run TestJoin -v ``` Expected: PASS. - [ ] **Step 6: Run session/turn tests** ```bash go test ./game/ -run TestSession -v -timeout 10s ``` Expected: PASS (turn resolves within 5s + buffer). - [ ] **Step 7: Commit** ```bash git add game/ git commit -m "feat: game session, turn system, lobby, and room events" ``` --- ### Task 6: BoltDB Store (Rankings & Player Identity) **Files:** - Create: `store/db.go` - Test: `store/db_test.go` - [ ] **Step 1: Write store test** `store/db_test.go`: ```go package store import ( "os" "testing" ) func TestPlayerProfile(t *testing.T) { db, err := Open("test.db") if err != nil { t.Fatal(err) } defer func() { db.Close() os.Remove("test.db") }() err = db.SaveProfile("SHA256:abc123", "TestPlayer") if err != nil { t.Fatal(err) } name, err := db.GetProfile("SHA256:abc123") if err != nil { t.Fatal(err) } if name != "TestPlayer" { t.Errorf("Name: got %q, want %q", name, "TestPlayer") } } func TestRanking(t *testing.T) { db, err := Open("test_rank.db") if err != nil { t.Fatal(err) } defer func() { db.Close() os.Remove("test_rank.db") }() db.SaveRun("Alice", 20, 1500) db.SaveRun("Bob", 15, 1000) db.SaveRun("Charlie", 20, 2000) rankings, err := db.TopRuns(10) if err != nil { t.Fatal(err) } if len(rankings) != 3 { t.Errorf("Rankings: got %d, want 3", len(rankings)) } if rankings[0].Player != "Charlie" { t.Errorf("Top player: got %q, want Charlie", rankings[0].Player) } } ``` - [ ] **Step 2: Run test to verify it fails** ```bash go test ./store/ -v ``` Expected: FAIL. - [ ] **Step 3: Implement store** `store/db.go`: ```go package store import ( "encoding/json" "fmt" "sort" bolt "go.etcd.io/bbolt" ) var ( bucketProfiles = []byte("profiles") bucketRankings = []byte("rankings") ) type DB struct { db *bolt.DB } type RunRecord struct { Player string `json:"player"` Floor int `json:"floor"` Score int `json:"score"` } func Open(path string) (*DB, error) { db, err := bolt.Open(path, 0600, nil) if err != nil { return nil, err } err = db.Update(func(tx *bolt.Tx) error { if _, err := tx.CreateBucketIfNotExists(bucketProfiles); err != nil { return err } if _, err := tx.CreateBucketIfNotExists(bucketRankings); err != nil { return err } return nil }) return &DB{db: db}, err } func (d *DB) Close() error { return d.db.Close() } func (d *DB) SaveProfile(fingerprint, name string) error { return d.db.Update(func(tx *bolt.Tx) error { return tx.Bucket(bucketProfiles).Put([]byte(fingerprint), []byte(name)) }) } func (d *DB) GetProfile(fingerprint string) (string, error) { var name string err := d.db.View(func(tx *bolt.Tx) error { v := tx.Bucket(bucketProfiles).Get([]byte(fingerprint)) if v == nil { return fmt.Errorf("profile not found") } name = string(v) return nil }) return name, err } func (d *DB) SaveRun(player string, floor, score int) error { return d.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket(bucketRankings) id, _ := b.NextSequence() record := RunRecord{Player: player, Floor: floor, Score: score} data, err := json.Marshal(record) if err != nil { return err } return b.Put([]byte(fmt.Sprintf("%010d", id)), data) }) } func (d *DB) TopRuns(limit int) ([]RunRecord, error) { var runs []RunRecord err := d.db.View(func(tx *bolt.Tx) error { b := tx.Bucket(bucketRankings) return b.ForEach(func(k, v []byte) error { var r RunRecord if err := json.Unmarshal(v, &r); err != nil { return err } runs = append(runs, r) return nil }) }) if err != nil { return nil, err } sort.Slice(runs, func(i, j int) bool { if runs[i].Floor != runs[j].Floor { return runs[i].Floor > runs[j].Floor } return runs[i].Score > runs[j].Score }) if len(runs) > limit { runs = runs[:limit] } return runs, nil } ``` - [ ] **Step 4: Run store tests** ```bash go test ./store/ -v ``` Expected: all PASS. - [ ] **Step 5: Commit** ```bash git add store/ git commit -m "feat: BoltDB store — player profiles and run rankings" ``` --- ### Task 7: TUI Views (Title, Lobby, Game, Result) **Files:** - Modify: `ui/model.go` - Create: `ui/title.go`, `ui/lobby_view.go`, `ui/game_view.go`, `ui/result_view.go` - [ ] **Step 1: Write title screen** `ui/title.go`: ```go package ui import ( "github.com/charmbracelet/lipgloss" ) var titleArt = ` ██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗ ██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝ ██║ ███████║ ██║ ███████║██║ ██║ ██║██╔████╔██║██████╔╝███████╗ ██║ ██╔══██║ ██║ ██╔══██║██║ ██║ ██║██║╚██╔╝██║██╔══██╗╚════██║ ╚██████╗██║ ██║ ██║ ██║ ██║╚██████╗╚██████╔╝██║ ╚═╝ ██║██████╔╝███████║ ╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝ ` func renderTitle(width, height int) string { titleStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("205")). Bold(true). Align(lipgloss.Center) subtitleStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("240")). Align(lipgloss.Center) menuStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("255")). Align(lipgloss.Center) return lipgloss.JoinVertical(lipgloss.Center, titleStyle.Render(titleArt), "", subtitleStyle.Render("A Co-op Roguelike Adventure"), "", menuStyle.Render("[Enter] Start [Q] Quit"), ) } ``` - [ ] **Step 2: Write lobby view** `ui/lobby_view.go`: ```go package ui import ( "fmt" "github.com/charmbracelet/lipgloss" ) type lobbyState struct { rooms []roomInfo input string cursor int creating bool roomName string } type roomInfo struct { Code string Name string Players int Status string } func renderLobby(state lobbyState, width, height int) string { headerStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("205")). Bold(true) roomStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). Padding(0, 1) header := headerStyle.Render("── Lobby ──") menu := "[C] Create Room [J] Join by Code [Q] Back" roomList := "" for i, r := range state.rooms { marker := " " if i == state.cursor { marker = "> " } roomList += fmt.Sprintf("%s%s [%s] (%d/4) %s\n", marker, r.Name, r.Code, r.Players, r.Status) } if roomList == "" { roomList = " No rooms available. Create one!" } return lipgloss.JoinVertical(lipgloss.Left, header, "", roomStyle.Render(roomList), "", menu, ) } ``` - [ ] **Step 3: Write game view** `ui/game_view.go`: ```go package ui import ( "fmt" "strings" "github.com/charmbracelet/lipgloss" "github.com/tolelom/catacombs/dungeon" "github.com/tolelom/catacombs/entity" "github.com/tolelom/catacombs/game" ) func renderGame(state game.GameState, width, height int) string { mapView := renderMap(state.Floor) hudView := renderHUD(state) return lipgloss.JoinVertical(lipgloss.Left, mapView, hudView, ) } func renderMap(floor *dungeon.Floor) string { if floor == nil { return "" } var sb strings.Builder headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) sb.WriteString(headerStyle.Render(fmt.Sprintf("── Catacombs B%d ──", floor.Number))) sb.WriteString("\n\n") roomStyle := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()) dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) hiddenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("236")) for i, room := range floor.Rooms { vis := dungeon.GetRoomVisibility(floor, i) symbol := roomTypeSymbol(room.Type) label := fmt.Sprintf("[%d] %s %s", i, symbol, room.Type.String()) if i == floor.CurrentRoom { label = ">> " + label + " <<" } switch vis { case dungeon.Visible: sb.WriteString(roomStyle.Render(label)) case dungeon.Visited: sb.WriteString(dimStyle.Render(label)) case dungeon.Hidden: sb.WriteString(hiddenStyle.Render("[?] ???")) } // Show connections for _, n := range room.Neighbors { if n > i { sb.WriteString(" ─── ") } } sb.WriteString("\n") } return sb.String() } func renderHUD(state game.GameState) string { var sb strings.Builder border := lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). Padding(0, 1) for _, p := range state.Players { hpBar := renderHPBar(p.HP, p.MaxHP, 20) status := "" if p.IsDead() { status = " [DEAD]" } sb.WriteString(fmt.Sprintf("%s (%s) %s %d/%d%s Gold: %d\n", p.Name, p.Class, hpBar, p.HP, p.MaxHP, status, p.Gold)) } if state.Phase == game.PhaseCombat { sb.WriteString("\n") for i, m := range state.Monsters { if !m.IsDead() { mhpBar := renderHPBar(m.HP, m.MaxHP, 15) sb.WriteString(fmt.Sprintf(" [%d] %s %s %d/%d\n", i, m.Name, mhpBar, m.HP, m.MaxHP)) } } sb.WriteString("\n[1]Attack [2]Skill [3]Item [4]Flee [5]Wait") } else if state.Phase == game.PhaseExploring { sb.WriteString("\nChoose a room to enter (number) or [Q] quit") } return border.Render(sb.String()) } func renderHPBar(current, max, width int) string { if max == 0 { return "" } filled := current * width / max if filled < 0 { filled = 0 } empty := width - filled greenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46")) redStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")) bar := greenStyle.Render(strings.Repeat("█", filled)) + redStyle.Render(strings.Repeat("░", empty)) return bar } func roomTypeSymbol(rt dungeon.RoomType) string { switch rt { case dungeon.RoomCombat: return "D" case dungeon.RoomTreasure: return "$" case dungeon.RoomShop: return "S" case dungeon.RoomEvent: return "?" case dungeon.RoomEmpty: return "." case dungeon.RoomBoss: return "B" default: return " " } } ``` - [ ] **Step 4: Write result view** `ui/result_view.go`: ```go package ui import ( "fmt" "github.com/charmbracelet/lipgloss" "github.com/tolelom/catacombs/store" ) func renderResult(won bool, floorReached int, rankings []store.RunRecord) string { titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205")) var title string if won { title = titleStyle.Render("VICTORY! You escaped the Catacombs!") } else { title = titleStyle.Render("GAME OVER") } floorInfo := fmt.Sprintf("Floor Reached: B%d", floorReached) rankHeader := lipgloss.NewStyle().Bold(true).Render("── Rankings ──") rankList := "" for i, r := range rankings { rankList += fmt.Sprintf(" %d. %s — B%d (Score: %d)\n", i+1, r.Player, r.Floor, r.Score) } menu := "[Enter] Return to Lobby [Q] Quit" return lipgloss.JoinVertical(lipgloss.Center, title, "", floorInfo, "", rankHeader, rankList, "", menu, ) } ``` - [ ] **Step 5: Write class selection screen** `ui/class_view.go`: ```go package ui import ( "fmt" "github.com/charmbracelet/lipgloss" "github.com/tolelom/catacombs/entity" ) type classSelectState struct { cursor int } var classOptions = []struct { class entity.Class name string desc string }{ {entity.ClassWarrior, "Warrior", "HP:120 ATK:12 DEF:8 Skill: Taunt (draw enemy fire)"}, {entity.ClassMage, "Mage", "HP:70 ATK:20 DEF:3 Skill: Fireball (AoE damage)"}, {entity.ClassHealer, "Healer", "HP:90 ATK:8 DEF:5 Skill: Heal (restore 30 HP)"}, {entity.ClassRogue, "Rogue", "HP:85 ATK:15 DEF:4 Skill: Scout (reveal rooms)"}, } func renderClassSelect(state classSelectState, width, height int) string { headerStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("205")). Bold(true) selectedStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("46")). Bold(true) normalStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("255")) descStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("240")) header := headerStyle.Render("── Choose Your Class ──") list := "" for i, opt := range classOptions { marker := " " style := normalStyle if i == state.cursor { marker = "> " style = selectedStyle } list += fmt.Sprintf("%s%s\n %s\n\n", marker, style.Render(opt.name), descStyle.Render(opt.desc)) } menu := "[Up/Down] Select [Enter] Confirm" return lipgloss.JoinVertical(lipgloss.Left, header, "", list, menu, ) } ``` - [ ] **Step 6: Update model.go with full state machine** `ui/model.go` — complete rewrite with all screens and input routing: ```go package ui import ( "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/tolelom/catacombs/entity" "github.com/tolelom/catacombs/game" "github.com/tolelom/catacombs/store" ) type screen int const ( screenTitle screen = iota screenLobby screenClassSelect screenGame screenShop screenResult ) // StateUpdateMsg is sent by GameSession to update the view type StateUpdateMsg struct { State game.GameState } type Model struct { width int height int fingerprint string playerName string screen screen // Shared references (set by server) lobby *game.Lobby store *store.DB // Per-session state session *game.GameSession roomCode string gameState game.GameState lobbyState lobbyState classState classSelectState inputBuffer string } func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *store.DB) Model { return Model{ width: width, height: height, fingerprint: fingerprint, screen: screenTitle, lobby: lobby, store: db, } } func (m Model) Init() tea.Cmd { return nil } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height return m, nil case StateUpdateMsg: m.gameState = msg.State return m, nil } switch m.screen { case screenTitle: return m.updateTitle(msg) case screenLobby: return m.updateLobby(msg) case screenClassSelect: return m.updateClassSelect(msg) case screenGame: return m.updateGame(msg) case screenShop: return m.updateShop(msg) case screenResult: return m.updateResult(msg) } return m, nil } func (m Model) View() string { if m.width < 80 || m.height < 24 { return fmt.Sprintf("Terminal too small (%dx%d). Minimum: 80x24.", m.width, m.height) } switch m.screen { case screenTitle: return renderTitle(m.width, m.height) case screenLobby: return renderLobby(m.lobbyState, m.width, m.height) case screenClassSelect: return renderClassSelect(m.classState, m.width, m.height) case screenGame: return renderGame(m.gameState, m.width, m.height) case screenShop: return renderShop(m.gameState, m.width, m.height) case screenResult: rankings, _ := m.store.TopRuns(10) return renderResult(m.gameState.Victory, m.gameState.FloorNum, rankings) } return "" } func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "enter": // Check if player has a name, if not prompt for one name, err := m.store.GetProfile(m.fingerprint) if err != nil { m.playerName = "Adventurer" } else { m.playerName = name } m.screen = screenLobby m.refreshLobbyState() case "q", "ctrl+c": return m, tea.Quit } } return m, nil } func (m Model) updateLobby(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "c": code := m.lobby.CreateRoom(m.playerName + "'s Room") m.lobby.JoinRoom(code, m.playerName) m.roomCode = code m.screen = screenClassSelect case "j": // Join by code — simplified: use input buffer case "up": if m.lobbyState.cursor > 0 { m.lobbyState.cursor-- } case "down": if m.lobbyState.cursor < len(m.lobbyState.rooms)-1 { m.lobbyState.cursor++ } case "enter": if len(m.lobbyState.rooms) > 0 { r := m.lobbyState.rooms[m.lobbyState.cursor] if err := m.lobby.JoinRoom(r.Code, m.playerName); err == nil { m.roomCode = r.Code m.screen = screenClassSelect } } case "q": m.screen = screenTitle } } return m, nil } func (m Model) updateClassSelect(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "up": if m.classState.cursor > 0 { m.classState.cursor-- } case "down": if m.classState.cursor < len(classOptions)-1 { m.classState.cursor++ } case "enter": selectedClass := classOptions[m.classState.cursor].class room := m.lobby.GetRoom(m.roomCode) if room.Session == nil { room.Session = game.NewGameSession() } m.session = room.Session player := entity.NewPlayer(m.playerName, selectedClass) player.Fingerprint = m.fingerprint m.session.AddPlayer(player) m.session.StartGame() m.gameState = m.session.GetState() m.screen = screenGame return m, m.listenForUpdates() } } return m, nil } func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) { if m.gameState.GameOver { m.screen = screenResult return m, nil } if m.gameState.Phase == game.PhaseShop { m.screen = screenShop return m, nil } if key, ok := msg.(tea.KeyMsg); ok { switch m.gameState.Phase { case game.PhaseExploring: // Number keys to select room if key.String() >= "0" && key.String() <= "9" { idx := int(key.String()[0] - '0') m.session.EnterRoom(idx) m.gameState = m.session.GetState() } case game.PhaseCombat: switch key.String() { case "1": m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionAttack, TargetIdx: 0}) case "2": m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionSkill, TargetIdx: 0}) case "3": m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionItem}) case "4": m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionFlee}) case "5": m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionWait}) } } } return m, nil } func (m Model) updateShop(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "1", "2", "3": idx := int(key.String()[0] - '1') m.session.BuyItem(m.playerName, idx) m.gameState = m.session.GetState() case "q": m.session.LeaveShop() m.gameState = m.session.GetState() m.screen = screenGame } } return m, nil } func (m Model) updateResult(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "enter": m.screen = screenLobby m.refreshLobbyState() case "q", "ctrl+c": return m, tea.Quit } } return m, nil } func (m *Model) refreshLobbyState() { rooms := m.lobby.ListRooms() m.lobbyState.rooms = make([]roomInfo, len(rooms)) for i, r := range rooms { status := "Waiting" if r.Status == game.RoomPlaying { status = "Playing" } m.lobbyState.rooms[i] = roomInfo{ Code: r.Code, Name: r.Name, Players: len(r.Players), Status: status, } } m.lobbyState.cursor = 0 } // listenForUpdates returns a tea.Cmd that waits for state updates from GameSession func (m Model) listenForUpdates() tea.Cmd { // This will be connected to GameSession's broadcast channel in Task 8 return nil } ``` - [ ] **Step 7: Write shop view** `ui/shop_view.go`: ```go package ui import ( "fmt" "github.com/charmbracelet/lipgloss" "github.com/tolelom/catacombs/game" ) func renderShop(state game.GameState, width, height int) string { headerStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("226")). Bold(true) header := headerStyle.Render("── Shop ──") items := "" for i, item := range state.ShopItems { items += fmt.Sprintf(" [%d] %s (+%d) — %d gold\n", i+1, item.Name, item.Bonus, item.Price) } menu := "[1-3] Buy [Q] Leave Shop" return lipgloss.JoinVertical(lipgloss.Left, header, "", items, "", menu, ) } ``` - [ ] **Step 8: Build and verify compilation** ```bash go build ./... ``` Expected: compiles without errors. - [ ] **Step 7: Commit** ```bash git add ui/ git commit -m "feat: TUI views — title, lobby, game map, HUD, result screen" ``` --- ### Task 8: Integration — Wire Everything Together **Files:** - Modify: `main.go`, `server/ssh.go` - [ ] **Step 1: Update main.go with store and lobby initialization** ```go package main import ( "log" "os" "os/signal" "syscall" "github.com/tolelom/catacombs/game" "github.com/tolelom/catacombs/server" "github.com/tolelom/catacombs/store" ) func main() { db, err := store.Open("data/catacombs.db") if err != nil { log.Fatalf("Failed to open database: %v", err) } defer db.Close() lobby := game.NewLobby() go func() { if err := server.Start("0.0.0.0", 2222, lobby, db); err != nil { log.Fatal(err) } }() log.Println("Catacombs server running on :2222") // Wait for interrupt c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt, syscall.SIGTERM) <-c log.Println("Shutting down...") } ``` - [ ] **Step 2: Update server/ssh.go to pass lobby and store** ```go package server import ( "context" "fmt" "log" "time" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" "github.com/charmbracelet/wish/bubbletea" "github.com/tolelom/catacombs/game" "github.com/tolelom/catacombs/store" "github.com/tolelom/catacombs/ui" ) func Start(host string, port int, lobby *game.Lobby, db *store.DB) error { s, err := wish.NewServer( wish.WithAddress(fmt.Sprintf("%s:%d", host, port)), wish.WithHostKeyPath(".ssh/catacombs_host_key"), wish.WithPublicKeyAuth(func(_ ssh.Context, _ ssh.PublicKey) bool { return true }), wish.WithMiddleware( bubbletea.Middleware(func(s ssh.Session) (bubbletea.Model, []bubbletea.ProgramOption) { pty, _, _ := s.Pty() fingerprint := "" if s.PublicKey() != nil { fingerprint = ssh.FingerprintSHA256(s.PublicKey()) } m := ui.NewModel(pty.Window.Width, pty.Window.Height, fingerprint, lobby, db) return m, []bubbletea.ProgramOption{bubbletea.WithAltScreen()} }), ), ) if err != nil { return fmt.Errorf("could not create server: %w", err) } log.Printf("Starting SSH server on %s:%d", host, port) return s.ListenAndServe() } ``` - [ ] **Step 3: Create data directory and verify build** ```bash mkdir -p E:/projects/catacombs/data echo "data/" >> E:/projects/catacombs/.gitignore go build ./... ``` Expected: compiles without errors. - [ ] **Step 4: Manual test — single player flow** ```bash go build -o catacombs . && ./catacombs & ssh -p 2222 -o StrictHostKeyChecking=no localhost ``` Verify: title screen → lobby → create room → select class → enter dungeon → encounter combat → complete a few turns. - [ ] **Step 5: Manual test — two player co-op** Open two terminals, both SSH to localhost:2222. One creates a room, the other joins by code. Start game and verify both see the same dungeon state. - [ ] **Step 6: Commit** ```bash git add main.go server/ .gitignore git commit -m "feat: wire up SSH server with lobby, store, and graceful shutdown" ``` --- ### Task 9: Docker & Deployment **Files:** - Modify: `Dockerfile`, `docker-compose.yml` - [ ] **Step 1: Test Docker build** ```bash docker build -t catacombs . ``` Expected: image builds successfully. - [ ] **Step 2: Test Docker run** ```bash docker run -p 2222:2222 --name catacombs-test catacombs & ssh -p 2222 -o StrictHostKeyChecking=no localhost ``` Expected: game loads in terminal. - [ ] **Step 3: Stop and clean up test container** ```bash docker stop catacombs-test && docker rm catacombs-test ``` - [ ] **Step 4: Commit** ```bash git add Dockerfile docker-compose.yml git commit -m "feat: Docker build and compose for deployment" ``` --- ### Task 10: Polish & Final Testing - [ ] **Step 1: Terminal size check** Add terminal size validation on connect. If width < 80 or height < 24, display a warning message instead of the game. - [ ] **Step 2: Chat system** Add `/` key handler in combat view to open a one-line chat input. Messages broadcast to all party members via `GameSession`. - [ ] **Step 3: Run all tests** ```bash go test ./... -v ``` Expected: all PASS. - [ ] **Step 4: Full playthrough test** Start server, connect 2 players via SSH, play through at least 5 floors of co-op. Verify: - Class selection works - Combat resolves correctly - Turns time out properly - Dead player sees spectator view - Disconnect/reconnect works - Gold is distributed correctly - [ ] **Step 5: Final commit** ```bash git add -A git commit -m "feat: polish — terminal size check, chat system, final testing" ```