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>
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.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
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: pass1.15as scaling param toNewMonster -
combat/combat_test.go: pass0.10as coopBonus toResolveAttacks -
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:
- Add
currentScreen Screenandctx *Contextfields toModel - Initialize in
Init()or constructor:m.currentScreen = NewTitleScreen(),m.ctx = &Context{...} - In
Update(): handletea.WindowSizeMsgto update ctx, then delegatem.currentScreen.Update(msg, m.ctx) - If returned Screen differs from current, swap it
- 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:
- Define
Modelstruct withcurrentScreen Screen,ctx *Context Init(): create initial screen and contextUpdate(): handletea.WindowSizeMsg, delegate tom.currentScreen.Update(msg, m.ctx), swap screen if neededView():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 tocurrentScreen.Update() -
View()only delegates tocurrentScreen.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"