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:
786
docs/superpowers/plans/2026-03-25-phase3-retention.md
Normal file
786
docs/superpowers/plans/2026-03-25-phase3-retention.md
Normal 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"
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user