Files
Catacombs/docs/superpowers/plans/2026-03-23-catacombs.md
tolelom 5772ca8b3f Add implementation plan with review fixes
10-task plan covering: scaffold, entities, combat, dungeon gen,
game session, store, TUI views, integration, Docker, polish.
Fixes from review: class selection screen, floor advancement,
victory condition, item/shop usage, dead monster gold fix,
per-player flee logic, combat turn counter, boss relic drops.

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

63 KiB

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

cd E:/projects/catacombs
go mod init github.com/tolelom/catacombs
  • Step 2: Install dependencies
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.

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:

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:

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
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
services:
  catacombs:
    build: .
    ports:
      - "2222:2222"
    volumes:
      - catacombs-data:/app/data
    restart: unless-stopped

volumes:
  catacombs-data:
  • Step 8: Build and verify
go build -o catacombs .

Expected: binary compiles without errors.

  • Step 9: Commit
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:

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:

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
cd E:/projects/catacombs && go test ./entity/ -v

Expected: FAIL — Player type not defined.

  • Step 4: Implement player

entity/player.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
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:

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:

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
go test ./entity/ -v

Expected: all PASS.

  • Step 9: Commit
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:

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
go test ./combat/ -v

Expected: FAIL — package not defined.

  • Step 3: Implement combat

combat/combat.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
go test ./combat/ -v

Expected: all PASS.

  • Step 5: Commit
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:

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:

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
go test ./dungeon/ -v

Expected: FAIL.

  • Step 4: Implement BSP generator

dungeon/generator.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:

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
go test ./dungeon/ -v

Expected: all PASS.

  • Step 7: Commit
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:

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:

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:

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:

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:

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:

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
go test ./game/ -run TestCreate -v && go test ./game/ -run TestJoin -v

Expected: PASS.

  • Step 6: Run session/turn tests
go test ./game/ -run TestSession -v -timeout 10s

Expected: PASS (turn resolves within 5s + buffer).

  • Step 7: Commit
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:

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
go test ./store/ -v

Expected: FAIL.

  • Step 3: Implement store

store/db.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
go test ./store/ -v

Expected: all PASS.

  • Step 5: Commit
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:

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:

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:

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:

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:

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:

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:

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
go build ./...

Expected: compiles without errors.

  • Step 7: Commit
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

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

docker build -t catacombs .

Expected: image builds successfully.

  • Step 2: Test Docker run
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
docker stop catacombs-test && docker rm catacombs-test
  • Step 4: Commit
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
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

git add -A
git commit -m "feat: polish — terminal size check, chat system, final testing"