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>
This commit is contained in:
2026-03-25 15:53:56 +09:00
parent b8697e414a
commit 5ff82120ff

View File

@@ -0,0 +1,786 @@
# 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**
```go
// 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**
```go
// 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**
```bash
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**
```go
// 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**
```go
// 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:
```go
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**
```bash
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
```go
// 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**
```bash
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**
```go
// 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**
```bash
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**
```go
// 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**
```bash
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**
```go
// 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**
```bash
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**
```go
// 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**
```go
// 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**
```bash
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**
```bash
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**
```bash
git add -A
git commit -m "chore: phase 3 complete — retention systems verified"
```