diff --git a/docs/superpowers/plans/2026-03-25-phase1-foundation.md b/docs/superpowers/plans/2026-03-25-phase1-foundation.md new file mode 100644 index 0000000..de66244 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-phase1-foundation.md @@ -0,0 +1,1023 @@ +# Phase 1: Foundation 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:** Establish the foundational infrastructure (UI architecture, config system, structured logging, emote system) that all subsequent phases build upon. + +**Architecture:** Extract each screen's Update logic from the central `ui/model.go` into independent screen models with a shared Context struct. Add a `config/` package for YAML-based configuration, `log/slog` structured logging in server/game packages, and a chat emote system on top of the existing `SendChat()`. + +**Tech Stack:** Go 1.25.1, Bubble Tea, BoltDB, `log/slog`, `gopkg.in/yaml.v3` + +**Module path:** `github.com/tolelom/catacombs` (all imports must use this prefix) + +**Note on spec deviation:** The spec defines `TurnTimeout` as `time.Duration`, but this plan uses `TurnTimeoutSec int` because `time.Duration` does not unmarshal cleanly from YAML integers. + +--- + +## File Structure + +### New Files +| File | Responsibility | +|------|---------------| +| `config/config.go` | YAML config parsing, default values, `Config` struct | +| `config/config_test.go` | Config loading and default tests | +| `config.yaml` | Default configuration file | +| `game/emote.go` | Emote definitions, parsing `/command` → emote text | +| `game/emote_test.go` | Emote parsing tests | +| `ui/context.go` | Shared `Context` struct used by all screen models | +| `ui/screen.go` | `Screen` interface definition (`Update`, `View`) | + +### Modified Files +| File | Changes | +|------|---------| +| `ui/model.go` | Extract Update/View dispatch to use Screen interface; remove per-screen update logic | +| `ui/game_view.go` | Add `GameScreen` struct implementing `Screen`; move `updateGame()` here | +| `ui/lobby_view.go` | Add `LobbyScreen` struct implementing `Screen`; move `updateLobby()` here | +| `ui/class_view.go` | Add `ClassSelectScreen` struct implementing `Screen`; move `updateClassSelect()` here | +| `ui/shop_view.go` | Add `ShopScreen` struct implementing `Screen`; move `updateShop()` here | +| `ui/result_view.go` | Add `ResultScreen` struct implementing `Screen`; move `updateResult()` here | +| `ui/help_view.go` | Add `HelpScreen` struct implementing `Screen`; move `updateHelp()` here | +| `ui/stats_view.go` | Add `StatsScreen` struct implementing `Screen`; move `updateStats()` here | +| `ui/achievements_view.go` | Add `AchievementsScreen` struct implementing `Screen`; move `updateAchievements()` here | +| `ui/leaderboard_view.go` | Add `LeaderboardScreen` struct implementing `Screen`; move `updateLeaderboard()` here | +| `ui/nickname_view.go` | Add `NicknameScreen` struct implementing `Screen`; move `updateNickname()` here | +| `ui/title.go` | Add `TitleScreen` struct implementing `Screen`; move `updateTitle()` here | +| `ui/model_test.go` | Update tests to work with new Screen interface | +| `game/session.go` | Store `*config.Config`; update `SendChat` to handle emote parsing | +| `game/turn.go` | Replace hardcoded `TurnTimeout` with config value | +| `game/event.go` | Replace hardcoded constants (skill uses, solo reduction, inventory limit) with config | +| `game/lobby.go` | Store `*config.Config`; replace hardcoded max players | +| `entity/monster.go` | Accept scaling param instead of hardcoded 1.15 | +| `combat/combat.go` | Accept flee chance and coop bonus params from config | +| `main.go` | Load config, initialize slog, pass config to server/lobby | +| `server/ssh.go` | Change `Start(host, port, lobby, db)` → `Start(addr string, lobby *game.Lobby, db *store.DB)`; add slog + panic recovery | +| `web/server.go` | Replace `log.Printf` with `slog.Info`/`slog.Error` (signature unchanged) | +| `go.mod` | Add `gopkg.in/yaml.v3` dependency | + +--- + +## Task 1: Configuration Package + +**Files:** +- Create: `config/config.go` +- Create: `config/config_test.go` +- Create: `config.yaml` +- Modify: `go.mod` + +- [ ] **Step 1: Write failing test for config loading** + +```go +// config/config_test.go +package config + +import ( + "os" + "testing" +) + +func TestLoadDefaults(t *testing.T) { + cfg, err := Load("") + if err != nil { + t.Fatal(err) + } + if cfg.Server.SSHPort != 2222 { + t.Errorf("expected SSH port 2222, got %d", cfg.Server.SSHPort) + } + if cfg.Server.HTTPPort != 8080 { + t.Errorf("expected HTTP port 8080, got %d", cfg.Server.HTTPPort) + } + if cfg.Game.TurnTimeoutSec != 5 { + t.Errorf("expected turn timeout 5, got %d", cfg.Game.TurnTimeoutSec) + } + if cfg.Game.MaxPlayers != 4 { + t.Errorf("expected max players 4, got %d", cfg.Game.MaxPlayers) + } + if cfg.Game.MaxFloors != 20 { + t.Errorf("expected max floors 20, got %d", cfg.Game.MaxFloors) + } + if cfg.Game.CoopBonus != 0.10 { + t.Errorf("expected coop bonus 0.10, got %f", cfg.Game.CoopBonus) + } + if cfg.Game.InventoryLimit != 10 { + t.Errorf("expected inventory limit 10, got %d", cfg.Game.InventoryLimit) + } + if cfg.Combat.FleeChance != 0.50 { + t.Errorf("expected flee chance 0.50, got %f", cfg.Combat.FleeChance) + } + if cfg.Combat.MonsterScaling != 1.15 { + t.Errorf("expected monster scaling 1.15, got %f", cfg.Combat.MonsterScaling) + } +} + +func TestLoadFromFile(t *testing.T) { + content := []byte(` +server: + ssh_port: 3333 + http_port: 9090 +game: + turn_timeout_sec: 10 + max_players: 2 +`) + f, err := os.CreateTemp("", "config-*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(f.Name()) + f.Write(content) + f.Close() + + cfg, err := Load(f.Name()) + if err != nil { + t.Fatal(err) + } + if cfg.Server.SSHPort != 3333 { + t.Errorf("expected SSH port 3333, got %d", cfg.Server.SSHPort) + } + if cfg.Server.HTTPPort != 9090 { + t.Errorf("expected HTTP port 9090, got %d", cfg.Server.HTTPPort) + } + if cfg.Game.TurnTimeoutSec != 10 { + t.Errorf("expected turn timeout 10, got %d", cfg.Game.TurnTimeoutSec) + } + if cfg.Game.MaxPlayers != 2 { + t.Errorf("expected max players 2, got %d", cfg.Game.MaxPlayers) + } + // Unset fields should have defaults + if cfg.Game.MaxFloors != 20 { + t.Errorf("expected default max floors 20, got %d", cfg.Game.MaxFloors) + } + // Unset sub-structs should have defaults + if cfg.Combat.FleeChance != 0.50 { + t.Errorf("expected default flee chance 0.50, got %f", cfg.Combat.FleeChance) + } + if cfg.Dungeon.MapWidth != 60 { + t.Errorf("expected default map width 60, got %d", cfg.Dungeon.MapWidth) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./config/ -v` +Expected: FAIL — package `config` not found + +- [ ] **Step 3: Implement config package** + +```go +// config/config.go +package config + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Server ServerConfig `yaml:"server"` + Game GameConfig `yaml:"game"` + Combat CombatConfig `yaml:"combat"` + Dungeon DungeonConfig `yaml:"dungeon"` + Backup BackupConfig `yaml:"backup"` +} + +type ServerConfig struct { + SSHPort int `yaml:"ssh_port"` + HTTPPort int `yaml:"http_port"` +} + +type GameConfig struct { + TurnTimeoutSec int `yaml:"turn_timeout_sec"` + MaxPlayers int `yaml:"max_players"` + MaxFloors int `yaml:"max_floors"` + CoopBonus float64 `yaml:"coop_bonus"` + InventoryLimit int `yaml:"inventory_limit"` + SkillUses int `yaml:"skill_uses"` +} + +type CombatConfig struct { + FleeChance float64 `yaml:"flee_chance"` + MonsterScaling float64 `yaml:"monster_scaling"` + SoloHPReduction float64 `yaml:"solo_hp_reduction"` +} + +type DungeonConfig struct { + MapWidth int `yaml:"map_width"` + MapHeight int `yaml:"map_height"` + MinRooms int `yaml:"min_rooms"` + MaxRooms int `yaml:"max_rooms"` +} + +type BackupConfig struct { + IntervalMin int `yaml:"interval_min"` + Dir string `yaml:"dir"` +} + +func defaults() Config { + return Config{ + Server: ServerConfig{ + SSHPort: 2222, + HTTPPort: 8080, + }, + Game: GameConfig{ + TurnTimeoutSec: 5, + MaxPlayers: 4, + MaxFloors: 20, + CoopBonus: 0.10, + InventoryLimit: 10, + SkillUses: 3, + }, + Combat: CombatConfig{ + FleeChance: 0.50, + MonsterScaling: 1.15, + SoloHPReduction: 0.50, + }, + Dungeon: DungeonConfig{ + MapWidth: 60, + MapHeight: 20, + MinRooms: 5, + MaxRooms: 8, + }, + Backup: BackupConfig{ + IntervalMin: 60, + Dir: "./data/backup", + }, + } +} + +func Load(path string) (*Config, error) { + cfg := defaults() + if path == "" { + return &cfg, nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} +``` + +- [ ] **Step 4: Add yaml dependency** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go get gopkg.in/yaml.v3` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./config/ -v` +Expected: PASS (both TestLoadDefaults and TestLoadFromFile) + +- [ ] **Step 6: Create default config.yaml** + +```yaml +# config.yaml — Catacombs server configuration +server: + ssh_port: 2222 + http_port: 8080 + +game: + turn_timeout_sec: 5 + max_players: 4 + max_floors: 20 + coop_bonus: 0.10 + inventory_limit: 10 + skill_uses: 3 + +combat: + flee_chance: 0.50 + monster_scaling: 1.15 + solo_hp_reduction: 0.50 + +dungeon: + map_width: 60 + map_height: 20 + min_rooms: 5 + max_rooms: 8 + +backup: + interval_min: 60 + dir: "./data/backup" +``` + +- [ ] **Step 7: Commit** + +```bash +git add config/ config.yaml go.mod go.sum +git commit -m "feat: add config package with YAML loading and defaults" +``` + +--- + +## Task 2: Wire Config Into main.go and Server + +**Files:** +- Modify: `main.go` +- Modify: `server/ssh.go` +- Modify: `game/lobby.go` + +**Note:** `web/server.go`'s `Start(addr string, sshPort int)` signature does NOT need changing — the plan passes `cfg.Server.SSHPort` as the sshPort argument. + +- [ ] **Step 1: Update main.go to load config and pass to server** + +```go +// main.go +package main + +import ( + "fmt" + "log" + "os" + + "github.com/tolelom/catacombs/config" + "github.com/tolelom/catacombs/game" + "github.com/tolelom/catacombs/server" + "github.com/tolelom/catacombs/store" + "github.com/tolelom/catacombs/web" +) + +func main() { + os.MkdirAll("data", 0755) + + cfg, err := config.Load("config.yaml") + if err != nil { + if os.IsNotExist(err) { + cfg, _ = config.Load("") + log.Println("No config.yaml found, using defaults") + } else { + log.Fatalf("Failed to load config: %v", err) + } + } + + db, err := store.Open("data/catacombs.db") + if err != nil { + log.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + lobby := game.NewLobby(cfg) + + go func() { + addr := fmt.Sprintf(":%d", cfg.Server.HTTPPort) + if err := web.Start(addr, cfg.Server.SSHPort); err != nil { + log.Printf("Web server error: %v", err) + } + }() + + log.Printf("Catacombs server starting — SSH :%d, Web :%d", cfg.Server.SSHPort, cfg.Server.HTTPPort) + sshAddr := fmt.Sprintf("0.0.0.0:%d", cfg.Server.SSHPort) + if err := server.Start(sshAddr, lobby, db); err != nil { + log.Fatal(err) + } +} +``` + +- [ ] **Step 2: Update server.Start() signature** + +Read `server/ssh.go` first. Change signature from: +```go +func Start(host string, port int, lobby *game.Lobby, db *store.DB) error +``` +to: +```go +func Start(addr string, lobby *game.Lobby, db *store.DB) error +``` +Replace internal `fmt.Sprintf("%s:%d", host, port)` with direct use of `addr`. + +- [ ] **Step 3: Update game.NewLobby() to accept config** + +Read `game/lobby.go` first. Add `cfg *config.Config` field to `Lobby` struct. Change `NewLobby()` to `NewLobby(cfg *config.Config)`. The config will propagate: `Lobby.cfg` → `GameSession.cfg` when sessions are created. + +Find where `NewGameSession` is called (likely in `ui/model.go` during class select). The config is accessible via `ctx.Lobby.Cfg()` or a public field. + +- [ ] **Step 4: Run build and all tests** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go build . && go test ./...` +Expected: Build succeeds, all tests pass. Fix any tests that call `NewLobby()` without config by passing `defaults()` or a test config. + +- [ ] **Step 5: Commit** + +```bash +git add main.go server/ssh.go game/lobby.go game/lobby_test.go +git commit -m "feat: wire config into main, server, and lobby" +``` + +--- + +## Task 3: Replace All Hardcoded Constants with Config + +**Files:** +- Modify: `game/session.go` (add cfg field) +- Modify: `game/turn.go` (TurnTimeout → cfg, MaxFloors → cfg) +- Modify: `game/event.go` (SkillUses, solo reduction, inventory limit) +- Modify: `game/lobby.go` (max players in JoinRoom) +- Modify: `combat/combat.go` (flee chance, coop bonus) +- Modify: `entity/monster.go` (monster scaling) + +- [ ] **Step 1: Add config to GameSession** + +In `game/session.go`, add `cfg *config.Config` to the `GameSession` struct. Update the constructor to accept and store it. The lobby should pass its config when creating sessions. + +- [ ] **Step 2: Replace TurnTimeout in turn.go** + +Remove `const TurnTimeout = 5 * time.Second` from `game/turn.go:13`. In `RunTurn()`, compute timeout from config: +```go +timeout := time.Duration(s.cfg.Game.TurnTimeoutSec) * time.Second +``` + +- [ ] **Step 3: Replace MaxFloors in turn.go** + +In `game/turn.go` `advanceFloor()` (~line 290), replace `if s.state.FloorNum >= 20` with `if s.state.FloorNum >= s.cfg.Game.MaxFloors`. + +- [ ] **Step 4: Replace skill uses in event.go** + +In `game/event.go`, `spawnMonsters()` and `spawnBoss()` set `p.SkillUses = 3`. Replace with `p.SkillUses = s.cfg.Game.SkillUses`. + +- [ ] **Step 5: Replace solo reduction in event.go** + +In `game/event.go`, solo mode does `m.HP /= 2` and `m.DEF /= 2`. Replace with: +```go +reduction := s.cfg.Combat.SoloHPReduction +m.HP = int(float64(m.HP) * reduction) +m.DEF = int(float64(m.DEF) * reduction) +``` + +- [ ] **Step 6: Replace inventory limit in session.go and event.go** + +In `game/session.go` `BuyItem()` (~line 343): replace `len(p.Inventory) >= 10` with `len(p.Inventory) >= s.cfg.Game.InventoryLimit`. + +In `game/event.go` `grantTreasure()` (~line 143): replace `len(p.Inventory) >= 10` with `len(p.Inventory) >= s.cfg.Game.InventoryLimit`. + +- [ ] **Step 7: Replace max players in lobby.go** + +In `game/lobby.go` `JoinRoom()` (~line 143): replace `len(room.Players) >= 4` with `len(room.Players) >= l.cfg.Game.MaxPlayers`. + +- [ ] **Step 8: Replace flee chance in combat.go** + +Change `combat.AttemptFlee()` signature to accept chance: +```go +func AttemptFlee(fleeChance float64) bool { + return rand.Float64() < fleeChance +} +``` +Update all callers to pass `s.cfg.Combat.FleeChance`. + +- [ ] **Step 9: Replace coop bonus in combat.go** + +In `combat.ResolveAttacks()`, the coop multiplier is `* 1.10` (~line 66). Change the function to accept `coopBonus float64` parameter: +```go +func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster, coopBonus float64) []AttackResult +``` +Replace `* 1.10` with `* (1.0 + coopBonus)`. Update all callers to pass `s.cfg.Game.CoopBonus`. + +- [ ] **Step 10: Replace monster scaling in entity/monster.go** + +In `entity/monster.go` `NewMonster()` (~line 61), change `math.Pow(1.15, ...)` to accept a scaling parameter. Change signature: +```go +func NewMonster(mt MonsterType, floor int, scaling float64) *Monster +``` +Update all callers in `game/event.go` to pass `s.cfg.Combat.MonsterScaling`. + +- [ ] **Step 11: Run all tests and fix breakages** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go build . && go test ./...` + +Expected: Some tests will break because function signatures changed. Fix: +- `entity/monster_test.go`: pass `1.15` as scaling param to `NewMonster` +- `combat/combat_test.go`: pass `0.10` as coopBonus to `ResolveAttacks` +- `game/session_test.go`: create sessions with test config +- `game/lobby_test.go`: create lobby with test config +- `ui/model_test.go`: create lobby/session with test config + +- [ ] **Step 12: Commit** + +```bash +git add game/ entity/ combat/ ui/model_test.go +git commit -m "feat: replace all hardcoded constants with config values" +``` + +--- + +## Task 4: Structured Logging + +**Files:** +- Modify: `main.go` (initialize slog) +- Modify: `server/ssh.go` (slog + panic recovery) +- Modify: `web/server.go` (slog) +- Modify: `game/session.go` (game event logging) +- Modify: `game/lobby.go` (room event logging) + +- [ ] **Step 1: Initialize slog in main.go** + +Add after config loading in `main.go`: + +```go +import "log/slog" + +logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, +})) +slog.SetDefault(logger) +``` + +Replace `log.Printf`/`log.Println`/`log.Fatalf` in `main.go` with `slog.Info`/`slog.Error`. Keep `log.Fatalf` for fatal errors (slog doesn't have Fatal). + +- [ ] **Step 2: Add panic recovery to SSH session handler** + +Read `server/ssh.go` to find the session handler function. Add at the top of the handler: + +```go +defer func() { + if r := recover(); r != nil { + slog.Error("session panic recovered", "error", r, "fingerprint", fingerprint) + } +}() +``` + +Replace all `log.Printf` in `server/ssh.go` with `slog.Info`/`slog.Error`. + +- [ ] **Step 3: Add logging to game/lobby.go** + +Add slog calls at key points: +- Room created: `slog.Info("room created", "code", code)` +- Player joined: `slog.Info("player joined", "room", code, "player", name)` +- Game started: `slog.Info("game started", "room", code, "players", playerCount)` + +- [ ] **Step 4: Add logging to game/session.go** + +Add slog calls: +- Game over: `slog.Info("game over", "floor", s.state.FloorNum, "victory", s.state.Victory)` +- Player removed for inactivity: `slog.Warn("player inactive removed", "fingerprint", fp)` + +- [ ] **Step 5: Replace log calls in web/server.go** + +Replace `log.Printf` with `slog.Info`/`slog.Error` for WebSocket connection events. + +- [ ] **Step 6: Run all tests** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go build . && go test ./...` +Expected: All pass + +- [ ] **Step 7: Commit** + +```bash +git add main.go server/ web/ game/ +git commit -m "feat: add structured logging with log/slog and panic recovery" +``` + +--- + +## Task 5: UI Screen Interface and Context + +**Files:** +- Create: `ui/context.go` +- Create: `ui/screen.go` + +- [ ] **Step 1: Define Screen interface** + +```go +// ui/screen.go +package ui + +import tea "github.com/charmbracelet/bubbletea" + +// Screen represents an independent screen with its own Update and View logic. +// Update returns the next Screen (can return itself or a different screen for transitions) +// plus a tea.Cmd for async operations. +type Screen interface { + Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) + View(ctx *Context) string +} +``` + +- [ ] **Step 2: Define Context struct** + +Read `ui/model.go` to identify all shared state. Create: + +```go +// ui/context.go +package ui + +import ( + "github.com/tolelom/catacombs/game" + "github.com/tolelom/catacombs/store" +) + +// Context holds shared state accessible to all screens. +type Context struct { + Width int + Height int + Fingerprint string + PlayerName string + + Lobby *game.Lobby + Store *store.DB + Session *game.GameSession + RoomCode string +} +``` + +Note: Screen transitions are handled by returning a new `Screen` from `Update()`, not via context flags. This is cleaner and more idiomatic Bubble Tea. + +- [ ] **Step 3: Run build to verify compilation** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go build ./ui/` +Expected: Build succeeds + +- [ ] **Step 4: Commit** + +```bash +git add ui/context.go ui/screen.go +git commit -m "feat: add Screen interface and Context for UI architecture" +``` + +--- + +## Task 6: Extract Title Screen + +**Files:** +- Modify: `ui/title.go` +- Modify: `ui/model.go` + +- [ ] **Step 1: Read current title update logic** + +Read `ui/model.go` — find `updateTitle()` method. Note all `m.xxx` fields it reads/writes. + +- [ ] **Step 2: Create TitleScreen in title.go** + +Add to `ui/title.go`: + +```go +type TitleScreen struct { + cursor int +} + +func NewTitleScreen() *TitleScreen { + return &TitleScreen{} +} + +func (s *TitleScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { + // Move logic from updateTitle() here + // For screen transitions: return NewXxxScreen(), cmd + // For staying on same screen: return s, cmd +} + +func (s *TitleScreen) View(ctx *Context) string { + // Move existing titleView() logic, replace m.xxx with ctx.xxx and s.cursor +} +``` + +- [ ] **Step 3: Add currentScreen field to Model and delegate** + +In `model.go`: +1. Add `currentScreen Screen` and `ctx *Context` fields to `Model` +2. Initialize in `Init()` or constructor: `m.currentScreen = NewTitleScreen()`, `m.ctx = &Context{...}` +3. In `Update()`: handle `tea.WindowSizeMsg` to update ctx, then delegate `m.currentScreen.Update(msg, m.ctx)` +4. If returned Screen differs from current, swap it +5. In `View()`: `return m.currentScreen.View(m.ctx)` + +Keep the old switch-based code for screens not yet extracted (will be removed incrementally). + +- [ ] **Step 4: Run tests** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./ui/ -v` +Expected: All existing tests pass + +- [ ] **Step 5: Commit** + +```bash +git add ui/title.go ui/model.go +git commit -m "refactor: extract TitleScreen from model.go" +``` + +--- + +## Task 7: Extract Nickname Screen + +**Files:** +- Modify: `ui/nickname_view.go` +- Modify: `ui/model.go` + +- [ ] **Step 1: Read updateNickname() in model.go** + +Read `ui/model.go` — find `updateNickname()`. Note fields: `m.nicknameInput`, `m.playerName`, `m.fingerprint`, `m.store`. + +- [ ] **Step 2: Create NicknameScreen** + +```go +type NicknameScreen struct { + input string +} +``` + +Move `updateNickname()` logic into `Update()`. Move nickname view rendering into `View()`. When nickname is confirmed, set `ctx.PlayerName` and return `NewLobbyScreen()` (or the next appropriate screen). + +- [ ] **Step 3: Delegate in model.go, remove old code** + +- [ ] **Step 4: Run tests** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./ui/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ui/nickname_view.go ui/model.go +git commit -m "refactor: extract NicknameScreen from model.go" +``` + +--- + +## Task 8: Extract Lobby Screen + +**Files:** +- Modify: `ui/lobby_view.go` +- Modify: `ui/model.go` + +- [ ] **Step 1: Read updateLobby() and lobbyState** + +Read `ui/model.go` for `updateLobby()` and `ui/lobby_view.go` for the existing `lobbyState` struct (10 fields). + +- [ ] **Step 2: Create LobbyScreen** + +Embed or replace `lobbyState` fields into `LobbyScreen` struct. Move `updateLobby()` logic into `Update()`. The view rendering functions already in `lobby_view.go` should reference `LobbyScreen` fields. + +- [ ] **Step 3: Delegate in model.go, remove old code** + +- [ ] **Step 4: Run tests** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./ui/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ui/lobby_view.go ui/model.go +git commit -m "refactor: extract LobbyScreen from model.go" +``` + +--- + +## Task 9: Extract Class Select Screen + +**Files:** +- Modify: `ui/class_view.go` +- Modify: `ui/model.go` + +- [ ] **Step 1: Read and extract** + +Read `ui/model.go` for `updateClassSelect()`. Create `ClassSelectScreen` with `cursor int` field. Move logic. This is where `NewGameSession` is called — use `ctx.Lobby.Cfg` to get config for session creation. + +- [ ] **Step 2: Delegate and test** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./ui/ -v` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add ui/class_view.go ui/model.go +git commit -m "refactor: extract ClassSelectScreen from model.go" +``` + +--- + +## Task 10: Extract Game Screen (Largest) + +**Files:** +- Modify: `ui/game_view.go` +- Modify: `ui/model.go` + +- [ ] **Step 1: Read updateGame() thoroughly** + +Read `ui/model.go` for `updateGame()` — the most complex screen. Note: exploration navigation (Up/Down/Enter), combat actions (1-5 keys, Tab), chat mode (`/` toggle), pollState, getNeighbors, achievement checking (lines with `m.store.UnlockAchievement`). + +- [ ] **Step 2: Create GameScreen struct** + +```go +type GameScreen struct { + gameState game.GameState + targetCursor int + moveCursor int + chatting bool + chatInput string +} +``` + +Move `updateGame()` logic into `Update()`. Move `pollState()` and `getNeighbors()` as methods on `GameScreen`. Move achievement checking logic into `GameScreen.Update()`. + +- [ ] **Step 3: Delegate in model.go, remove old code** + +- [ ] **Step 4: Run tests** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./ui/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ui/game_view.go ui/model.go +git commit -m "refactor: extract GameScreen from model.go" +``` + +--- + +## Task 11: Extract Remaining Screens (Shop, Result, Help, Stats, Achievements, Leaderboard) + +**Files:** +- Modify: `ui/shop_view.go`, `ui/result_view.go`, `ui/help_view.go`, `ui/stats_view.go`, `ui/achievements_view.go`, `ui/leaderboard_view.go` +- Modify: `ui/model.go` + +- [ ] **Step 1: Extract ShopScreen** + +Read `ui/model.go` for `updateShop()`. Create `ShopScreen` with `shopMsg string` field. Move logic. + +- [ ] **Step 2: Extract ResultScreen** + +Read `ui/model.go` for `updateResult()`. Create `ResultScreen` with `rankingSaved bool` field. Move logic. + +- [ ] **Step 3: Extract HelpScreen, StatsScreen, AchievementsScreen, LeaderboardScreen** + +These are simple screens that handle Esc to go back. Create minimal structs for each. + +- [ ] **Step 4: Clean up model.go** + +After all screens extracted, `model.go` should only: +1. Define `Model` struct with `currentScreen Screen`, `ctx *Context` +2. `Init()`: create initial screen and context +3. `Update()`: handle `tea.WindowSizeMsg`, delegate to `m.currentScreen.Update(msg, m.ctx)`, swap screen if needed +4. `View()`: `return m.currentScreen.View(m.ctx)` + +Remove all `updateXxx()` methods, `screen` enum (if no longer needed), and per-screen state fields from `Model`. + +- [ ] **Step 5: Run all tests** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./... -v` +Expected: ALL PASS + +- [ ] **Step 6: Commit** + +```bash +git add ui/ +git commit -m "refactor: extract all remaining screens, model.go is now a thin router" +``` + +--- + +## Task 12: Emote System + +**Files:** +- Create: `game/emote.go` +- Create: `game/emote_test.go` +- Modify: `game/session.go` (SendChat emote integration) + +- [ ] **Step 1: Write failing test for emote parsing** + +```go +// game/emote_test.go +package game + +import "testing" + +func TestParseEmote(t *testing.T) { + tests := []struct { + input string + isEmote bool + expected string + }{ + {"/hi", true, "👋 waves hello!"}, + {"/gg", true, "🎉 says GG!"}, + {"/go", true, "⚔️ says Let's go!"}, + {"/wait", true, "✋ says Wait!"}, + {"/help", true, "🆘 calls for help!"}, + {"/unknown", false, ""}, + {"hello", false, ""}, + {"", false, ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result, ok := ParseEmote(tt.input) + if ok != tt.isEmote { + t.Errorf("ParseEmote(%q) isEmote = %v, want %v", tt.input, ok, tt.isEmote) + } + if ok && result != tt.expected { + t.Errorf("ParseEmote(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./game/ -run TestParseEmote -v` +Expected: FAIL — `ParseEmote` undefined + +- [ ] **Step 3: Implement emote system** + +```go +// game/emote.go +package game + +var emotes = map[string]string{ + "/hi": "👋 waves hello!", + "/gg": "🎉 says GG!", + "/go": "⚔️ says Let's go!", + "/wait": "✋ says Wait!", + "/help": "🆘 calls for help!", +} + +func ParseEmote(input string) (string, bool) { + if input == "" { + return "", false + } + text, ok := emotes[input] + return text, ok +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./game/ -run TestParseEmote -v` +Expected: PASS + +- [ ] **Step 5: Write integration test for SendChat with emotes** + +Add to `game/emote_test.go` or the existing `game/session_test.go`: + +```go +func TestSendChatEmote(t *testing.T) { + // Create a test session (use test config) + // Call SendChat("Alice", "/hi") + // Verify the log contains "✨ Alice 👋 waves hello!" + // Call SendChat("Bob", "normal message") + // Verify the log contains "[Bob] normal message" +} +``` + +This confirms the existing `TestSendChat` still passes (regression) and emotes work end-to-end. + +- [ ] **Step 6: Integrate emote into SendChat** + +Modify `game/session.go` `SendChat()`: + +```go +func (s *GameSession) SendChat(playerName, message string) { + s.mu.Lock() + defer s.mu.Unlock() + if emoteText, ok := ParseEmote(message); ok { + s.addLog(fmt.Sprintf("✨ %s %s", playerName, emoteText)) + } else { + s.addLog(fmt.Sprintf("[%s] %s", playerName, message)) + } +} +``` + +- [ ] **Step 7: Run all tests (including existing TestSendChat as regression)** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./game/ -v` +Expected: ALL PASS (TestParseEmote, TestSendChat, TestSendChatEmote) + +- [ ] **Step 8: Commit** + +```bash +git add game/emote.go game/emote_test.go game/session.go +git commit -m "feat: add chat emote system (/hi, /gg, /go, /wait, /help)" +``` + +--- + +## Task 13: Final Verification + +- [ ] **Step 1: Run full test suite** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./... -v` +Expected: ALL PASS + +- [ ] **Step 2: Run go vet** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go vet ./...` +Expected: No issues + +- [ ] **Step 3: Build and verify** + +Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go build -o catacombs .` +Expected: Build succeeds + +- [ ] **Step 4: Verify model.go is now a thin router** + +Read `ui/model.go` and confirm: +- `Update()` only handles window resize and delegates to `currentScreen.Update()` +- `View()` only delegates to `currentScreen.View()` +- All per-screen update functions (`updateTitle`, `updateLobby`, etc.) are removed +- Per-screen state fields are removed from `Model` + +- [ ] **Step 5: Final commit if any cleanup needed** + +```bash +git add -A +git commit -m "chore: phase 1 complete — foundation verified" +```