Files
Catacombs/docs/superpowers/plans/2026-03-25-phase1-foundation.md
tolelom 089d5c76ed docs: add Phase 1 foundation implementation plan
13 tasks covering config package, server wiring, constant replacement,
structured logging, UI Screen interface extraction, and emote system.
All reviewed and corrected for module paths, signatures, and coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:51:31 +09:00

29 KiB

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

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

func Start(host string, port int, lobby *game.Lobby, db *store.DB) error

to:

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.cfgGameSession.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
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:

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:

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:

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:

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:

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

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:

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:

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

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

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

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

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

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

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

git add -A
git commit -m "chore: phase 1 complete — foundation verified"