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