Files
Catacombs/docs/superpowers/plans/2026-03-25-phase3-retention.md
tolelom 5ff82120ff docs: add Phase 3 retention systems implementation plan
9 tasks: daily challenge seed/store, unlock system, player titles,
codex system+UI, hard mode/mutations, integration, verification.

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

21 KiB

Phase 3: Retention Systems 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: Add retention mechanics (daily challenges, meta progression, difficulty modes) that give players reasons to keep coming back.

Architecture: Extend the BoltDB store with 4 new buckets (daily_runs, unlocks, titles, codex). Daily challenges use the Phase 2 seed-based generator with a date-derived seed. Unlock/title/codex systems follow the existing achievements pattern (key-value pairs per player). Hard mode and weekly mutations modify game config at session creation. New UI screens (codex_view) follow the Phase 1 Screen interface pattern.

Tech Stack: Go 1.25.1, BoltDB, math/rand, crypto/sha256 for date→seed hashing

Module path: github.com/tolelom/catacombs


File Structure

New Files

File Responsibility
store/daily.go DailyRecord struct; SaveDaily, GetDailyLeaderboard, GetStreak methods
store/daily_test.go Daily record persistence tests
store/unlocks.go Unlock definitions; CheckUnlock, GetUnlocks methods
store/unlocks_test.go Unlock condition tests
store/titles.go Title definitions; EarnTitle, SetActiveTitle, GetTitles methods
store/titles_test.go Title persistence tests
store/codex.go Codex struct; RecordMonster/Item/Event, GetCodex methods
store/codex_test.go Codex tracking tests
game/daily.go DailySeed generation; CreateDailySession helper
game/daily_test.go Seed determinism tests
game/mutation.go Mutation definitions; GetWeeklyMutation, ApplyMutation
game/mutation_test.go Mutation application tests
ui/codex_view.go CodexScreen implementing Screen interface

Modified Files

File Changes
store/db.go Create 4 new buckets in Open(); use cfg.Game.MaxFloors in GetStats victory check
config/config.go Add DifficultyConfig (HardMode multipliers)
config.yaml Add difficulty section
game/session.go Add DailyMode/HardMode/Mutation fields to GameSession; seed-based generation for daily; codex recording hooks
game/event.go Record codex entries on monster spawn, treasure, events
game/turn.go Record codex on monster kills; apply hard mode multipliers
ui/lobby_view.go Add daily challenge button; show hard mode option if unlocked
ui/game_view.go Show daily/hard mode indicators in HUD; save daily record on game over
ui/title.go Show active title next to player name on title screen
ui/leaderboard_view.go Add daily leaderboard tab

Task 1: Daily Challenge Seed System

Files:

  • Create: game/daily.go

  • Create: game/daily_test.go

  • Step 1: Write determinism test

// game/daily_test.go
package game

import (
	"math/rand"
	"testing"

	"github.com/tolelom/catacombs/dungeon"
)

func TestDailySeed(t *testing.T) {
	seed1 := DailySeed("2026-03-25")
	seed2 := DailySeed("2026-03-25")
	if seed1 != seed2 {
		t.Errorf("same date should produce same seed: %d vs %d", seed1, seed2)
	}
	seed3 := DailySeed("2026-03-26")
	if seed1 == seed3 {
		t.Error("different dates should produce different seeds")
	}
}

func TestDailyFloorDeterminism(t *testing.T) {
	seed := DailySeed("2026-03-25")
	rng1 := rand.New(rand.NewSource(seed))
	rng2 := rand.New(rand.NewSource(seed))
	f1 := dungeon.GenerateFloor(1, rng1)
	f2 := dungeon.GenerateFloor(1, rng2)
	if len(f1.Rooms) != len(f2.Rooms) {
		t.Fatal("daily floors should be identical")
	}
	for i := range f1.Rooms {
		if f1.Rooms[i].Type != f2.Rooms[i].Type {
			t.Errorf("room %d type differs", i)
		}
	}
}
  • Step 2: Implement DailySeed
// game/daily.go
package game

import (
	"crypto/sha256"
	"encoding/binary"
)

// DailySeed returns a deterministic seed for the given date string (YYYY-MM-DD).
func DailySeed(date string) int64 {
	h := sha256.Sum256([]byte("catacombs:" + date))
	return int64(binary.BigEndian.Uint64(h[:8]))
}
  • Step 3: Run tests

Run: cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./game/ -run TestDaily -v Expected: PASS

  • Step 4: Commit
git add game/daily.go game/daily_test.go
git commit -m "feat: add daily challenge seed generation"

Task 2: Daily Record Store

Files:

  • Create: store/daily.go

  • Create: store/daily_test.go

  • Modify: store/db.go (add bucket creation)

  • Step 1: Write tests

// store/daily_test.go
package store

import (
	"os"
	"testing"
)

func TestSaveAndGetDaily(t *testing.T) {
	f, _ := os.CreateTemp("", "daily-*.db")
	f.Close()
	defer os.Remove(f.Name())

	db, err := Open(f.Name())
	if err != nil {
		t.Fatal(err)
	}
	defer db.Close()

	err = db.SaveDaily(DailyRecord{
		Date: "2026-03-25", Player: "Alice", PlayerName: "Alice",
		FloorReached: 10, GoldEarned: 200,
	})
	if err != nil {
		t.Fatal(err)
	}

	records, err := db.GetDailyLeaderboard("2026-03-25", 10)
	if err != nil {
		t.Fatal(err)
	}
	if len(records) != 1 {
		t.Fatalf("expected 1 record, got %d", len(records))
	}
	if records[0].FloorReached != 10 {
		t.Errorf("expected floor 10, got %d", records[0].FloorReached)
	}
}

func TestDailyStreak(t *testing.T) {
	f, _ := os.CreateTemp("", "streak-*.db")
	f.Close()
	defer os.Remove(f.Name())

	db, _ := Open(f.Name())
	defer db.Close()

	db.SaveDaily(DailyRecord{Date: "2026-03-23", Player: "fp1", PlayerName: "A", FloorReached: 5, GoldEarned: 50})
	db.SaveDaily(DailyRecord{Date: "2026-03-24", Player: "fp1", PlayerName: "A", FloorReached: 8, GoldEarned: 100})
	db.SaveDaily(DailyRecord{Date: "2026-03-25", Player: "fp1", PlayerName: "A", FloorReached: 12, GoldEarned: 150})

	streak := db.GetStreak("fp1", "2026-03-25")
	if streak != 3 {
		t.Errorf("expected streak 3, got %d", streak)
	}
}
  • Step 2: Implement daily store
// store/daily.go
package store

import (
	"encoding/json"
	"fmt"
	"sort"
	"time"

	bolt "go.etcd.io/bbolt"
)

var bucketDailyRuns = []byte("daily_runs")

type DailyRecord struct {
	Date         string `json:"date"`
	Player       string `json:"player"`       // fingerprint
	PlayerName   string `json:"player_name"`
	FloorReached int    `json:"floor_reached"`
	GoldEarned   int    `json:"gold_earned"`
}

func (d *DB) SaveDaily(record DailyRecord) error {
	return d.db.Update(func(tx *bolt.Tx) error {
		b := tx.Bucket(bucketDailyRuns)
		key := []byte(record.Date + ":" + record.Player)
		data, err := json.Marshal(record)
		if err != nil {
			return err
		}
		return b.Put(key, data)
	})
}

func (d *DB) GetDailyLeaderboard(date string, limit int) ([]DailyRecord, error) {
	var records []DailyRecord
	prefix := []byte(date + ":")
	err := d.db.View(func(tx *bolt.Tx) error {
		b := tx.Bucket(bucketDailyRuns)
		c := b.Cursor()
		for k, v := c.Seek(prefix); k != nil && len(k) >= len(prefix) && string(k[:len(prefix)]) == string(prefix); k, v = c.Next() {
			var r DailyRecord
			if json.Unmarshal(v, &r) == nil {
				records = append(records, r)
			}
		}
		return nil
	})
	if err != nil {
		return nil, err
	}
	sort.Slice(records, func(i, j int) bool {
		if records[i].FloorReached != records[j].FloorReached {
			return records[i].FloorReached > records[j].FloorReached
		}
		return records[i].GoldEarned > records[j].GoldEarned
	})
	if len(records) > limit {
		records = records[:limit]
	}
	return records, nil
}

func (d *DB) GetStreak(fingerprint, currentDate string) int {
	streak := 0
	date, err := time.Parse("2006-01-02", currentDate)
	if err != nil {
		return 0
	}
	for i := 0; i < 365; i++ {
		checkDate := date.AddDate(0, 0, -i).Format("2006-01-02")
		key := []byte(checkDate + ":" + fingerprint)
		found := false
		d.db.View(func(tx *bolt.Tx) error {
			if tx.Bucket(bucketDailyRuns).Get(key) != nil {
				found = true
			}
			return nil
		})
		if found {
			streak++
		} else {
			break
		}
	}
	return streak
}

In store/db.go Open(), add bucket creation:

if _, err := tx.CreateBucketIfNotExists(bucketDailyRuns); err != nil {
	return err
}
  • Step 3: Run tests

Run: cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./store/ -v Expected: ALL PASS

  • Step 4: Commit
git add store/daily.go store/daily_test.go store/db.go
git commit -m "feat: add daily challenge record storage and leaderboard"

Task 3: Unlock System

Files:

  • Create: store/unlocks.go

  • Create: store/unlocks_test.go

  • Modify: store/db.go (add bucket)

  • Step 1: Implement unlock system

Follow the achievements.go pattern. Define 3 unlocks:

  • "fifth_class": "Clear floor 10+" → unlocks 5th class (future)
  • "hard_mode": "Clear with 3+ players" → unlocks hard mode
  • "mutations": "Clear floor 20" → unlocks weekly mutations
// store/unlocks.go
package store

import bolt "go.etcd.io/bbolt"

var bucketUnlocks = []byte("unlocks")

type UnlockDef struct {
	ID          string
	Name        string
	Description string
}

var UnlockDefs = []UnlockDef{
	{ID: "fifth_class", Name: "New Recruit", Description: "Clear floor 10 to unlock a new class"},
	{ID: "hard_mode", Name: "Hardened", Description: "Complete a run with 3+ players to unlock Hard Mode"},
	{ID: "mutations", Name: "Chaos Unleashed", Description: "Conquer floor 20 to unlock Weekly Mutations"},
}

func (d *DB) UnlockContent(fingerprint, unlockID string) (bool, error) {
	key := []byte(fingerprint + ":" + unlockID)
	alreadyUnlocked := false
	err := d.db.Update(func(tx *bolt.Tx) error {
		b := tx.Bucket(bucketUnlocks)
		if b.Get(key) != nil {
			alreadyUnlocked = true
			return nil
		}
		return b.Put(key, []byte("1"))
	})
	return !alreadyUnlocked, err
}

func (d *DB) IsUnlocked(fingerprint, unlockID string) bool {
	key := []byte(fingerprint + ":" + unlockID)
	found := false
	d.db.View(func(tx *bolt.Tx) error {
		if tx.Bucket(bucketUnlocks).Get(key) != nil {
			found = true
		}
		return nil
	})
	return found
}

func (d *DB) GetUnlocks(fingerprint string) []UnlockDef {
	result := make([]UnlockDef, len(UnlockDefs))
	copy(result, UnlockDefs)
	// Mark unlocked ones (reuse the struct, no Unlocked field — caller checks IsUnlocked)
	return result
}

Add bucketUnlocks creation in store/db.go Open().

  • Step 2: Write tests and verify

Run: cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./store/ -v

  • Step 3: Commit
git add store/unlocks.go store/unlocks_test.go store/db.go
git commit -m "feat: add unlock system with 3 unlockable contents"

Task 4: Player Titles

Files:

  • Create: store/titles.go

  • Create: store/titles_test.go

  • Modify: store/db.go (add bucket)

  • Step 1: Implement titles

// store/titles.go
package store

import (
	"encoding/json"

	bolt "go.etcd.io/bbolt"
)

var bucketTitles = []byte("titles")

type TitleDef struct {
	ID   string
	Name string
}

var TitleDefs = []TitleDef{
	{ID: "novice", Name: "Novice"},
	{ID: "explorer", Name: "Explorer"},          // reach floor 5
	{ID: "veteran", Name: "Veteran"},             // reach floor 10
	{ID: "champion", Name: "Champion"},           // reach floor 20
	{ID: "gold_king", Name: "Gold King"},          // earn 500+ gold in one run
	{ID: "team_player", Name: "Team Player"},      // complete 5 multiplayer runs
	{ID: "survivor", Name: "Survivor"},            // complete a run without dying
}

type PlayerTitleData struct {
	ActiveTitle string   `json:"active_title"`
	Earned      []string `json:"earned"`
}

func (d *DB) EarnTitle(fingerprint, titleID string) (bool, error) {
	data := d.loadTitleData(fingerprint)
	for _, t := range data.Earned {
		if t == titleID {
			return false, nil // already earned
		}
	}
	data.Earned = append(data.Earned, titleID)
	if data.ActiveTitle == "" {
		data.ActiveTitle = titleID
	}
	return true, d.saveTitleData(fingerprint, data)
}

func (d *DB) SetActiveTitle(fingerprint, titleID string) error {
	data := d.loadTitleData(fingerprint)
	data.ActiveTitle = titleID
	return d.saveTitleData(fingerprint, data)
}

func (d *DB) GetTitleData(fingerprint string) PlayerTitleData {
	return d.loadTitleData(fingerprint)
}

func (d *DB) loadTitleData(fingerprint string) PlayerTitleData {
	var data PlayerTitleData
	d.db.View(func(tx *bolt.Tx) error {
		v := tx.Bucket(bucketTitles).Get([]byte(fingerprint))
		if v != nil {
			json.Unmarshal(v, &data)
		}
		return nil
	})
	return data
}

func (d *DB) saveTitleData(fingerprint string, data PlayerTitleData) error {
	return d.db.Update(func(tx *bolt.Tx) error {
		raw, _ := json.Marshal(data)
		return tx.Bucket(bucketTitles).Put([]byte(fingerprint), raw)
	})
}

Add bucketTitles to store/db.go Open().

  • Step 2: Write tests and verify
  • Step 3: Commit
git add store/titles.go store/titles_test.go store/db.go
git commit -m "feat: add player title system with 7 titles"

Task 5: Codex System

Files:

  • Create: store/codex.go

  • Create: store/codex_test.go

  • Modify: store/db.go (add bucket)

  • Modify: game/event.go (record monster/item/event encounters)

  • Step 1: Implement codex store

// store/codex.go
package store

import (
	"encoding/json"

	bolt "go.etcd.io/bbolt"
)

var bucketCodex = []byte("codex")

type Codex struct {
	Monsters map[string]bool `json:"monsters"`
	Items    map[string]bool `json:"items"`
	Events   map[string]bool `json:"events"`
}

func (d *DB) RecordCodexEntry(fingerprint, category, id string) error {
	return d.db.Update(func(tx *bolt.Tx) error {
		b := tx.Bucket(bucketCodex)
		var codex Codex
		if v := b.Get([]byte(fingerprint)); v != nil {
			json.Unmarshal(v, &codex)
		}
		if codex.Monsters == nil {
			codex.Monsters = make(map[string]bool)
		}
		if codex.Items == nil {
			codex.Items = make(map[string]bool)
		}
		if codex.Events == nil {
			codex.Events = make(map[string]bool)
		}
		switch category {
		case "monster":
			codex.Monsters[id] = true
		case "item":
			codex.Items[id] = true
		case "event":
			codex.Events[id] = true
		}
		raw, _ := json.Marshal(codex)
		return b.Put([]byte(fingerprint), raw)
	})
}

func (d *DB) GetCodex(fingerprint string) Codex {
	var codex Codex
	d.db.View(func(tx *bolt.Tx) error {
		if v := tx.Bucket(bucketCodex).Get([]byte(fingerprint)); v != nil {
			json.Unmarshal(v, &codex)
		}
		return nil
	})
	if codex.Monsters == nil {
		codex.Monsters = make(map[string]bool)
	}
	if codex.Items == nil {
		codex.Items = make(map[string]bool)
	}
	if codex.Events == nil {
		codex.Events = make(map[string]bool)
	}
	return codex
}

Add bucketCodex to store/db.go. The codex recording hooks in game/event.go will be added — but since GameSession doesn't have a DB reference, recording will happen from the UI layer (GameScreen) which has access to ctx.Store. Add codex recording in ui/game_view.go when the gameState changes (new monsters, items, events).

  • Step 2: Write tests and verify
  • Step 3: Commit
git add store/codex.go store/codex_test.go store/db.go
git commit -m "feat: add codex system for monster/item/event tracking"

Task 6: Codex UI Screen

Files:

  • Create: ui/codex_view.go

  • Modify: ui/lobby_view.go or ui/title.go (add navigation to codex)

  • Step 1: Create CodexScreen

// ui/codex_view.go
package ui

import (
	"fmt"
	tea "github.com/charmbracelet/bubbletea"
	"github.com/tolelom/catacombs/store"
)

type CodexScreen struct {
	codex store.Codex
	tab   int // 0=monsters, 1=items, 2=events
}

func NewCodexScreen(ctx *Context) *CodexScreen {
	codex := ctx.Store.GetCodex(ctx.Fingerprint)
	return &CodexScreen{codex: codex}
}

Implement Update (Tab to switch tabs, Esc to go back) and View (show discovered entries with completion percentage per category).

Add a keybinding in the title screen or lobby to open the codex (e.g., c key).

  • Step 2: Run tests
  • Step 3: Commit
git add ui/codex_view.go ui/title.go
git commit -m "feat: add codex UI screen with completion tracking"

Task 7: Hard Mode and Weekly Mutations

Files:

  • Create: game/mutation.go

  • Create: game/mutation_test.go

  • Modify: config/config.go (add DifficultyConfig)

  • Modify: game/session.go (add HardMode/Mutation fields, apply at session start)

  • Modify: ui/lobby_view.go (show hard mode toggle if unlocked)

  • Step 1: Add DifficultyConfig

// Add to config/config.go
type DifficultyConfig struct {
	HardModeMonsterMult float64 `yaml:"hard_mode_monster_mult"` // default: 1.5
	HardModeShopMult    float64 `yaml:"hard_mode_shop_mult"`    // default: 2.0
	HardModeHealMult    float64 `yaml:"hard_mode_heal_mult"`    // default: 0.5
}

Add to Config struct and defaults.

  • Step 2: Implement mutations
// game/mutation.go
package game

import (
	"crypto/sha256"
	"encoding/binary"
	"time"

	"github.com/tolelom/catacombs/config"
)

type Mutation struct {
	ID          string
	Name        string
	Description string
	Apply       func(cfg *config.GameConfig)
}

var mutations = []Mutation{
	{ID: "no_skills", Name: "Skill Lockout", Description: "Class skills are disabled",
		Apply: func(cfg *config.GameConfig) { cfg.SkillUses = 0 }},
	{ID: "elite_flood", Name: "Elite Flood", Description: "All monsters are elite",
		Apply: func(cfg *config.GameConfig) {}}, // handled in event.go spawn logic
	{ID: "no_shop", Name: "Shop Closed", Description: "Shops are unavailable",
		Apply: func(cfg *config.GameConfig) {}}, // handled in event.go room generation
	{ID: "glass_cannon", Name: "Glass Cannon", Description: "Double ATK, half HP",
		Apply: func(cfg *config.GameConfig) {}}, // handled at player creation
	{ID: "speed_run", Name: "Speed Run", Description: "Turn timeout halved",
		Apply: func(cfg *config.GameConfig) { cfg.TurnTimeoutSec = cfg.TurnTimeoutSec / 2 }},
}

func GetWeeklyMutation() Mutation {
	_, week := time.Now().ISOWeek()
	year, _ := time.Now().ISOWeek()
	h := sha256.Sum256([]byte(fmt.Sprintf("mutation:%d:%d", year, week)))
	idx := int(binary.BigEndian.Uint64(h[:8])) % len(mutations)
	return mutations[idx]
}
  • Step 3: Wire into session

Add HardMode bool and ActiveMutation *Mutation fields to GameSession. When creating a session for hard mode, apply difficulty multipliers to a cloned config. When creating a mutation session, call mutation.Apply(&cfg.Game).

  • Step 4: Add hard mode toggle in lobby UI

In LobbyScreen, if ctx.Store.IsUnlocked(ctx.Fingerprint, "hard_mode"), show a [H] Hard Mode toggle. Store the selection and pass to session creation.

  • Step 5: Run all tests

Run: cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./... -v

  • Step 6: Commit
git add game/mutation.go game/mutation_test.go config/ game/session.go ui/lobby_view.go config.yaml
git commit -m "feat: add hard mode and weekly mutation system"

Task 8: Integration — Daily Challenge Flow + Codex Recording

Files:

  • Modify: ui/lobby_view.go (add daily challenge button)

  • Modify: ui/game_view.go (save daily record on game over; record codex entries)

  • Modify: ui/leaderboard_view.go (add daily tab)

  • Modify: game/session.go (add DailyMode field, use daily seed)

  • Step 1: Add DailyMode to GameSession

Add DailyMode bool and DailyDate string fields. When DailyMode, use DailySeed(date) to create the RNG for GenerateFloor.

  • Step 2: Add daily challenge in lobby

In LobbyScreen, add [D] Daily Challenge button. Creates a solo session with DailyMode=true using today's date.

  • Step 3: Save daily record on game over

In GameScreen, when game ends and ctx.Session.DailyMode, call ctx.Store.SaveDaily(...).

  • Step 4: Record codex entries

In GameScreen.Update(), when gameState shows new monsters (entering combat), record them to codex. When getting items (treasure/shop), record items. When events trigger, record event IDs.

  • Step 5: Add daily leaderboard tab

In LeaderboardScreen, add a tab for daily leaderboard using ctx.Store.GetDailyLeaderboard(today, 20).

  • Step 6: Trigger unlock checks

In GameScreen, after game over:

  • Floor >= 10 → unlock "fifth_class"
  • Players >= 3 && floor >= 5 → unlock "hard_mode"
  • Victory (floor >= maxFloors) → unlock "mutations"

Trigger title earning based on conditions.

  • Step 7: Run all tests

Run: cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./... -v

  • Step 8: Commit
git add ui/ game/session.go
git commit -m "feat: integrate daily challenges, codex recording, and unlock triggers"

Task 9: 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

Run: cd C:/Users/SSAFY/Desktop/projects/Catacombs && go build -o catacombs.exe . Expected: Success

  • Step 4: Verify new BoltDB buckets

Confirm store/db.go Open() creates all 7 buckets: profiles, rankings, achievements, daily_runs, unlocks, titles, codex.

  • Step 5: Commit if cleanup needed
git add -A
git commit -m "chore: phase 3 complete — retention systems verified"