Compare commits

..

39 Commits

Author SHA1 Message Date
97aa4667a1 docs: add CLAUDE.md and Phase 4 implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:42:06 +09:00
ee4147b255 feat: graceful shutdown with signal handling and backup scheduler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 17:39:17 +09:00
7c9a12cd6e refactor: extract NewServer for SSH shutdown control 2026-03-25 17:38:01 +09:00
6e78d8a073 refactor: web server returns *http.Server for shutdown control
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 17:36:15 +09:00
a38cf804ef feat: add /admin endpoint with Basic Auth and JSON stats
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 17:35:00 +09:00
ae8ed8a8ae feat: add today's run count and avg floor stat queries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 17:33:44 +09:00
6c749ba591 feat: add DB backup with consistent BoltDB snapshots
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 17:32:39 +09:00
4c006df27e feat: add admin config for dashboard authentication
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 17:31:08 +09:00
a7bca9d2f2 feat: integrate daily challenges, codex recording, and unlock triggers
- Add DailyMode/DailyDate fields to GameSession; use daily seed for floor generation
- Add [D] Daily Challenge button in lobby for solo daily sessions
- Record codex entries for monsters and shop items during gameplay
- Trigger unlock checks (fifth_class, hard_mode, mutations) on game over
- Trigger title checks (novice, explorer, veteran, champion, gold_king) on game over
- Save daily records on game over for daily mode sessions
- Add daily leaderboard tab with Tab key cycling in leaderboard view

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:13:10 +09:00
00581880f2 feat: add hard mode and weekly mutation system
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:59:35 +09:00
cf37eef1b1 feat: add codex UI screen with completion tracking
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:39:08 +09:00
caefaff200 feat: add codex system for monster/item/event tracking
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:20:36 +09:00
6c3188e747 feat: add player title system with 7 titles
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:07:02 +09:00
8f899a5afd feat: add unlock system with 3 unlockable contents
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:06:40 +09:00
253d1f80d3 feat: add daily challenge record storage and leaderboard
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:01:58 +09:00
cd0d6c1c4c feat: add daily challenge seed generation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:01:17 +09:00
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
b8697e414a feat: integrate skill tree UI and combat bonuses
Grant skill points on floor clear, add allocation UI with [ ] keys
during exploration, apply SkillPower bonus to Mage Fireball and Healer
Heal, initialize skills for new players, and deep copy skills in
GetState.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:47:01 +09:00
65c062a1f7 feat: seed-based dungeon generation for deterministic floors
Thread *rand.Rand through GenerateFloor, splitBSP, and RandomRoomType
so floors can be reproduced from a seed. This enables daily challenges
in Phase 3. All callers now create a local rng instance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:39:21 +09:00
7f29995833 feat: add secret rooms and mini-bosses on floors 4/9/14/19
Add RoomSecret (5% chance) and RoomMiniBoss room types. Add 4 mini-boss
monsters at 60% of boss stats (Guardian's Herald, Warden's Shadow,
Overlord's Lieutenant, Archlich's Harbinger) with IsMiniBoss flag and
boss pattern logic. Secret rooms grant double treasure. Mini-boss rooms
are placed on floors 4/9/14/19 at room index 1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:30:21 +09:00
e167165bbc feat: add 8 random event rooms with choice-based outcomes
Implement RandomEvent system with 8 events (altar, fountain, merchant,
trap_room, shrine, chest, ghost, mushroom), each with 2-3 choices that
resolve based on floor number. Update triggerEvent() to use the new
system, auto-resolving a random choice on a random alive player.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:27:07 +09:00
1e155c62fb feat: add floor themes with status effect modifiers
Add 4 floor themes (Swamp/Volcano/Glacier/Inferno) that boost status
effect damage on matching floors. Realign boss patterns to match themes
and add PatternFreeze for the Glacier boss.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:22:17 +09:00
69ac6cd383 feat: add combo skill system with 5 combos
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:06:56 +09:00
22ebeb1d48 feat: add elite monsters with 5 prefix types
Elite monsters have ~20% spawn chance with Venomous, Burning, Freezing,
Bleeding, or Vampiric prefixes. Each prefix scales HP/ATK and applies
on-hit status effects (or life drain for Vampiric).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:02:44 +09:00
8ef3d9dd13 feat: add skill tree system with 2 branches per class
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:36:19 +09:00
05cf59c659 feat: add Bleed and Curse status effects, add Freeze handler
- Add StatusBleed and StatusCurse to the status effect enum
- Rewrite TickEffects with index-based loop to support Bleed value mutation
- Add Freeze tick message, Bleed damage (intensifies each turn), Curse message
- Update Heal() to reduce healing amount when cursed
- Add tests for Bleed stacking, Curse heal reduction, and Freeze tick message

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:21:46 +09:00
fa78bfecee docs: add Phase 2 combat/dungeon implementation plan
10 tasks covering: status effects, skill trees, elite monsters, combos,
floor themes, random events, secret rooms, mini-bosses, seed-based
generation, and UI integration. Reviewed and corrected for all critical
issues (package visibility, loop structure, missing handlers, stats).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:18:54 +09:00
fc0c5edc38 feat: add chat emote system (/hi, /gg, /go, /wait, /help)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:05:04 +09:00
083a895be2 fix: address review issues in screen extraction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 14:01:55 +09:00
ba01c11d36 refactor: extract all screens from model.go into Screen implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:26:47 +09:00
7cb9290798 feat: add Screen interface and Context for UI architecture
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:21:04 +09:00
afe4ee1056 feat: add structured logging with log/slog and panic recovery
Replace log.Printf/Println with slog.Info/Error/Warn across the codebase.
Initialize slog with JSON handler in main.go. Add panic recovery defer
in SSH session handler. Add structured game event logging (room created,
player joined, game started, game over, player inactive removed).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:18:06 +09:00
f85775dd3e feat: replace all hardcoded constants with config values
Replace hardcoded game constants with values from the config system:
- GameSession now receives *config.Config from Lobby
- TurnTimeout, MaxFloors, SkillUses, InventoryLimit use config values
- combat.AttemptFlee accepts fleeChance param
- combat.ResolveAttacks accepts coopBonus param
- entity.NewMonster accepts scaling param
- Solo HP/DEF reduction uses config SoloHPReduction
- Lobby JoinRoom uses config MaxPlayers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:08:52 +09:00
ad1482ae03 feat: wire config into main, server, and lobby
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:02:54 +09:00
0f524779c0 feat: add configuration package with YAML loading and defaults
Add config package that loads game settings from YAML files with
sensible defaults for server, game, combat, dungeon, and backup
settings. Includes config.yaml with all defaults documented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:59:44 +09:00
089d5c76ed docs: add Phase 1 foundation implementation plan
13 tasks covering config package, server wiring, constant replacement,
structured logging, UI Screen interface extraction, and emote system.
All reviewed and corrected for module paths, signatures, and coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:51:31 +09:00
7064544693 docs: fix boss naming and AoE pattern in spec
- Archfiend → Archlich (matches codebase)
- Clarify AoE pattern moves from Guardian to Archlich final boss

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:38:10 +09:00
dfdc18a444 docs: revise game enhancement spec after review
Fix critical issues from spec review:
- Correct model.go line count (712, not 19K) and adjust Phase 1-1 scope
- Acknowledge existing chat/freeze implementations, rescope accordingly
- Add data model definitions (structs, BoltDB schemas) for all new entities
- Move logging and config externalization to Phase 1 (foundation)
- Align floor themes with boss patterns
- Add testing strategy, data migration plan, and admin API contract

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:02:48 +09:00
07587b103e docs: add game enhancement design spec
Comprehensive enhancement plan for Catacombs covering 4 phases:
UI refactoring, combat/dungeon expansion, retention systems, and
operational stability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:58:34 +09:00
76 changed files with 8969 additions and 858 deletions

55
CLAUDE.md Normal file
View File

@@ -0,0 +1,55 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Catacombs is a multiplayer roguelike dungeon crawler with dual access: SSH (native TUI) and HTTP/WebSocket (web browser via xterm.js). Written in Go, it uses Bubble Tea for the terminal UI and BoltDB for persistence.
## Build & Run Commands
```bash
go build -o catacombs . # Build
go test ./... # Run all tests
go test ./combat/ # Run tests for a single package
go test ./entity/ -run TestName # Run a specific test
go vet ./... # Lint
```
Docker:
```bash
docker build -t catacombs .
docker-compose up # SSH on :2222, HTTP on :8080
```
## Architecture
**Package dependency flow:** `main``server`/`web`/`store``game``dungeon``entity``combat`
| Package | Responsibility |
|---------|---------------|
| `main.go` | Entry point: initializes BoltDB (`./data/catacombs.db`), starts SSH server (:2222) and HTTP server (:8080) |
| `game/` | Lobby (room management, player tracking, reconnection), GameSession (turn-based state), turn execution (5s action timeout), room events (combat/shop/treasure) |
| `ui/` | Bubble Tea state machine with 8 screen states (nickname → lobby → class select → game → shop → result → leaderboard → achievements). `model.go` is the central state machine (~19K lines) |
| `dungeon/` | BSP tree procedural generation (60x20 maps), ASCII rendering with floor themes, field-of-view |
| `entity/` | Player (4 classes: Warrior/Mage/Healer/Rogue), Monster (8 types + 4 bosses with floor scaling), Items/Relics |
| `combat/` | Damage calculation, monster AI targeting, cooperative damage bonus |
| `store/` | BoltDB persistence: profiles, rankings, achievements (10 unlockable) |
| `server/` | Wish SSH server with fingerprint-based auth |
| `web/` | HTTP + WebSocket bridge to SSH, embedded xterm.js frontend |
## Key Patterns
- **Concurrent session management**: Mutex-protected game state for multi-player synchronization (up to 4 players per room)
- **Turn-based action collection**: 5-second timeout window; players who don't submit default to "Wait"
- **SSH fingerprint reconnection**: Players reconnect to active sessions via `Lobby.activeSessions` fingerprint mapping
- **Dual access**: SSH server (native PTY) and HTTP/WebSocket (xterm.js) share the same Lobby and DB instances
- **Combat log reveal**: Logs shown incrementally via `PendingLogs``CombatLog` system
## Game Balance Constants
- 20 floors with bosses at 5, 10, 15, 20
- Monster scaling: 1.15x power per floor above minimum
- Solo mode halves enemy stats
- Cooperative bonus: +10% damage when 2+ players target same enemy
- Inventory limit: 10 items, 3 skill uses per combat

View File

@@ -31,7 +31,7 @@ type AttackResult struct {
IsAoE bool
}
func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster) []AttackResult {
func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster, coopBonus float64) []AttackResult {
targetCount := make(map[int]int)
targetOrder := make(map[int]int)
for i, intent := range intents {
@@ -63,7 +63,7 @@ func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster) []Attack
dmg := CalcDamage(intent.PlayerATK, m.DEF, intent.Multiplier)
coopApplied := false
if targetCount[intent.TargetIdx] >= 2 && targetOrder[intent.TargetIdx] != i {
dmg = int(math.Round(float64(dmg) * 1.10))
dmg = int(math.Round(float64(dmg) * (1.0 + coopBonus)))
coopApplied = true
}
m.TakeDamage(dmg)
@@ -77,13 +77,13 @@ func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster) []Attack
return results
}
func AttemptFlee() bool {
return rand.Float64() < 0.5
func AttemptFlee(fleeChance float64) bool {
return rand.Float64() < fleeChance
}
func MonsterAI(m *entity.Monster, players []*entity.Player, turnNumber int) (targetIdx int, isAoE bool) {
if m.IsBoss && turnNumber > 0 && turnNumber%3 == 0 {
return -1, true // AoE every 3 turns for all bosses
if (m.IsBoss || m.IsMiniBoss) && turnNumber > 0 && turnNumber%3 == 0 {
return -1, true // AoE every 3 turns for all bosses and mini-bosses
}
if m.TauntTarget {
for i, p := range players {

View File

@@ -25,7 +25,7 @@ func TestCoopBonus(t *testing.T) {
{PlayerATK: 12, TargetIdx: 0, Multiplier: 1.0, IsAoE: false},
{PlayerATK: 15, TargetIdx: 0, Multiplier: 1.0, IsAoE: false},
}
results := ResolveAttacks(attackers, []*entity.Monster{entity.NewMonster(entity.MonsterSlime, 1)})
results := ResolveAttacks(attackers, []*entity.Monster{entity.NewMonster(entity.MonsterSlime, 1, 1.15)}, 0.10)
if !results[1].CoopApplied {
t.Error("Second attacker should get co-op bonus")
}
@@ -37,10 +37,10 @@ func TestAoENoCoopBonus(t *testing.T) {
{PlayerATK: 20, TargetIdx: -1, Multiplier: 0.8, IsAoE: true},
}
monsters := []*entity.Monster{
entity.NewMonster(entity.MonsterSlime, 1),
entity.NewMonster(entity.MonsterSlime, 1),
entity.NewMonster(entity.MonsterSlime, 1, 1.15),
entity.NewMonster(entity.MonsterSlime, 1, 1.15),
}
results := ResolveAttacks(attackers, monsters)
results := ResolveAttacks(attackers, monsters, 0.10)
if results[0].CoopApplied {
t.Error("AoE should not trigger co-op bonus")
}
@@ -68,7 +68,7 @@ func TestMonsterAITauntDeadWarrior(t *testing.T) {
func TestFleeChance(t *testing.T) {
successes := 0
for i := 0; i < 100; i++ {
if AttemptFlee() {
if AttemptFlee(0.50) {
successes++
}
}

91
combat/combo.go Normal file
View File

@@ -0,0 +1,91 @@
package combat
import "github.com/tolelom/catacombs/entity"
type ComboAction struct {
Class entity.Class
ActionType string // "attack", "skill", "item"
}
type ComboEffect struct {
DamageMultiplier float64 // multiplied onto each AttackIntent.Multiplier
BonusDamage int // added to each AttackIntent.PlayerATK
HealAll int // heal all players after resolution
Message string // shown in combat log
}
type ComboDef struct {
Name string
Required []ComboAction
Effect ComboEffect
}
var comboDefs = []ComboDef{
{
Name: "Ice Shatter",
Required: []ComboAction{
{Class: entity.ClassMage, ActionType: "skill"},
{Class: entity.ClassWarrior, ActionType: "attack"},
},
Effect: ComboEffect{DamageMultiplier: 1.5, Message: "💥 ICE SHATTER! Frozen enemies shatter!"},
},
{
Name: "Holy Assault",
Required: []ComboAction{
{Class: entity.ClassHealer, ActionType: "skill"},
{Class: entity.ClassWarrior, ActionType: "attack"},
},
Effect: ComboEffect{DamageMultiplier: 1.3, HealAll: 10, Message: "✨ HOLY ASSAULT! Blessed strikes heal the party!"},
},
{
Name: "Shadow Strike",
Required: []ComboAction{
{Class: entity.ClassRogue, ActionType: "skill"},
{Class: entity.ClassMage, ActionType: "attack"},
},
Effect: ComboEffect{DamageMultiplier: 1.4, Message: "🗡️ SHADOW STRIKE! Magical shadows amplify the attack!"},
},
{
Name: "Full Assault",
Required: []ComboAction{
{Class: entity.ClassWarrior, ActionType: "attack"},
{Class: entity.ClassMage, ActionType: "attack"},
{Class: entity.ClassRogue, ActionType: "attack"},
},
Effect: ComboEffect{DamageMultiplier: 1.3, BonusDamage: 5, Message: "⚔️ FULL ASSAULT! Combined attack overwhelms!"},
},
{
Name: "Restoration",
Required: []ComboAction{
{Class: entity.ClassHealer, ActionType: "skill"},
{Class: entity.ClassRogue, ActionType: "item"},
},
Effect: ComboEffect{HealAll: 20, Message: "💚 RESTORATION! Combined healing surges!"},
},
}
func DetectCombos(actions map[string]ComboAction) []ComboDef {
var triggered []ComboDef
for _, combo := range comboDefs {
if matchesCombo(combo.Required, actions) {
triggered = append(triggered, combo)
}
}
return triggered
}
func matchesCombo(required []ComboAction, actions map[string]ComboAction) bool {
for _, req := range required {
found := false
for _, act := range actions {
if act.Class == req.Class && act.ActionType == req.ActionType {
found = true
break
}
}
if !found {
return false
}
}
return true
}

45
combat/combo_test.go Normal file
View File

@@ -0,0 +1,45 @@
package combat
import (
"testing"
"github.com/tolelom/catacombs/entity"
)
func TestDetectCombo_IceShatter(t *testing.T) {
actions := map[string]ComboAction{
"mage": {Class: entity.ClassMage, ActionType: "skill"},
"warrior": {Class: entity.ClassWarrior, ActionType: "attack"},
}
combos := DetectCombos(actions)
if len(combos) == 0 {
t.Fatal("expected Ice Shatter combo")
}
if combos[0].Name != "Ice Shatter" {
t.Errorf("expected 'Ice Shatter', got %q", combos[0].Name)
}
}
func TestDetectCombo_NoMatch(t *testing.T) {
actions := map[string]ComboAction{
"w1": {Class: entity.ClassWarrior, ActionType: "attack"},
"w2": {Class: entity.ClassWarrior, ActionType: "attack"},
}
combos := DetectCombos(actions)
if len(combos) != 0 {
t.Errorf("expected no combos, got %d", len(combos))
}
}
func TestDetectCombo_Multiple(t *testing.T) {
// Healer skill + Warrior attack + Rogue item → Holy Assault + Restoration
actions := map[string]ComboAction{
"healer": {Class: entity.ClassHealer, ActionType: "skill"},
"warrior": {Class: entity.ClassWarrior, ActionType: "attack"},
"rogue": {Class: entity.ClassRogue, ActionType: "item"},
}
combos := DetectCombos(actions)
if len(combos) != 2 {
t.Errorf("expected 2 combos, got %d", len(combos))
}
}

57
config.yaml Normal file
View File

@@ -0,0 +1,57 @@
# Catacombs Configuration
# All values shown are defaults. Uncomment and modify as needed.
server:
# SSH port for game client connections
ssh_port: 2222
# HTTP port for web interface
http_port: 8080
game:
# Seconds allowed per player turn
turn_timeout_sec: 5
# Maximum players per game session
max_players: 4
# Maximum dungeon floors
max_floors: 20
# Cooperative play bonus multiplier
coop_bonus: 0.10
# Maximum items a player can carry
inventory_limit: 10
# Number of skill uses per floor
skill_uses: 3
combat:
# Probability of successfully fleeing combat
flee_chance: 0.50
# Monster stat scaling per floor
monster_scaling: 1.15
# HP reduction when playing solo
solo_hp_reduction: 0.50
dungeon:
# Map dimensions in tiles
map_width: 60
map_height: 20
# Room count range per floor
min_rooms: 5
max_rooms: 8
backup:
# Minutes between automatic backups
interval_min: 60
# Directory for backup files
dir: "./data/backup"
difficulty:
# Monster stat multiplier in hard mode
hard_mode_monster_mult: 1.5
# Shop price multiplier in hard mode
hard_mode_shop_mult: 2.0
# Healing effectiveness multiplier in hard mode
hard_mode_heal_mult: 0.5
admin:
# Basic auth credentials for /admin endpoint
username: "admin"
password: "catacombs"

90
config/config.go Normal file
View File

@@ -0,0 +1,90 @@
package config
import (
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `yaml:"server"`
Game GameConfig `yaml:"game"`
Combat CombatConfig `yaml:"combat"`
Dungeon DungeonConfig `yaml:"dungeon"`
Backup BackupConfig `yaml:"backup"`
Difficulty DifficultyConfig `yaml:"difficulty"`
Admin AdminConfig `yaml:"admin"`
}
type AdminConfig struct {
Username string `yaml:"username"`
Password string `yaml:"password"`
}
type DifficultyConfig struct {
HardModeMonsterMult float64 `yaml:"hard_mode_monster_mult"`
HardModeShopMult float64 `yaml:"hard_mode_shop_mult"`
HardModeHealMult float64 `yaml:"hard_mode_heal_mult"`
}
type ServerConfig struct {
SSHPort int `yaml:"ssh_port"`
HTTPPort int `yaml:"http_port"`
}
type GameConfig struct {
TurnTimeoutSec int `yaml:"turn_timeout_sec"`
MaxPlayers int `yaml:"max_players"`
MaxFloors int `yaml:"max_floors"`
CoopBonus float64 `yaml:"coop_bonus"`
InventoryLimit int `yaml:"inventory_limit"`
SkillUses int `yaml:"skill_uses"`
}
type CombatConfig struct {
FleeChance float64 `yaml:"flee_chance"`
MonsterScaling float64 `yaml:"monster_scaling"`
SoloHPReduction float64 `yaml:"solo_hp_reduction"`
}
type DungeonConfig struct {
MapWidth int `yaml:"map_width"`
MapHeight int `yaml:"map_height"`
MinRooms int `yaml:"min_rooms"`
MaxRooms int `yaml:"max_rooms"`
}
type BackupConfig struct {
IntervalMin int `yaml:"interval_min"`
Dir string `yaml:"dir"`
}
func defaults() Config {
return Config{
Server: ServerConfig{SSHPort: 2222, HTTPPort: 8080},
Game: GameConfig{
TurnTimeoutSec: 5, MaxPlayers: 4, MaxFloors: 20,
CoopBonus: 0.10, InventoryLimit: 10, SkillUses: 3,
},
Combat: CombatConfig{FleeChance: 0.50, MonsterScaling: 1.15, SoloHPReduction: 0.50},
Dungeon: DungeonConfig{MapWidth: 60, MapHeight: 20, MinRooms: 5, MaxRooms: 8},
Backup: BackupConfig{IntervalMin: 60, Dir: "./data/backup"},
Difficulty: DifficultyConfig{HardModeMonsterMult: 1.5, HardModeShopMult: 2.0, HardModeHealMult: 0.5},
Admin: AdminConfig{Username: "admin", Password: "catacombs"},
}
}
func Load(path string) (*Config, error) {
cfg := defaults()
if path == "" {
return &cfg, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}

94
config/config_test.go Normal file
View File

@@ -0,0 +1,94 @@
package config
import (
"os"
"testing"
)
func TestLoadDefaults(t *testing.T) {
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
if cfg.Server.SSHPort != 2222 {
t.Errorf("expected SSH port 2222, got %d", cfg.Server.SSHPort)
}
if cfg.Server.HTTPPort != 8080 {
t.Errorf("expected HTTP port 8080, got %d", cfg.Server.HTTPPort)
}
if cfg.Game.TurnTimeoutSec != 5 {
t.Errorf("expected turn timeout 5, got %d", cfg.Game.TurnTimeoutSec)
}
if cfg.Game.MaxPlayers != 4 {
t.Errorf("expected max players 4, got %d", cfg.Game.MaxPlayers)
}
if cfg.Game.MaxFloors != 20 {
t.Errorf("expected max floors 20, got %d", cfg.Game.MaxFloors)
}
if cfg.Game.CoopBonus != 0.10 {
t.Errorf("expected coop bonus 0.10, got %f", cfg.Game.CoopBonus)
}
if cfg.Game.InventoryLimit != 10 {
t.Errorf("expected inventory limit 10, got %d", cfg.Game.InventoryLimit)
}
if cfg.Combat.FleeChance != 0.50 {
t.Errorf("expected flee chance 0.50, got %f", cfg.Combat.FleeChance)
}
if cfg.Combat.MonsterScaling != 1.15 {
t.Errorf("expected monster scaling 1.15, got %f", cfg.Combat.MonsterScaling)
}
if cfg.Difficulty.HardModeMonsterMult != 1.5 {
t.Errorf("expected hard mode monster mult 1.5, got %f", cfg.Difficulty.HardModeMonsterMult)
}
if cfg.Difficulty.HardModeShopMult != 2.0 {
t.Errorf("expected hard mode shop mult 2.0, got %f", cfg.Difficulty.HardModeShopMult)
}
if cfg.Difficulty.HardModeHealMult != 0.5 {
t.Errorf("expected hard mode heal mult 0.5, got %f", cfg.Difficulty.HardModeHealMult)
}
}
func TestLoadFromFile(t *testing.T) {
content := []byte(`
server:
ssh_port: 3333
http_port: 9090
game:
turn_timeout_sec: 10
max_players: 2
`)
f, err := os.CreateTemp("", "config-*.yaml")
if err != nil {
t.Fatal(err)
}
defer os.Remove(f.Name())
f.Write(content)
f.Close()
cfg, err := Load(f.Name())
if err != nil {
t.Fatal(err)
}
if cfg.Server.SSHPort != 3333 {
t.Errorf("expected SSH port 3333, got %d", cfg.Server.SSHPort)
}
if cfg.Server.HTTPPort != 9090 {
t.Errorf("expected HTTP port 9090, got %d", cfg.Server.HTTPPort)
}
if cfg.Game.TurnTimeoutSec != 10 {
t.Errorf("expected turn timeout 10, got %d", cfg.Game.TurnTimeoutSec)
}
if cfg.Game.MaxPlayers != 2 {
t.Errorf("expected max players 2, got %d", cfg.Game.MaxPlayers)
}
// Unset fields should have defaults
if cfg.Game.MaxFloors != 20 {
t.Errorf("expected default max floors 20, got %d", cfg.Game.MaxFloors)
}
if cfg.Combat.FleeChance != 0.50 {
t.Errorf("expected default flee chance 0.50, got %f", cfg.Combat.FleeChance)
}
if cfg.Dungeon.MapWidth != 60 {
t.Errorf("expected default map width 60, got %d", cfg.Dungeon.MapWidth)
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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"
```

View File

@@ -0,0 +1,941 @@
# Phase 4: Operational Stability 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 admin dashboard, automatic DB backup, and graceful shutdown for production-ready operation.
**Architecture:** Three independent subsystems: (1) `/admin` HTTP endpoint with Basic Auth returning JSON stats, (2) periodic BoltDB file backup using existing `BackupConfig`, (3) signal-driven graceful shutdown that stops servers and backs up before exit. All hook into `main.go` orchestration.
**Tech Stack:** Go stdlib (`net/http`, `os/signal`, `io`, `time`), existing BoltDB (`go.etcd.io/bbolt`), Wish SSH server, config package.
**Spec:** `docs/superpowers/specs/2026-03-25-game-enhancement-design.md` (Phase 4, lines 302-325)
**Note on "restart session cleanup" (spec 4-2):** The spec mentions cleaning up incomplete sessions on restart. Since `game.Lobby` and all `GameSession` state are in-memory (not persisted to BoltDB), a fresh server start always begins with a clean slate. No explicit cleanup logic is needed.
---
## File Structure
| File | Action | Responsibility |
|------|--------|---------------|
| `config/config.go` | Modify | Add `AdminConfig` (username, password) to `Config` struct |
| `config.yaml` | Modify | Add `admin` section |
| `store/backup.go` | Create | `Backup(destDir)` method — copies DB file with timestamp |
| `store/backup_test.go` | Create | Tests for backup functionality |
| `store/stats.go` | Create | `GetTodayRunCount()` and `GetTodayAvgFloor()` query methods |
| `store/stats_test.go` | Create | Tests for stats queries |
| `web/admin.go` | Create | `/admin` handler with Basic Auth, JSON response |
| `web/admin_test.go` | Create | Tests for admin endpoint |
| `web/server.go` | Modify | Accept `*http.Server` pattern for graceful shutdown; add admin route; accept lobby+db params |
| `server/ssh.go` | Modify | Return `*wish.Server` for shutdown control |
| `main.go` | Modify | Signal handler, backup scheduler, graceful shutdown orchestration |
---
### Task 1: Admin Config
**Files:**
- Modify: `config/config.go:9-16` (Config struct)
- Modify: `config/config.go:56-68` (defaults func)
- Modify: `config.yaml:1-53`
- Test: `config/config_test.go` (existing)
- [ ] **Step 1: Add AdminConfig to config struct**
In `config/config.go`, add after `DifficultyConfig`:
```go
type AdminConfig struct {
Username string `yaml:"username"`
Password string `yaml:"password"`
}
```
Add `Admin AdminConfig \`yaml:"admin"\`` to the `Config` struct.
In `defaults()`, add:
```go
Admin: AdminConfig{Username: "admin", Password: "catacombs"},
```
- [ ] **Step 2: Add admin section to config.yaml**
Append to `config.yaml`:
```yaml
admin:
# Basic auth credentials for /admin endpoint
username: "admin"
password: "catacombs"
```
- [ ] **Step 3: Run tests to verify config still loads**
Run: `go test ./config/ -v`
Expected: PASS (existing tests still pass with new fields)
- [ ] **Step 4: Commit**
```bash
git add config/config.go config.yaml
git commit -m "feat: add admin config for dashboard authentication"
```
---
### Task 2: DB Backup
**Files:**
- Create: `store/backup.go`
- Create: `store/backup_test.go`
- [ ] **Step 1: Write the failing test**
Create `store/backup_test.go`:
```go
package store
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestBackup(t *testing.T) {
// Create a temp DB
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := Open(dbPath)
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
defer db.Close()
// Write some data
if err := db.SaveProfile("fp1", "player1"); err != nil {
t.Fatalf("failed to save profile: %v", err)
}
backupDir := filepath.Join(tmpDir, "backups")
// Run backup
backupPath, err := db.Backup(backupDir)
if err != nil {
t.Fatalf("backup failed: %v", err)
}
// Verify backup file exists
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
t.Fatal("backup file does not exist")
}
// Verify backup file name contains timestamp pattern
base := filepath.Base(backupPath)
if !strings.HasPrefix(base, "catacombs-") || !strings.HasSuffix(base, ".db") {
t.Fatalf("unexpected backup filename: %s", base)
}
// Verify backup is readable by opening it
backupDB, err := Open(backupPath)
if err != nil {
t.Fatalf("failed to open backup: %v", err)
}
defer backupDB.Close()
name, err := backupDB.GetProfile("fp1")
if err != nil {
t.Fatalf("failed to read from backup: %v", err)
}
if name != "player1" {
t.Fatalf("expected player1, got %s", name)
}
}
func TestBackupCreatesDir(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := Open(dbPath)
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
defer db.Close()
backupDir := filepath.Join(tmpDir, "nested", "backups")
_, err = db.Backup(backupDir)
if err != nil {
t.Fatalf("backup with nested dir failed: %v", err)
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./store/ -run TestBackup -v`
Expected: FAIL — `db.Backup` method does not exist
- [ ] **Step 3: Write minimal implementation**
Create `store/backup.go`:
```go
package store
import (
"fmt"
"os"
"path/filepath"
"time"
bolt "go.etcd.io/bbolt"
)
// Backup creates a consistent snapshot of the database in destDir.
// Returns the path to the backup file.
func (d *DB) Backup(destDir string) (string, error) {
if err := os.MkdirAll(destDir, 0755); err != nil {
return "", fmt.Errorf("create backup dir: %w", err)
}
timestamp := time.Now().Format("20060102-150405")
filename := fmt.Sprintf("catacombs-%s.db", timestamp)
destPath := filepath.Join(destDir, filename)
f, err := os.Create(destPath)
if err != nil {
return "", fmt.Errorf("create backup file: %w", err)
}
defer f.Close()
// BoltDB View transaction provides a consistent snapshot
err = d.db.View(func(tx *bolt.Tx) error {
_, err := tx.WriteTo(f)
return err
})
if err != nil {
os.Remove(destPath)
return "", fmt.Errorf("backup write: %w", err)
}
return destPath, nil
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `go test ./store/ -run TestBackup -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add store/backup.go store/backup_test.go
git commit -m "feat: add DB backup with consistent BoltDB snapshots"
```
---
### Task 3: Today's Stats Queries
**Files:**
- Create: `store/stats.go`
- Create: `store/stats_test.go`
- [ ] **Step 1: Write the failing test**
Create `store/stats_test.go`:
```go
package store
import (
"path/filepath"
"testing"
"time"
)
func TestGetTodayRunCount(t *testing.T) {
tmpDir := t.TempDir()
db, err := Open(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
today := time.Now().Format("2006-01-02")
// No runs yet
count, err := db.GetTodayRunCount()
if err != nil {
t.Fatalf("GetTodayRunCount: %v", err)
}
if count != 0 {
t.Fatalf("expected 0, got %d", count)
}
// Add some daily runs for today
db.SaveDaily(DailyRecord{Date: today, Player: "fp1", PlayerName: "A", FloorReached: 10, GoldEarned: 100})
db.SaveDaily(DailyRecord{Date: today, Player: "fp2", PlayerName: "B", FloorReached: 15, GoldEarned: 200})
// Add a run for yesterday (should not count)
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
db.SaveDaily(DailyRecord{Date: yesterday, Player: "fp3", PlayerName: "C", FloorReached: 5, GoldEarned: 50})
count, err = db.GetTodayRunCount()
if err != nil {
t.Fatalf("GetTodayRunCount: %v", err)
}
if count != 2 {
t.Fatalf("expected 2, got %d", count)
}
}
func TestGetTodayAvgFloor(t *testing.T) {
tmpDir := t.TempDir()
db, err := Open(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
today := time.Now().Format("2006-01-02")
// No runs
avg, err := db.GetTodayAvgFloor()
if err != nil {
t.Fatalf("GetTodayAvgFloor: %v", err)
}
if avg != 0 {
t.Fatalf("expected 0, got %f", avg)
}
// Add runs: floor 10, floor 20 → avg 15
db.SaveDaily(DailyRecord{Date: today, Player: "fp1", PlayerName: "A", FloorReached: 10, GoldEarned: 100})
db.SaveDaily(DailyRecord{Date: today, Player: "fp2", PlayerName: "B", FloorReached: 20, GoldEarned: 200})
avg, err = db.GetTodayAvgFloor()
if err != nil {
t.Fatalf("GetTodayAvgFloor: %v", err)
}
if avg != 15.0 {
t.Fatalf("expected 15.0, got %f", avg)
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./store/ -run TestGetToday -v`
Expected: FAIL — methods do not exist
- [ ] **Step 3: Write minimal implementation**
Create `store/stats.go`:
```go
package store
import (
"encoding/json"
"time"
bolt "go.etcd.io/bbolt"
)
// GetTodayRunCount returns the number of daily challenge runs for today.
func (d *DB) GetTodayRunCount() (int, error) {
today := time.Now().Format("2006-01-02")
count := 0
err := d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketDailyRuns)
c := b.Cursor()
prefix := []byte(today + ":")
for k, _ := c.Seek(prefix); k != nil && len(k) >= len(prefix) && string(k[:len(prefix)]) == string(prefix); k, _ = c.Next() {
count++
}
return nil
})
return count, err
}
// GetTodayAvgFloor returns the average floor reached in today's daily runs.
func (d *DB) GetTodayAvgFloor() (float64, error) {
today := time.Now().Format("2006-01-02")
total := 0
count := 0
err := d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketDailyRuns)
c := b.Cursor()
prefix := []byte(today + ":")
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 {
total += r.FloorReached
count++
}
}
return nil
})
if count == 0 {
return 0, err
}
return float64(total) / float64(count), err
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `go test ./store/ -run TestGetToday -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add store/stats.go store/stats_test.go
git commit -m "feat: add today's run count and avg floor stat queries"
```
---
### Task 4: Admin HTTP Endpoint
**Files:**
- Create: `web/admin.go`
- Create: `web/admin_test.go`
- Modify: `web/server.go:30-43` (Start function signature and route setup)
- [ ] **Step 1: Write the failing test**
Create `web/admin_test.go`:
```go
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/tolelom/catacombs/config"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
"path/filepath"
)
func TestAdminEndpoint(t *testing.T) {
tmpDir := t.TempDir()
db, err := store.Open(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close()
cfg := &config.Config{
Admin: config.AdminConfig{Username: "admin", Password: "secret"},
}
lobby := game.NewLobby(cfg)
handler := AdminHandler(lobby, db, time.Now())
// Test without auth → 401
req := httptest.NewRequest("GET", "/admin", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
// Test with wrong auth → 401
req = httptest.NewRequest("GET", "/admin", nil)
req.SetBasicAuth("admin", "wrong")
w = httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
// Test with correct auth → 200 + JSON
req = httptest.NewRequest("GET", "/admin", nil)
req.SetBasicAuth("admin", "secret")
w = httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var stats AdminStats
if err := json.Unmarshal(w.Body.Bytes(), &stats); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if stats.OnlinePlayers != 0 {
t.Fatalf("expected 0 online, got %d", stats.OnlinePlayers)
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./web/ -run TestAdmin -v`
Expected: FAIL — `AdminHandler` and `AdminStats` do not exist
- [ ] **Step 3: Write minimal implementation**
Create `web/admin.go`:
```go
package web
import (
"encoding/json"
"net/http"
"time"
"github.com/tolelom/catacombs/config"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
)
// AdminStats is the JSON response for the /admin endpoint.
type AdminStats struct {
OnlinePlayers int `json:"online_players"`
ActiveRooms int `json:"active_rooms"`
TodayRuns int `json:"today_runs"`
AvgFloorReach float64 `json:"avg_floor_reached"`
UptimeSeconds int64 `json:"uptime_seconds"`
}
// AdminHandler returns an http.Handler for the /admin stats endpoint.
// It requires Basic Auth using credentials from config.
func AdminHandler(lobby *game.Lobby, db *store.DB, startTime time.Time) http.Handler {
cfg := lobby.Cfg()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !checkAuth(r, cfg.Admin) {
w.Header().Set("WWW-Authenticate", `Basic realm="Catacombs Admin"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
todayRuns, _ := db.GetTodayRunCount()
avgFloor, _ := db.GetTodayAvgFloor()
stats := AdminStats{
OnlinePlayers: len(lobby.ListOnline()),
ActiveRooms: len(lobby.ListRooms()),
TodayRuns: todayRuns,
AvgFloorReach: avgFloor,
UptimeSeconds: int64(time.Since(startTime).Seconds()),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
})
}
func checkAuth(r *http.Request, cfg config.AdminConfig) bool {
username, password, ok := r.BasicAuth()
if !ok {
return false
}
return username == cfg.Username && password == cfg.Password
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `go test ./web/ -run TestAdmin -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add web/admin.go web/admin_test.go
git commit -m "feat: add /admin endpoint with Basic Auth and JSON stats"
```
---
### Task 5: Refactor Web Server for Graceful Shutdown
**Files:**
- Modify: `web/server.go:29-43` (Start function)
The web server currently uses `http.ListenAndServe` which cannot be shut down gracefully. Refactor to return an `*http.Server` that the caller can `Shutdown()`.
- [ ] **Step 1: Refactor web.Start to return *http.Server**
Change `web/server.go` `Start` function:
```go
// Start launches the HTTP server for the web terminal.
// Returns the *http.Server for graceful shutdown control.
func Start(addr string, sshPort int, lobby *game.Lobby, db *store.DB, startTime time.Time) *http.Server {
mux := http.NewServeMux()
// Serve static files from embedded FS
mux.Handle("/", http.FileServer(http.FS(staticFiles)))
// WebSocket endpoint
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
handleWS(w, r, sshPort)
})
// Admin dashboard endpoint
mux.Handle("/admin", AdminHandler(lobby, db, startTime))
srv := &http.Server{Addr: addr, Handler: mux}
go func() {
slog.Info("starting web terminal", "addr", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("web server error", "error", err)
}
}()
return srv
}
```
Add `"time"` to imports. Add `game` and `store` imports:
```go
import (
"embed"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"golang.org/x/crypto/ssh"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
)
```
- [ ] **Step 2: Update main.go to use new Start signature**
In `main.go`, replace the web server goroutine:
```go
// Before:
go func() {
if err := web.Start(webAddr, cfg.Server.SSHPort); err != nil {
slog.Error("web server error", "error", err)
}
}()
// After:
startTime := time.Now()
webServer := web.Start(webAddr, cfg.Server.SSHPort, lobby, db, startTime)
_ = webServer // used later for graceful shutdown
```
Add `"time"` to main.go imports.
- [ ] **Step 3: Run build to verify compilation**
Run: `go build ./...`
Expected: SUCCESS
- [ ] **Step 4: Commit**
```bash
git add web/server.go main.go
git commit -m "refactor: web server returns *http.Server for shutdown control"
```
---
### Task 6: Refactor SSH Server for Graceful Shutdown
**Files:**
- Modify: `server/ssh.go:17-53`
Return the `*ssh.Server` so the caller can shut it down.
- [ ] **Step 1: Refactor server.Start to return wish Server**
Change `server/ssh.go`:
```go
import (
"fmt"
"log/slog"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/bubbletea"
tea "github.com/charmbracelet/bubbletea"
gossh "golang.org/x/crypto/ssh"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
"github.com/tolelom/catacombs/ui"
)
// NewServer creates the SSH server but does not start it.
// The caller is responsible for calling ListenAndServe() and Shutdown().
func NewServer(addr string, lobby *game.Lobby, db *store.DB) (*ssh.Server, error) {
s, err := wish.NewServer(
wish.WithAddress(addr),
wish.WithHostKeyPath(".ssh/catacombs_host_key"),
wish.WithPublicKeyAuth(func(_ ssh.Context, _ ssh.PublicKey) bool {
return true
}),
wish.WithPasswordAuth(func(_ ssh.Context, _ string) bool {
return true
}),
wish.WithMiddleware(
bubbletea.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pty, _, _ := s.Pty()
fingerprint := ""
if s.PublicKey() != nil {
fingerprint = gossh.FingerprintSHA256(s.PublicKey())
}
defer func() {
if r := recover(); r != nil {
slog.Error("session panic recovered", "error", r, "fingerprint", fingerprint)
}
}()
slog.Info("new SSH session", "fingerprint", fingerprint, "width", pty.Window.Width, "height", pty.Window.Height)
m := ui.NewModel(pty.Window.Width, pty.Window.Height, fingerprint, lobby, db)
return m, []tea.ProgramOption{tea.WithAltScreen()}
}),
),
)
if err != nil {
return nil, fmt.Errorf("could not create server: %w", err)
}
return s, nil
}
```
Keep the old `Start` function for backwards compatibility during transition:
```go
// Start creates and starts the SSH server (blocking).
func Start(addr string, lobby *game.Lobby, db *store.DB) error {
s, err := NewServer(addr, lobby, db)
if err != nil {
return err
}
slog.Info("starting SSH server", "addr", addr)
return s.ListenAndServe()
}
```
- [ ] **Step 2: Run build to verify compilation**
Run: `go build ./...`
Expected: SUCCESS (Start still works, NewServer is additive)
- [ ] **Step 3: Commit**
```bash
git add server/ssh.go
git commit -m "refactor: extract NewServer for SSH shutdown control"
```
---
### Task 7: Graceful Shutdown + Backup Scheduler in main.go
**Files:**
- Modify: `main.go`
This is the orchestration task: signal handling, backup scheduler, and graceful shutdown.
- [ ] **Step 1: Rewrite main.go with full orchestration**
```go
package main
import (
"context"
"fmt"
"log"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/tolelom/catacombs/config"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/server"
"github.com/tolelom/catacombs/store"
"github.com/tolelom/catacombs/web"
)
func main() {
os.MkdirAll("data", 0755)
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
cfg, err := config.Load("config.yaml")
if err != nil {
if os.IsNotExist(err) {
cfg, _ = config.Load("")
} else {
log.Fatalf("Failed to load config: %v", err)
}
}
db, err := store.Open("data/catacombs.db")
if err != nil {
log.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
lobby := game.NewLobby(cfg)
startTime := time.Now()
sshAddr := fmt.Sprintf("0.0.0.0:%d", cfg.Server.SSHPort)
webAddr := fmt.Sprintf(":%d", cfg.Server.HTTPPort)
// Start web server (non-blocking, returns *http.Server)
webServer := web.Start(webAddr, cfg.Server.SSHPort, lobby, db, startTime)
// Create SSH server
sshServer, err := server.NewServer(sshAddr, lobby, db)
if err != nil {
log.Fatalf("Failed to create SSH server: %v", err)
}
// Start backup scheduler
backupDone := make(chan struct{})
go backupScheduler(db, cfg.Backup, backupDone)
// Start SSH server in background
sshErrCh := make(chan error, 1)
go func() {
slog.Info("starting SSH server", "addr", sshAddr)
sshErrCh <- sshServer.ListenAndServe()
}()
slog.Info("server starting", "ssh_port", cfg.Server.SSHPort, "http_port", cfg.Server.HTTPPort)
// Wait for shutdown signal or SSH server error
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-sigCh:
slog.Info("shutdown signal received", "signal", sig)
case err := <-sshErrCh:
if err != nil {
slog.Error("SSH server error", "error", err)
}
}
// Graceful shutdown
slog.Info("starting graceful shutdown")
// Stop backup scheduler
close(backupDone)
// Shutdown web server (5s timeout)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := webServer.Shutdown(ctx); err != nil {
slog.Error("web server shutdown error", "error", err)
}
// Shutdown SSH server (10s timeout for active sessions to finish)
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel2()
if err := sshServer.Shutdown(ctx2); err != nil {
slog.Error("SSH server shutdown error", "error", err)
}
// Final backup before exit
if path, err := db.Backup(cfg.Backup.Dir); err != nil {
slog.Error("final backup failed", "error", err)
} else {
slog.Info("final backup completed", "path", path)
}
slog.Info("server stopped")
}
func backupScheduler(db *store.DB, cfg config.BackupConfig, done chan struct{}) {
if cfg.IntervalMin <= 0 {
return
}
ticker := time.NewTicker(time.Duration(cfg.IntervalMin) * time.Minute)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
if path, err := db.Backup(cfg.Dir); err != nil {
slog.Error("scheduled backup failed", "error", err)
} else {
slog.Info("scheduled backup completed", "path", path)
}
}
}
}
```
Note: `syscall.SIGTERM` works on Windows as a no-op but `SIGINT` (Ctrl+C) works. On Linux both work. This is acceptable.
- [ ] **Step 2: Run build to verify compilation**
Run: `go build ./...`
Expected: SUCCESS
- [ ] **Step 3: Run all tests**
Run: `go test ./...`
Expected: ALL PASS
- [ ] **Step 4: Commit**
```bash
git add main.go
git commit -m "feat: graceful shutdown with signal handling and backup scheduler"
```
---
### Task 8: Integration Verification
**Files:** None (verification only)
- [ ] **Step 1: Run all tests**
Run: `go test ./... -v`
Expected: ALL PASS
- [ ] **Step 2: Run vet**
Run: `go vet ./...`
Expected: No issues
- [ ] **Step 3: Build binary**
Run: `go build -o catacombs .`
Expected: Binary builds successfully
- [ ] **Step 4: Verify admin endpoint manually (optional)**
Start the server and test:
```bash
curl -u admin:catacombs http://localhost:8080/admin
```
Expected: JSON response with `online_players`, `active_rooms`, `today_runs`, `avg_floor_reached`, `uptime_seconds`
- [ ] **Step 5: Final commit if any fixes needed**
Only if previous steps revealed issues that were fixed.

View File

@@ -0,0 +1,358 @@
# Catacombs Game Enhancement Design
## Overview
Catacombs 멀티플레이 로그라이크 던전 크롤러의 종합 고도화 설계.
실제 유저 운영(소규모 10~30명 커뮤니티) 목표로, 재미/리텐션/안정성 중심.
접근 방식: **기반 정비 → 콘텐츠 → 리텐션 → 운영** (접근 A)
## Current State
- Go + Bubble Tea TUI, SSH(:2222) + WebSocket(:8080) 듀얼 접속
- 4 클래스(Warrior/Mage/Healer/Rogue), 8 몬스터 + 4 보스, 20층 던전
- 턴제 전투(5초 타임아웃), 상점, 업적 10개, 리더보드
- BoltDB 영속화, SSH 핑거프린트 재접속
- 인게임 채팅 구현됨 (`/` 키로 입력, `SendChat()` 브로드캐스트)
- 상태이상: 독, 화상, 빙결 이미 존재
- `ui/model.go` ~712라인 (라우팅/업데이트 디스패치), 화면별 `*_view.go`로 분리 완료
- 기존 협동 보너스: 2인 이상 같은 타겟 공격 시 +10% 대미지
## Phase 1: Foundation + Structured Logging
### 1-1. UI Architecture Refinement
**Problem:** `model.go` (712라인)의 `Update()` 메서드가 모든 화면의 키 입력을 하나의 switch문에서 처리. 새 화면/기능 추가 시 분기가 복잡해짐.
**Design:**
- 각 화면을 독립적인 Bubble Tea `Model` 인터페이스로 추출 (`LobbyModel`, `GameModel`, `ShopModel` 등)
-`*_view.go`에 해당 화면의 `Update()`/`View()` 로직을 완전히 위임
- 메인 `Model`은 화면 전환 라우팅만 담당
- 공유 상태(게임 세션, DB, 뷰포트 크기 등)는 공통 `Context` 구조체로 추출
**Success criteria:** `model.go``Update()` 메서드가 화면별 라우팅만 수행 (각 화면 로직 0라인).
### 1-2. Chat Emote System
**Note:** 기본 채팅은 이미 구현됨 (`game/session.go:SendChat`, `/` 키 입력).
**Design:**
- 이모트 프리셋 시스템 추가 (`/hi`, `/gg`, `/go`, `/wait`, `/help` 등)
- 이모트는 채팅 로그에 강조 스타일로 표시
- `game/emote.go` — 이모트 정의 및 파싱
### 1-3. Structured Logging
**Rationale:** Phase 2~3 디버깅을 위해 초기에 도입.
**Design:**
- `log/slog` (Go 표준) 도입, JSON 형식 구조화 로깅
- 이벤트: 접속, 게임 시작, 전투, 종료, 에러
- 패닉 리커버리 미들웨어 — 세션 크래시 격리
### 1-4. Configuration Externalization
**Rationale:** Phase 2~3에서 밸런스 상수를 추가하기 전에 설정 구조를 먼저 도입.
**Design:**
- `config/config.go` — YAML 파싱 및 기본값 관리
- `config.yaml` — 서버 포트, 턴 타임아웃, 몬스터 스케일링, 상점 가격 배율 등
```go
type Config struct {
Server ServerConfig `yaml:"server"`
Game GameConfig `yaml:"game"`
Combat CombatConfig `yaml:"combat"`
Dungeon DungeonConfig `yaml:"dungeon"`
Backup BackupConfig `yaml:"backup"`
}
type GameConfig struct {
TurnTimeout time.Duration `yaml:"turn_timeout"` // default: 5s
MaxPlayers int `yaml:"max_players"` // default: 4
MaxFloors int `yaml:"max_floors"` // default: 20
CoopBonus float64 `yaml:"coop_bonus"` // default: 0.10
InventoryLimit int `yaml:"inventory_limit"` // default: 10
}
```
**Affected packages:** 전체 (상수 참조 부분), `server/`, `game/`
## Phase 2: Combat & Dungeon Enhancement
### 2-1. Combat System Expansion
**Skill Tree:**
- 클래스별 2갈래 특성 트리 (MVP, 추후 3갈래 확장 가능)
- Warrior: 탱커 / 버서커
- Mage: 원소술사 / 시간술사
- Healer: 수호자 / 사제
- Rogue: 암살자 / 약사
- 층 클리어 시 1포인트 획득, 브랜치당 3노드, 런 내에서만 유효
- 스킬 포인트 배분은 층 이동 화면에서 수행 (턴 동기화 불필요)
```go
// entity/skill_tree.go
type SkillBranch struct {
Name string
Nodes [3]SkillNode // 3 sequential unlocks per branch
Description string
}
type SkillNode struct {
Name string
Effect SkillEffect // enum: ATKBoost, DEFBoost, SkillPower, etc.
Value float64 // modifier amount
Required int // points spent in branch to unlock
}
type PlayerSkills struct {
BranchIndex int // 0 or 1 (chosen branch)
Points int // total points earned
Allocated int // points allocated in chosen branch
}
```
**Combo Skills:**
- 2인 이상이 같은 턴에 특정 조합 사용 시 연계 효과 발동
- 기존 협동 보너스(+10%)와 별개로 스택됨
- 예: Mage 빙결 + Warrior 강타 = 빙쇄 (대미지 1.5배 + 빙결 해제)
```go
// combat/combo.go
type ComboDefinition struct {
RequiredActions []ComboAction // class + action type pairs
Effect ComboEffect
Name string
Description string
}
type ComboAction struct {
Class entity.Class
ActionType string // "skill", "attack"
SkillName string // optional: specific skill required
}
```
**Elite Monsters:**
- 일반 몬스터의 강화 변종, 층당 ~20% 확률로 등장
- 접두사별 스탯 변형 및 특수 능력
```go
// entity/elite.go
type ElitePrefix struct {
Name string // "맹독의", "불타는", "흡혈의"
StatMod StatModifier // HP/ATK/DEF multipliers
OnHit StatusEffect // applied on monster's attack
DropBonus float64 // extra loot chance
}
```
**Status Effects Expansion:**
- 기존: 독, 화상, 빙결
- 추가: 출혈(턴마다 대미지 누적 +1), 저주(회복량 50% 감소)
- `entity/player.go``StatusBleed`, `StatusCurse` 추가
**Success criteria:** 8개 스킬 브랜치, 5개 이상 콤보 조합, 5개 엘리트 접두사, 5개 상태이상.
### 2-2. Dungeon Event Diversification
**Random Event Rooms:**
- 선택지 기반 이벤트 (예: "수상한 제단 발견" → 제물 바치기/무시/파괴)
- 최소 8개 이벤트 풀
```go
// game/random_event.go
type RandomEvent struct {
ID string
Description string
Choices []EventChoice
}
type EventChoice struct {
Text string
Outcome EventOutcome // reward/penalty/mixed
Weight float64 // probability weight
}
```
**Secret Rooms:**
- 낮은 확률(~10%)로 생성, `dungeon/room.go``RoomSecret` 타입 추가
- 희귀 아이템/렐릭 보상
**Floor Themes:**
- 5층 단위로 환경 효과, 보스 패턴과 테마 정렬:
- 1~5층 습지 (독 강화) → Guardian 보스 (독 패턴으로 변경)
- 6~10층 화산 (화상 강화) → Warden 보스 (화상 패턴으로 변경)
- 11~15층 얼음 (빙결 강화) → Overlord 보스 (빙결 패턴)
- 16~20층 지옥 (전체 강화) → Archlich 최종 보스 (AoE + 회복 패턴, 기존 Guardian의 AoE를 최종 보스로 이동)
```go
// dungeon/theme.go
type FloorTheme struct {
Name string
StatusBoost entity.StatusEffect // which status is empowered
DamageModifier float64 // multiplier for boosted status
AsciiStyle string // visual theme for rendering
}
```
**Mini-boss Rooms:**
- 보스 층 직전(4, 9, 14, 19층)에 미니보스 등장
- 기존 `Monster` 구조체에 `IsMiniBoss bool` 추가, `BossPattern` 사용하되 HP/ATK는 보스의 60%
**Prerequisite for Phase 3-1:** 던전 생성을 시드 기반으로 리팩토링.
- `GenerateFloor(floor int)``GenerateFloor(floor int, rng *rand.Rand)`
- 모든 랜덤 호출을 `rng` 인스턴스로 교체
**Success criteria:** 8개 이상 랜덤 이벤트, 4개 층 테마, 비밀 방, 4개 미니보스.
**Affected packages:** `dungeon/`, `game/`, `entity/`, `combat/`
## Phase 3: Retention Systems
### 3-1. Daily Challenge
- 날짜 기반 시드(`time.Now().Format("2006-01-02")` → hash → seed)로 동일 던전 생성
- Phase 2-2에서 리팩토링된 시드 기반 생성기 활용
- 일일 전용 리더보드
```go
// store/daily.go (BoltDB bucket: "daily_runs")
// Key: "YYYY-MM-DD:playerFingerprint"
type DailyRecord struct {
Date string `json:"date"`
Player string `json:"player"`
FloorReached int `json:"floor_reached"`
GoldEarned int `json:"gold_earned"`
Streak int `json:"streak"` // consecutive days played
}
```
**Success criteria:** 동일 날짜에 같은 시드 던전 보장, 일일 리더보드 표시.
### 3-2. Meta Progression
**Unlock System:**
```go
// store/unlocks.go (BoltDB bucket: "unlocks")
// Key: "playerFingerprint:unlockID"
type Unlock struct {
ID string `json:"id"`
Condition string `json:"condition"` // human-readable
Unlocked bool `json:"unlocked"`
}
```
언락 콘텐츠:
- "10층 이상 클리어" → 5번째 클래스 해금
- "3인 이상 클리어" → 하드 모드 해금
- "20층 클리어" → 주간 변이 모드 해금
**Player Titles:**
```go
// store/titles.go (BoltDB bucket: "titles")
// Key: "playerFingerprint"
type PlayerTitle struct {
ActiveTitle string `json:"active_title"`
Earned []string `json:"earned"` // list of title IDs
}
```
**Codex System:**
```go
// store/codex.go (BoltDB bucket: "codex")
// Key: "playerFingerprint"
type Codex struct {
Monsters map[string]bool `json:"monsters"` // monsterID → encountered
Items map[string]bool `json:"items"` // itemID → acquired
Events map[string]bool `json:"events"` // eventID → discovered
}
```
- `ui/codex_view.go` — 도감 화면, 완성률 표시
**Success criteria:** 3개 이상 언락 콘텐츠, 5개 이상 칭호, 도감 완성률 추적.
### 3-3. Difficulty System
**Hard Mode:**
- 난이도 배율은 `config.yaml`에서 관리 (Phase 1-4의 설정 구조 활용)
- 몬스터 스탯 1.5배, 상점 가격 2배, 회복량 절반
- 언락 조건 충족 시 로비에서 선택 가능
**Weekly Mutations:**
- 매주 바뀌는 특수 규칙, 주 번호 기반 시드로 결정
```go
// game/mutation.go
type Mutation struct {
ID string
Name string // "스킬 봉인", "엘리트 범람", "상점 폐쇄" 등
Description string
Apply func(cfg *config.GameConfig) // config 값 오버라이드
}
```
**Affected packages:** `game/`, `store/`, `ui/`, `config/`
## Phase 4: Operational Stability
### 4-1. Admin Dashboard
- `/admin` HTTP 엔드포인트 (Basic Auth 인증)
- JSON 응답 형식:
```go
type AdminStats struct {
OnlinePlayers int `json:"online_players"`
ActiveRooms int `json:"active_rooms"`
TodayRuns int `json:"today_runs"`
AvgFloorReach float64 `json:"avg_floor_reached"`
Uptime time.Duration `json:"uptime"`
}
```
### 4-2. Data Safety
- 설정 가능한 주기로 DB 파일 자동 백업 (`./data/backup/`, `config.yaml``backup.interval`)
- 그레이스풀 셧다운: SIGTERM 시 진행 중인 세션 저장 후 종료
- 재시작 시 미완료 세션 정리
**Affected packages:** `store/`, `web/`, `main.go`
## Data Migration
기존 BoltDB 데이터와의 호환성:
- 새 버킷(`daily_runs`, `unlocks`, `titles`, `codex`)은 `CreateBucketIfNotExists`로 안전하게 추가
- 기존 `profiles`, `rankings`, `achievements` 버킷은 구조 변경 없음
- 업적 시스템과 언락 시스템은 별도 버킷으로 독립 운영 (기존 업적 데이터 보존)
## Testing Strategy
- **단위 테스트:** 새 패키지별 `*_test.go` (스킬 트리 포인트 계산, 콤보 판정, 엘리트 스탯 변형)
- **시드 결정성 테스트:** 동일 시드 → 동일 던전 보장 검증
- **밸런스 시뮬레이션:** 엘리트/미니보스 스탯 범위가 솔로/파티 모두에서 적정한지 수치 검증
- **통합 테스트:** 턴 실행 시 콤보 판정 → 대미지 계산 → 상태이상 적용 흐름
## Dependencies Between Phases
```
Phase 1 (Foundation: UI정리 + 로깅 + 설정 외부화)
├── Phase 2 (Combat/Dungeon) — 설정 구조 위에 밸런스 상수 추가
│ └── Phase 3 (Retention) — 시드 기반 생성기, 콘텐츠 위에 메타 시스템
└── Phase 4 (Operations) — 로깅 기반 위에 어드민/백업 추가
```
Phase 4는 Phase 1 완료 후 독립 진행 가능 (Phase 2~3과 병행).
## Out of Scope
- 대규모(100+) 스케일링 (분산 서버, 로드밸런싱)
- 모바일/데스크톱 네이티브 클라이언트
- 유료 결제/과금 시스템
- PvP 대전 모드
- 외부 DB 마이그레이션 (BoltDB 유지)

View File

@@ -19,7 +19,7 @@ type bspNode struct {
roomIdx int
}
func GenerateFloor(floorNum int) *Floor {
func GenerateFloor(floorNum int, rng *rand.Rand) *Floor {
// Create tile map filled with walls
tiles := make([][]Tile, MapHeight)
for y := 0; y < MapHeight; y++ {
@@ -29,21 +29,21 @@ func GenerateFloor(floorNum int) *Floor {
// BSP tree
root := &bspNode{x: 0, y: 0, w: MapWidth, h: MapHeight}
splitBSP(root, 0)
splitBSP(root, 0, rng)
// Collect leaf nodes
var leaves []*bspNode
collectLeaves(root, &leaves)
// Shuffle leaves so room assignment is varied
rand.Shuffle(len(leaves), func(i, j int) {
rng.Shuffle(len(leaves), func(i, j int) {
leaves[i], leaves[j] = leaves[j], leaves[i]
})
// We want 5-8 rooms. If we have more leaves, merge some; if fewer, accept it.
// Ensure at least 5 leaves by re-generating if needed (BSP should produce enough).
// Cap at 8 rooms.
targetRooms := 5 + rand.Intn(4) // 5..8
targetRooms := 5 + rng.Intn(4) // 5..8
if len(leaves) > targetRooms {
leaves = leaves[:targetRooms]
}
@@ -64,21 +64,21 @@ func GenerateFloor(floorNum int) *Floor {
rw := MinRoomW
if maxW > MinRoomW {
rw = MinRoomW + rand.Intn(maxW-MinRoomW+1)
rw = MinRoomW + rng.Intn(maxW-MinRoomW+1)
}
rh := MinRoomH
if maxH > MinRoomH {
rh = MinRoomH + rand.Intn(maxH-MinRoomH+1)
rh = MinRoomH + rng.Intn(maxH-MinRoomH+1)
}
// Position room within the leaf
rx := leaf.x + RoomPad
if leaf.w-2*RoomPad > rw {
rx += rand.Intn(leaf.w - 2*RoomPad - rw + 1)
rx += rng.Intn(leaf.w - 2*RoomPad - rw + 1)
}
ry := leaf.y + RoomPad
if leaf.h-2*RoomPad > rh {
ry += rand.Intn(leaf.h - 2*RoomPad - rh + 1)
ry += rng.Intn(leaf.h - 2*RoomPad - rh + 1)
}
// Clamp to map bounds
@@ -95,7 +95,7 @@ func GenerateFloor(floorNum int) *Floor {
ry = 1
}
rt := RandomRoomType()
rt := RandomRoomType(rng)
rooms[i] = &Room{
Type: rt,
X: rx,
@@ -111,6 +111,14 @@ func GenerateFloor(floorNum int) *Floor {
// Last room is boss
rooms[len(rooms)-1].Type = RoomBoss
// On floors 4, 9, 14, 19: assign one room as mini-boss
if floorNum == 4 || floorNum == 9 || floorNum == 14 || floorNum == 19 {
// Pick the second room (index 1), or any non-first non-last room
if len(rooms) > 2 {
rooms[1].Type = RoomMiniBoss
}
}
// Carve rooms into tile map
for _, room := range rooms {
for dy := 0; dy < room.H; dy++ {
@@ -132,10 +140,10 @@ func GenerateFloor(floorNum int) *Floor {
}
// Add 1-2 extra connections
extras := 1 + rand.Intn(2)
extras := 1 + rng.Intn(2)
for e := 0; e < extras; e++ {
a := rand.Intn(len(rooms))
b := rand.Intn(len(rooms))
a := rng.Intn(len(rooms))
b := rng.Intn(len(rooms))
if a != b && !hasNeighbor(rooms[a], b) {
rooms[a].Neighbors = append(rooms[a].Neighbors, b)
rooms[b].Neighbors = append(rooms[b].Neighbors, a)
@@ -153,7 +161,7 @@ func GenerateFloor(floorNum int) *Floor {
}
}
func splitBSP(node *bspNode, depth int) {
func splitBSP(node *bspNode, depth int, rng *rand.Rand) {
// Stop conditions
if depth > 4 {
return
@@ -163,12 +171,12 @@ func splitBSP(node *bspNode, depth int) {
}
// Random chance to stop splitting (more likely at deeper levels)
if depth > 2 && rand.Float64() < 0.3 {
if depth > 2 && rng.Float64() < 0.3 {
return
}
// Decide split direction
horizontal := rand.Float64() < 0.5
horizontal := rng.Float64() < 0.5
if node.w < MinLeafW*2 {
horizontal = true
}
@@ -180,20 +188,20 @@ func splitBSP(node *bspNode, depth int) {
if node.h < MinLeafH*2 {
return
}
split := MinLeafH + rand.Intn(node.h-MinLeafH*2+1)
split := MinLeafH + rng.Intn(node.h-MinLeafH*2+1)
node.left = &bspNode{x: node.x, y: node.y, w: node.w, h: split}
node.right = &bspNode{x: node.x, y: node.y + split, w: node.w, h: node.h - split}
} else {
if node.w < MinLeafW*2 {
return
}
split := MinLeafW + rand.Intn(node.w-MinLeafW*2+1)
split := MinLeafW + rng.Intn(node.w-MinLeafW*2+1)
node.left = &bspNode{x: node.x, y: node.y, w: split, h: node.h}
node.right = &bspNode{x: node.x + split, y: node.y, w: node.w - split, h: node.h}
}
splitBSP(node.left, depth+1)
splitBSP(node.right, depth+1)
splitBSP(node.left, depth+1, rng)
splitBSP(node.right, depth+1, rng)
}
func collectLeaves(node *bspNode, leaves *[]*bspNode) {

View File

@@ -1,9 +1,16 @@
package dungeon
import "testing"
import (
"math/rand"
"testing"
)
func newTestRng() *rand.Rand {
return rand.New(rand.NewSource(rand.Int63()))
}
func TestGenerateFloor(t *testing.T) {
floor := GenerateFloor(1)
floor := GenerateFloor(1, newTestRng())
if len(floor.Rooms) < 5 || len(floor.Rooms) > 8 {
t.Errorf("Room count: got %d, want 5~8", len(floor.Rooms))
}
@@ -32,17 +39,61 @@ func TestGenerateFloor(t *testing.T) {
func TestRoomTypeProbability(t *testing.T) {
counts := make(map[RoomType]int)
n := 10000
rng := rand.New(rand.NewSource(12345))
for i := 0; i < n; i++ {
counts[RandomRoomType()]++
counts[RandomRoomType(rng)]++
}
combatPct := float64(counts[RoomCombat]) / float64(n) * 100
if combatPct < 40 || combatPct > 50 {
t.Errorf("Combat room probability: got %.1f%%, want ~45%%", combatPct)
t.Errorf("Combat room probability: got %.1f%%, want ~45%% (range 5-50)", combatPct)
}
}
func TestSecretRoomInRandomType(t *testing.T) {
counts := make(map[RoomType]int)
n := 10000
rng := rand.New(rand.NewSource(12345))
for i := 0; i < n; i++ {
counts[RandomRoomType(rng)]++
}
secretPct := float64(counts[RoomSecret]) / float64(n) * 100
if secretPct < 2 || secretPct > 8 {
t.Errorf("Secret room probability: got %.1f%%, want ~5%%", secretPct)
}
// Verify RoomMiniBoss is never returned by RandomRoomType
if counts[RoomMiniBoss] > 0 {
t.Errorf("MiniBoss rooms should not be generated randomly, got %d", counts[RoomMiniBoss])
}
}
func TestMiniBossRoomPlacement(t *testing.T) {
for _, floorNum := range []int{4, 9, 14, 19} {
floor := GenerateFloor(floorNum, newTestRng())
found := false
for _, r := range floor.Rooms {
if r.Type == RoomMiniBoss {
found = true
break
}
}
if !found {
t.Errorf("Floor %d should have a mini-boss room", floorNum)
}
}
// Non-miniboss floors should not have mini-boss rooms
for _, floorNum := range []int{1, 3, 5, 10} {
floor := GenerateFloor(floorNum, newTestRng())
for _, r := range floor.Rooms {
if r.Type == RoomMiniBoss {
t.Errorf("Floor %d should not have a mini-boss room", floorNum)
break
}
}
}
}
func TestFloorHasTileMap(t *testing.T) {
floor := GenerateFloor(1)
floor := GenerateFloor(1, newTestRng())
if floor.Tiles == nil {
t.Fatal("Floor should have tile map")
}
@@ -56,3 +107,27 @@ func TestFloorHasTileMap(t *testing.T) {
t.Errorf("Room center should be floor tile, got %d", centerTile)
}
}
func TestDeterministicGeneration(t *testing.T) {
rng1 := rand.New(rand.NewSource(42))
rng2 := rand.New(rand.NewSource(42))
f1 := GenerateFloor(5, rng1)
f2 := GenerateFloor(5, rng2)
if len(f1.Rooms) != len(f2.Rooms) {
t.Fatalf("room counts differ: %d vs %d", len(f1.Rooms), len(f2.Rooms))
}
for i, r := range f1.Rooms {
if r.Type != f2.Rooms[i].Type || r.X != f2.Rooms[i].X || r.Y != f2.Rooms[i].Y {
t.Errorf("room %d differs: type=%v/%v x=%d/%d y=%d/%d",
i, r.Type, f2.Rooms[i].Type, r.X, f2.Rooms[i].X, r.Y, f2.Rooms[i].Y)
}
}
// Also verify tile maps match
for y := 0; y < f1.Height; y++ {
for x := 0; x < f1.Width; x++ {
if f1.Tiles[y][x] != f2.Tiles[y][x] {
t.Errorf("tile at (%d,%d) differs: %d vs %d", x, y, f1.Tiles[y][x], f2.Tiles[y][x])
}
}
}
}

View File

@@ -11,10 +11,12 @@ const (
RoomEvent
RoomEmpty
RoomBoss
RoomSecret
RoomMiniBoss
)
func (r RoomType) String() string {
return [...]string{"Combat", "Treasure", "Shop", "Event", "Empty", "Boss"}[r]
return [...]string{"Combat", "Treasure", "Shop", "Event", "Empty", "Boss", "Secret", "MiniBoss"}[r]
}
type Tile int
@@ -44,16 +46,18 @@ type Floor struct {
Height int
}
func RandomRoomType() RoomType {
r := rand.Float64() * 100
func RandomRoomType(rng *rand.Rand) RoomType {
r := rng.Float64() * 100
switch {
case r < 45:
case r < 5:
return RoomSecret
case r < 50:
return RoomCombat
case r < 60:
case r < 65:
return RoomTreasure
case r < 70:
case r < 75:
return RoomShop
case r < 85:
case r < 90:
return RoomEvent
default:
return RoomEmpty

32
dungeon/theme.go Normal file
View File

@@ -0,0 +1,32 @@
package dungeon
import "github.com/tolelom/catacombs/entity"
// ThemeModifier defines gameplay modifiers for a range of floors.
type ThemeModifier struct {
Name string
StatusBoost entity.StatusEffect // which status is boosted (-1 = all for Inferno)
DamageMult float64
Description string
}
var themeModifiers = []ThemeModifier{
{"Swamp", entity.StatusPoison, 1.5, "Toxic marshes amplify poison"},
{"Volcano", entity.StatusBurn, 1.5, "Volcanic heat intensifies burns"},
{"Glacier", entity.StatusFreeze, 1.5, "Glacial cold strengthens frost"},
{"Inferno", entity.StatusEffect(-1), 1.3, "Hellfire empowers all afflictions"},
}
// GetTheme returns the theme modifier for the given floor number.
func GetTheme(floor int) ThemeModifier {
switch {
case floor <= 5:
return themeModifiers[0]
case floor <= 10:
return themeModifiers[1]
case floor <= 15:
return themeModifiers[2]
default:
return themeModifiers[3]
}
}

32
dungeon/theme_test.go Normal file
View File

@@ -0,0 +1,32 @@
package dungeon
import "testing"
func TestGetTheme(t *testing.T) {
tests := []struct {
floor int
name string
}{
{1, "Swamp"}, {5, "Swamp"},
{6, "Volcano"}, {10, "Volcano"},
{11, "Glacier"}, {15, "Glacier"},
{16, "Inferno"}, {20, "Inferno"},
}
for _, tt := range tests {
theme := GetTheme(tt.floor)
if theme.Name != tt.name {
t.Errorf("floor %d: expected %q, got %q", tt.floor, tt.name, theme.Name)
}
}
}
func TestThemeDamageMult(t *testing.T) {
theme := GetTheme(1) // Swamp
if theme.DamageMult != 1.5 {
t.Errorf("Swamp DamageMult: expected 1.5, got %f", theme.DamageMult)
}
theme = GetTheme(20) // Inferno
if theme.DamageMult != 1.3 {
t.Errorf("Inferno DamageMult: expected 1.3, got %f", theme.DamageMult)
}
}

47
entity/elite.go Normal file
View File

@@ -0,0 +1,47 @@
package entity
import "math/rand"
type ElitePrefixType int
const (
PrefixVenomous ElitePrefixType = iota // poison on hit
PrefixBurning // burn on hit
PrefixFreezing // freeze on hit
PrefixBleeding // bleed on hit
PrefixVampiric // heals self on hit
)
type ElitePrefixDef struct {
Name string
HPMult float64
ATKMult float64
OnHit StatusEffect // -1 for vampiric (special)
}
// ElitePrefixDefs is exported so game/turn.go can access for on-hit effects.
var ElitePrefixDefs = map[ElitePrefixType]ElitePrefixDef{
PrefixVenomous: {"Venomous", 1.3, 1.0, StatusPoison},
PrefixBurning: {"Burning", 1.2, 1.1, StatusBurn},
PrefixFreezing: {"Freezing", 1.2, 1.0, StatusFreeze},
PrefixBleeding: {"Bleeding", 1.1, 1.2, StatusBleed},
PrefixVampiric: {"Vampiric", 1.4, 1.1, StatusEffect(-1)},
}
func (p ElitePrefixType) String() string {
return ElitePrefixDefs[p].Name
}
func RandomPrefix() ElitePrefixType {
return ElitePrefixType(rand.Intn(5))
}
func ApplyPrefix(m *Monster, prefix ElitePrefixType) {
def := ElitePrefixDefs[prefix]
m.IsElite = true
m.ElitePrefix = prefix
m.Name = def.Name + " " + m.Name
m.HP = int(float64(m.HP) * def.HPMult)
m.MaxHP = m.HP
m.ATK = int(float64(m.ATK) * def.ATKMult)
}

94
entity/elite_test.go Normal file
View File

@@ -0,0 +1,94 @@
package entity
import "testing"
func TestApplyPrefix(t *testing.T) {
m := &Monster{
Name: "Slime",
HP: 100,
MaxHP: 100,
ATK: 10,
DEF: 5,
}
ApplyPrefix(m, PrefixVenomous)
if !m.IsElite {
t.Fatal("expected IsElite to be true")
}
if m.ElitePrefix != PrefixVenomous {
t.Fatalf("expected PrefixVenomous, got %d", m.ElitePrefix)
}
if m.Name != "Venomous Slime" {
t.Fatalf("expected 'Venomous Slime', got %q", m.Name)
}
// HP should be multiplied by 1.3 => 130
if m.HP != 130 {
t.Fatalf("expected HP=130, got %d", m.HP)
}
if m.MaxHP != 130 {
t.Fatalf("expected MaxHP=130, got %d", m.MaxHP)
}
// ATK mult is 1.0 for Venomous, so ATK stays 10
if m.ATK != 10 {
t.Fatalf("expected ATK=10, got %d", m.ATK)
}
}
func TestApplyPrefixVampiric(t *testing.T) {
m := &Monster{
Name: "Orc",
HP: 100,
MaxHP: 100,
ATK: 20,
DEF: 5,
}
ApplyPrefix(m, PrefixVampiric)
if !m.IsElite {
t.Fatal("expected IsElite to be true")
}
if m.Name != "Vampiric Orc" {
t.Fatalf("expected 'Vampiric Orc', got %q", m.Name)
}
// HP * 1.4 = 140
if m.HP != 140 {
t.Fatalf("expected HP=140, got %d", m.HP)
}
// ATK * 1.1 = 22
if m.ATK != 22 {
t.Fatalf("expected ATK=22, got %d", m.ATK)
}
}
func TestRandomPrefix(t *testing.T) {
seen := make(map[ElitePrefixType]bool)
for i := 0; i < 200; i++ {
p := RandomPrefix()
if p < 0 || p > 4 {
t.Fatalf("RandomPrefix returned out-of-range value: %d", p)
}
seen[p] = true
}
// With 200 tries, all 5 prefixes should appear
if len(seen) != 5 {
t.Fatalf("expected all 5 prefixes to appear, got %d distinct values", len(seen))
}
}
func TestElitePrefixString(t *testing.T) {
if PrefixVenomous.String() != "Venomous" {
t.Fatalf("expected 'Venomous', got %q", PrefixVenomous.String())
}
if PrefixBurning.String() != "Burning" {
t.Fatalf("expected 'Burning', got %q", PrefixBurning.String())
}
if PrefixFreezing.String() != "Freezing" {
t.Fatalf("expected 'Freezing', got %q", PrefixFreezing.String())
}
if PrefixBleeding.String() != "Bleeding" {
t.Fatalf("expected 'Bleeding', got %q", PrefixBleeding.String())
}
if PrefixVampiric.String() != "Vampiric" {
t.Fatalf("expected 'Vampiric', got %q", PrefixVampiric.String())
}
}

View File

@@ -13,6 +13,10 @@ const (
MonsterBoss10
MonsterBoss15
MonsterBoss20
MonsterMiniBoss5
MonsterMiniBoss10
MonsterMiniBoss15
MonsterMiniBoss20
)
type monsterBase struct {
@@ -31,6 +35,10 @@ var monsterDefs = map[MonsterType]monsterBase{
MonsterBoss10: {"Warden", 250, 22, 12, 10, true},
MonsterBoss15: {"Overlord", 400, 30, 16, 15, true},
MonsterBoss20: {"Archlich", 600, 40, 20, 20, true},
MonsterMiniBoss5: {"Guardian's Herald", 90, 9, 5, 4, false},
MonsterMiniBoss10: {"Warden's Shadow", 150, 13, 7, 9, false},
MonsterMiniBoss15: {"Overlord's Lieutenant", 240, 18, 10, 14, false},
MonsterMiniBoss20: {"Archlich's Harbinger", 360, 24, 12, 19, false},
}
type BossPattern int
@@ -41,6 +49,7 @@ const (
PatternPoison // applies poison
PatternBurn // applies burn to random player
PatternHeal // heals self
PatternFreeze // applies freeze to all players
)
type Monster struct {
@@ -49,28 +58,34 @@ type Monster struct {
HP, MaxHP int
ATK, DEF int
IsBoss bool
IsMiniBoss bool
IsElite bool
ElitePrefix ElitePrefixType
TauntTarget bool
TauntTurns int
Pattern BossPattern
}
func NewMonster(mt MonsterType, floor int) *Monster {
func NewMonster(mt MonsterType, floor int, scaling float64) *Monster {
base := monsterDefs[mt]
isMiniBoss := mt == MonsterMiniBoss5 || mt == MonsterMiniBoss10 ||
mt == MonsterMiniBoss15 || mt == MonsterMiniBoss20
scale := 1.0
if !base.IsBoss && floor > base.MinFloor {
scale = math.Pow(1.15, float64(floor-base.MinFloor))
if !base.IsBoss && !isMiniBoss && floor > base.MinFloor {
scale = math.Pow(scaling, float64(floor-base.MinFloor))
}
hp := int(math.Round(float64(base.HP) * scale))
atk := int(math.Round(float64(base.ATK) * scale))
def := int(math.Round(float64(base.DEF) * scale))
return &Monster{
Name: base.Name,
Type: mt,
HP: hp,
MaxHP: hp,
ATK: atk,
DEF: def,
IsBoss: base.IsBoss,
Name: base.Name,
Type: mt,
HP: hp,
MaxHP: hp,
ATK: atk,
DEF: def,
IsBoss: base.IsBoss,
IsMiniBoss: isMiniBoss,
}
}

View File

@@ -6,11 +6,11 @@ import (
)
func TestMonsterScaling(t *testing.T) {
slime := NewMonster(MonsterSlime, 1)
slime := NewMonster(MonsterSlime, 1, 1.15)
if slime.HP != 20 || slime.ATK != 5 {
t.Errorf("Slime floor 1: got HP=%d ATK=%d, want HP=20 ATK=5", slime.HP, slime.ATK)
}
slimeF3 := NewMonster(MonsterSlime, 3)
slimeF3 := NewMonster(MonsterSlime, 3, 1.15)
expectedHP := int(math.Round(20 * math.Pow(1.15, 2)))
if slimeF3.HP != expectedHP {
t.Errorf("Slime floor 3: got HP=%d, want %d", slimeF3.HP, expectedHP)
@@ -18,7 +18,7 @@ func TestMonsterScaling(t *testing.T) {
}
func TestBossStats(t *testing.T) {
boss := NewMonster(MonsterBoss5, 5)
boss := NewMonster(MonsterBoss5, 5, 1.15)
if boss.HP != 150 || boss.ATK != 15 || boss.DEF != 8 {
t.Errorf("Boss5: got HP=%d ATK=%d DEF=%d, want 150/15/8", boss.HP, boss.ATK, boss.DEF)
}
@@ -26,12 +26,12 @@ func TestBossStats(t *testing.T) {
func TestMonsterDEFScaling(t *testing.T) {
// Slime base DEF=1, minFloor=1. At floor 5, scale = 1.15^4 ≈ 1.749
m := NewMonster(MonsterSlime, 5)
m := NewMonster(MonsterSlime, 5, 1.15)
if m.DEF <= 1 {
t.Errorf("Slime DEF at floor 5 should be scaled above base 1, got %d", m.DEF)
}
// Boss DEF should NOT scale
boss := NewMonster(MonsterBoss5, 5)
boss := NewMonster(MonsterBoss5, 5, 1.15)
if boss.DEF != 8 {
t.Errorf("Boss5 DEF should be base 8, got %d", boss.DEF)
}
@@ -49,9 +49,43 @@ func TestTickTaunt(t *testing.T) {
}
}
func TestMiniBossStats(t *testing.T) {
tests := []struct {
mt MonsterType
name string
wantHP, wantATK, wantDEF int
}{
{MonsterMiniBoss5, "Guardian's Herald", 90, 9, 5},
{MonsterMiniBoss10, "Warden's Shadow", 150, 13, 7},
{MonsterMiniBoss15, "Overlord's Lieutenant", 240, 18, 10},
{MonsterMiniBoss20, "Archlich's Harbinger", 360, 24, 12},
}
for _, tc := range tests {
m := NewMonster(tc.mt, tc.wantHP, 1.15) // floor doesn't matter, no scaling
if m.Name != tc.name {
t.Errorf("%v: name got %q, want %q", tc.mt, m.Name, tc.name)
}
if m.HP != tc.wantHP {
t.Errorf("%v: HP got %d, want %d", tc.mt, m.HP, tc.wantHP)
}
if m.ATK != tc.wantATK {
t.Errorf("%v: ATK got %d, want %d", tc.mt, m.ATK, tc.wantATK)
}
if m.DEF != tc.wantDEF {
t.Errorf("%v: DEF got %d, want %d", tc.mt, m.DEF, tc.wantDEF)
}
if !m.IsMiniBoss {
t.Errorf("%v: IsMiniBoss should be true", tc.mt)
}
if m.IsBoss {
t.Errorf("%v: IsBoss should be false for mini-bosses", tc.mt)
}
}
}
func TestMonsterAtMinFloor(t *testing.T) {
// Slime at floor 1 (minFloor=1) should have base stats
m := NewMonster(MonsterSlime, 1)
m := NewMonster(MonsterSlime, 1, 1.15)
if m.HP != 20 || m.ATK != 5 || m.DEF != 1 {
t.Errorf("Slime at min floor should be base stats, got HP=%d ATK=%d DEF=%d", m.HP, m.ATK, m.DEF)
}

View File

@@ -32,6 +32,8 @@ const (
StatusPoison StatusEffect = iota
StatusBurn
StatusFreeze
StatusBleed
StatusCurse
)
type ActiveEffect struct {
@@ -53,6 +55,7 @@ type Player struct {
Dead bool
Fled bool
SkillUses int // remaining skill uses this combat
Skills *PlayerSkills
}
func NewPlayer(name string, class Class) *Player {
@@ -76,6 +79,12 @@ func (p *Player) TakeDamage(dmg int) {
}
func (p *Player) Heal(amount int) {
for _, e := range p.Effects {
if e.Type == StatusCurse {
amount = amount * (100 - e.Value) / 100
break
}
}
p.HP += amount
if p.HP > p.MaxHP {
p.HP = p.MaxHP
@@ -110,6 +119,7 @@ func (p *Player) EffectiveATK() int {
atk += r.Value
}
}
atk += p.Skills.GetATKBonus(p.Class)
return atk
}
@@ -125,6 +135,7 @@ func (p *Player) EffectiveDEF() int {
def += r.Value
}
}
def += p.Skills.GetDEFBonus(p.Class)
return def
}
@@ -157,29 +168,45 @@ func (p *Player) HasEffect(t StatusEffect) bool {
return false
}
func (p *Player) TickEffects() (damages []string) {
var remaining []ActiveEffect
for _, e := range p.Effects {
func (p *Player) TickEffects() []string {
var msgs []string
remaining := p.Effects[:0] // reuse underlying array
for i := 0; i < len(p.Effects); i++ {
e := &p.Effects[i]
switch e.Type {
case StatusPoison:
p.HP -= e.Value
if p.HP <= 0 {
p.HP = 1 // Poison can't kill, leaves at 1 HP
}
damages = append(damages, fmt.Sprintf("%s takes %d poison damage", p.Name, e.Value))
msgs = append(msgs, fmt.Sprintf("%s takes %d poison damage", p.Name, e.Value))
case StatusBurn:
p.HP -= e.Value
if p.HP <= 0 {
p.HP = 0
p.Dead = true
}
damages = append(damages, fmt.Sprintf("%s takes %d burn damage", p.Name, e.Value))
msgs = append(msgs, fmt.Sprintf("%s takes %d burn damage", p.Name, e.Value))
case StatusFreeze:
msgs = append(msgs, fmt.Sprintf("%s is frozen!", p.Name))
case StatusBleed:
p.HP -= e.Value
msgs = append(msgs, fmt.Sprintf("%s takes %d bleed damage", p.Name, e.Value))
e.Value++ // Bleed intensifies each turn
case StatusCurse:
msgs = append(msgs, fmt.Sprintf("%s is cursed! Healing reduced", p.Name))
}
if p.HP < 0 {
p.HP = 0
}
e.Duration--
if e.Duration > 0 {
remaining = append(remaining, e)
remaining = append(remaining, *e)
}
}
p.Effects = remaining
return
if p.HP <= 0 && !p.Dead {
p.Dead = true
}
return msgs
}

View File

@@ -1,6 +1,9 @@
package entity
import "testing"
import (
"strings"
"testing"
)
func TestNewPlayer(t *testing.T) {
p := NewPlayer("testuser", ClassWarrior)
@@ -190,3 +193,49 @@ func TestEffectOverwrite(t *testing.T) {
t.Error("should have overwritten with new values")
}
}
func TestBleedEffect(t *testing.T) {
p := NewPlayer("Test", ClassWarrior)
startHP := p.HP
p.AddEffect(ActiveEffect{Type: StatusBleed, Duration: 3, Value: 2})
msgs := p.TickEffects()
if len(msgs) == 0 || !strings.Contains(msgs[0], "bleed") {
t.Error("expected bleed damage message")
}
if p.HP != startHP-2 {
t.Errorf("expected HP %d, got %d", startHP-2, p.HP)
}
// After tick, remaining bleed should have value 3 (increased by 1)
if len(p.Effects) == 0 || p.Effects[0].Value != 3 {
t.Error("expected bleed value to increase to 3")
}
}
func TestCurseReducesHealing(t *testing.T) {
p := NewPlayer("Test", ClassHealer)
p.HP = 50
p.AddEffect(ActiveEffect{Type: StatusCurse, Duration: 3, Value: 50})
p.Heal(100)
// Curse reduces by 50%, so heal 50 from HP 50 -> 100, capped at MaxHP
expected := p.MaxHP
if 50+50 < p.MaxHP {
expected = 50 + 50
}
if p.HP != expected {
t.Errorf("expected HP %d, got %d", expected, p.HP)
}
}
func TestFreezeTickMessage(t *testing.T) {
p := NewPlayer("Test", ClassMage)
p.AddEffect(ActiveEffect{Type: StatusFreeze, Duration: 1, Value: 0})
msgs := p.TickEffects()
if len(msgs) == 0 || !strings.Contains(msgs[0], "frozen") {
t.Error("expected freeze message")
}
// Freeze duration 1 -> removed after tick
if len(p.Effects) != 0 {
t.Error("expected freeze to be removed after 1 tick")
}
}

196
entity/skill_tree.go Normal file
View File

@@ -0,0 +1,196 @@
package entity
import "errors"
// SkillEffect represents the type of bonus a skill node provides.
type SkillEffect int
const (
EffectATKBoost SkillEffect = iota
EffectDEFBoost
EffectMaxHPBoost
EffectSkillPower
EffectCritChance
EffectHealBoost
)
// SkillNode is a single node in a skill branch.
type SkillNode struct {
Name string
Effect SkillEffect
Value int
}
// SkillBranch is a named sequence of 3 skill nodes.
type SkillBranch struct {
Name string
Nodes [3]SkillNode
}
// PlayerSkills tracks a player's skill tree state for the current run.
type PlayerSkills struct {
BranchIndex int // -1 = not chosen, 0 or 1
Points int // total points earned (1 per floor clear)
Allocated int // points spent in chosen branch (max 3)
}
// NewPlayerSkills returns an initialized PlayerSkills with no branch chosen.
func NewPlayerSkills() *PlayerSkills {
return &PlayerSkills{BranchIndex: -1}
}
// branchDefs holds 2 branches per class.
var branchDefs = map[Class][2]SkillBranch{
ClassWarrior: {
{
Name: "Tank",
Nodes: [3]SkillNode{
{"Iron Skin", EffectDEFBoost, 3},
{"Fortitude", EffectMaxHPBoost, 20},
{"Bastion", EffectDEFBoost, 5},
},
},
{
Name: "Berserker",
Nodes: [3]SkillNode{
{"Fury", EffectATKBoost, 4},
{"Wrath", EffectSkillPower, 20},
{"Rampage", EffectATKBoost, 6},
},
},
},
ClassMage: {
{
Name: "Elementalist",
Nodes: [3]SkillNode{
{"Arcane Focus", EffectSkillPower, 15},
{"Elemental Fury", EffectATKBoost, 5},
{"Overload", EffectSkillPower, 25},
},
},
{
Name: "Chronomancer",
Nodes: [3]SkillNode{
{"Temporal Shield", EffectDEFBoost, 3},
{"Time Warp", EffectATKBoost, 3},
{"Stasis", EffectMaxHPBoost, 15},
},
},
},
ClassHealer: {
{
Name: "Guardian",
Nodes: [3]SkillNode{
{"Blessing", EffectHealBoost, 20},
{"Divine Armor", EffectDEFBoost, 4},
{"Miracle", EffectHealBoost, 30},
},
},
{
Name: "Priest",
Nodes: [3]SkillNode{
{"Smite", EffectATKBoost, 5},
{"Holy Power", EffectSkillPower, 20},
{"Judgment", EffectATKBoost, 7},
},
},
},
ClassRogue: {
{
Name: "Assassin",
Nodes: [3]SkillNode{
{"Backstab", EffectATKBoost, 5},
{"Precision", EffectCritChance, 15},
{"Execute", EffectATKBoost, 8},
},
},
{
Name: "Alchemist",
Nodes: [3]SkillNode{
{"Tonic", EffectHealBoost, 15},
{"Brew", EffectSkillPower, 20},
{"Elixir", EffectMaxHPBoost, 25},
},
},
},
}
// GetBranches returns the 2 skill branches for the given class.
func GetBranches(class Class) [2]SkillBranch {
return branchDefs[class]
}
// Allocate spends one skill point into the given branch. Returns an error if
// the player tries to switch branches after first allocation or has already
// allocated the maximum of 3 points.
func (ps *PlayerSkills) Allocate(branchIdx int, class Class) error {
if ps == nil {
return errors.New("skills not initialized")
}
if branchIdx < 0 || branchIdx > 1 {
return errors.New("invalid branch index")
}
if ps.Allocated >= 3 {
return errors.New("branch fully allocated")
}
if ps.Points <= ps.Allocated {
return errors.New("no available skill points")
}
if ps.BranchIndex != -1 && ps.BranchIndex != branchIdx {
return errors.New("cannot switch branch after first allocation")
}
ps.BranchIndex = branchIdx
ps.Allocated++
return nil
}
// allocatedNodes returns the slice of nodes the player has unlocked.
func (ps *PlayerSkills) allocatedNodes(class Class) []SkillNode {
if ps == nil || ps.BranchIndex < 0 || ps.Allocated == 0 {
return nil
}
branches := GetBranches(class)
branch := branches[ps.BranchIndex]
return branch.Nodes[:ps.Allocated]
}
// sumEffect sums values of nodes matching the given effect.
func (ps *PlayerSkills) sumEffect(class Class, effect SkillEffect) int {
total := 0
for _, node := range ps.allocatedNodes(class) {
if node.Effect == effect {
total += node.Value
}
}
return total
}
// GetATKBonus returns the total ATK bonus from allocated skill nodes.
func (ps *PlayerSkills) GetATKBonus(class Class) int {
return ps.sumEffect(class, EffectATKBoost)
}
// GetDEFBonus returns the total DEF bonus from allocated skill nodes.
func (ps *PlayerSkills) GetDEFBonus(class Class) int {
return ps.sumEffect(class, EffectDEFBoost)
}
// GetMaxHPBonus returns the total MaxHP bonus from allocated skill nodes.
func (ps *PlayerSkills) GetMaxHPBonus(class Class) int {
return ps.sumEffect(class, EffectMaxHPBoost)
}
// GetSkillPower returns the total SkillPower bonus from allocated skill nodes.
func (ps *PlayerSkills) GetSkillPower(class Class) int {
return ps.sumEffect(class, EffectSkillPower)
}
// GetCritChance returns the total CritChance bonus from allocated skill nodes.
func (ps *PlayerSkills) GetCritChance(class Class) int {
return ps.sumEffect(class, EffectCritChance)
}
// GetHealBoost returns the total HealBoost bonus from allocated skill nodes.
func (ps *PlayerSkills) GetHealBoost(class Class) int {
return ps.sumEffect(class, EffectHealBoost)
}

148
entity/skill_tree_test.go Normal file
View File

@@ -0,0 +1,148 @@
package entity
import "testing"
func TestGetBranches(t *testing.T) {
classes := []Class{ClassWarrior, ClassMage, ClassHealer, ClassRogue}
for _, c := range classes {
branches := GetBranches(c)
if len(branches) != 2 {
t.Errorf("expected 2 branches for %s, got %d", c, len(branches))
}
for i, b := range branches {
if b.Name == "" {
t.Errorf("branch %d for %s has empty name", i, c)
}
for j, node := range b.Nodes {
if node.Name == "" {
t.Errorf("node %d in branch %d for %s has empty name", j, i, c)
}
if node.Value <= 0 {
t.Errorf("node %d in branch %d for %s has non-positive value %d", j, i, c, node.Value)
}
}
}
}
}
func TestAllocateSkillPoint(t *testing.T) {
ps := NewPlayerSkills()
ps.Points = 1
err := ps.Allocate(0, ClassWarrior)
if err != nil {
t.Fatalf("unexpected error on first allocation: %v", err)
}
if ps.BranchIndex != 0 {
t.Errorf("expected BranchIndex 0, got %d", ps.BranchIndex)
}
if ps.Allocated != 1 {
t.Errorf("expected Allocated 1, got %d", ps.Allocated)
}
}
func TestCannotSwitchBranch(t *testing.T) {
ps := NewPlayerSkills()
ps.Points = 2
err := ps.Allocate(0, ClassWarrior)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
err = ps.Allocate(1, ClassWarrior)
if err == nil {
t.Fatal("expected error when switching branch, got nil")
}
}
func TestCannotAllocateWithoutPoints(t *testing.T) {
ps := NewPlayerSkills()
ps.Points = 0
err := ps.Allocate(0, ClassWarrior)
if err == nil {
t.Fatal("expected error when no points available, got nil")
}
}
func TestFullyAllocated(t *testing.T) {
ps := NewPlayerSkills()
ps.Points = 4
for i := 0; i < 3; i++ {
err := ps.Allocate(0, ClassWarrior)
if err != nil {
t.Fatalf("unexpected error on allocation %d: %v", i+1, err)
}
}
err := ps.Allocate(0, ClassWarrior)
if err == nil {
t.Fatal("expected error when fully allocated, got nil")
}
}
func TestSkillBonuses(t *testing.T) {
// Warrior Tank branch: DEF+3, MaxHP+20, DEF+5
ps := NewPlayerSkills()
ps.Points = 3
for i := 0; i < 3; i++ {
if err := ps.Allocate(0, ClassWarrior); err != nil {
t.Fatalf("allocate error: %v", err)
}
}
if got := ps.GetDEFBonus(ClassWarrior); got != 8 {
t.Errorf("expected DEF bonus 8 (3+5), got %d", got)
}
if got := ps.GetMaxHPBonus(ClassWarrior); got != 20 {
t.Errorf("expected MaxHP bonus 20, got %d", got)
}
if got := ps.GetATKBonus(ClassWarrior); got != 0 {
t.Errorf("expected ATK bonus 0 for Tank, got %d", got)
}
// Warrior Berserker branch: ATK+4, SkillPower+20, ATK+6
ps2 := NewPlayerSkills()
ps2.Points = 3
for i := 0; i < 3; i++ {
if err := ps2.Allocate(1, ClassWarrior); err != nil {
t.Fatalf("allocate error: %v", err)
}
}
if got := ps2.GetATKBonus(ClassWarrior); got != 10 {
t.Errorf("expected ATK bonus 10 (4+6), got %d", got)
}
if got := ps2.GetSkillPower(ClassWarrior); got != 20 {
t.Errorf("expected SkillPower bonus 20, got %d", got)
}
}
func TestNilPlayerSkillsBonuses(t *testing.T) {
var ps *PlayerSkills
if got := ps.GetATKBonus(ClassWarrior); got != 0 {
t.Errorf("expected 0 ATK bonus from nil skills, got %d", got)
}
if got := ps.GetDEFBonus(ClassWarrior); got != 0 {
t.Errorf("expected 0 DEF bonus from nil skills, got %d", got)
}
}
func TestPartialAllocation(t *testing.T) {
// Rogue Assassin: ATK+5, CritChance+15, ATK+8
// Allocate only 2 points: should get ATK+5 and CritChance+15
ps := NewPlayerSkills()
ps.Points = 2
for i := 0; i < 2; i++ {
if err := ps.Allocate(0, ClassRogue); err != nil {
t.Fatalf("allocate error: %v", err)
}
}
if got := ps.GetATKBonus(ClassRogue); got != 5 {
t.Errorf("expected ATK bonus 5, got %d", got)
}
if got := ps.GetCritChance(ClassRogue); got != 15 {
t.Errorf("expected CritChance bonus 15, got %d", got)
}
}

11
game/daily.go Normal file
View File

@@ -0,0 +1,11 @@
package game
import (
"crypto/sha256"
"encoding/binary"
)
func DailySeed(date string) int64 {
h := sha256.Sum256([]byte("catacombs:" + date))
return int64(binary.BigEndian.Uint64(h[:8]))
}

42
game/daily_test.go Normal file
View File

@@ -0,0 +1,42 @@
package game
import (
"math/rand"
"testing"
"github.com/tolelom/catacombs/dungeon"
)
func TestDailySeed(t *testing.T) {
// Same date produces the same seed
seed1 := DailySeed("2026-03-25")
seed2 := DailySeed("2026-03-25")
if seed1 != seed2 {
t.Errorf("same date should produce same seed: got %d and %d", seed1, seed2)
}
// Different date produces a different seed
seed3 := DailySeed("2026-03-26")
if seed1 == seed3 {
t.Errorf("different dates should produce different seeds: both got %d", seed1)
}
}
func TestDailyFloorDeterminism(t *testing.T) {
seed := DailySeed("2026-03-25")
// Generate floor twice with the same seed
floor1 := dungeon.GenerateFloor(1, rand.New(rand.NewSource(seed)))
floor2 := dungeon.GenerateFloor(1, rand.New(rand.NewSource(seed)))
if len(floor1.Rooms) != len(floor2.Rooms) {
t.Fatalf("room count mismatch: %d vs %d", len(floor1.Rooms), len(floor2.Rooms))
}
for i, r1 := range floor1.Rooms {
r2 := floor2.Rooms[i]
if r1.Type != r2.Type {
t.Errorf("room %d type mismatch: %d vs %d", i, r1.Type, r2.Type)
}
}
}

17
game/emote.go Normal file
View File

@@ -0,0 +1,17 @@
package game
var emotes = map[string]string{
"/hi": "👋 waves hello!",
"/gg": "🎉 says GG!",
"/go": "⚔️ says Let's go!",
"/wait": "✋ says Wait!",
"/help": "🆘 calls for help!",
}
func ParseEmote(input string) (string, bool) {
if input == "" {
return "", false
}
text, ok := emotes[input]
return text, ok
}

31
game/emote_test.go Normal file
View File

@@ -0,0 +1,31 @@
package game
import "testing"
func TestParseEmote(t *testing.T) {
tests := []struct {
input string
isEmote bool
expected string
}{
{"/hi", true, "👋 waves hello!"},
{"/gg", true, "🎉 says GG!"},
{"/go", true, "⚔️ says Let's go!"},
{"/wait", true, "✋ says Wait!"},
{"/help", true, "🆘 calls for help!"},
{"/unknown", false, ""},
{"hello", false, ""},
{"", false, ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result, ok := ParseEmote(tt.input)
if ok != tt.isEmote {
t.Errorf("ParseEmote(%q) isEmote = %v, want %v", tt.input, ok, tt.isEmote)
}
if ok && result != tt.expected {
t.Errorf("ParseEmote(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}

View File

@@ -48,6 +48,14 @@ func (s *GameSession) EnterRoom(roomIdx int) {
case dungeon.RoomEvent:
s.triggerEvent()
room.Cleared = true
case dungeon.RoomSecret:
s.grantSecretTreasure()
room.Cleared = true
case dungeon.RoomMiniBoss:
s.spawnMiniBoss()
s.state.Phase = PhaseCombat
s.state.CombatTurn = 0
s.signalCombat()
case dungeon.RoomEmpty:
room.Cleared = true
}
@@ -81,21 +89,24 @@ func (s *GameSession) spawnMonsters() {
for i := 0; i < count; i++ {
mt := valid[rand.Intn(len(valid))]
m := entity.NewMonster(mt, floor)
m := entity.NewMonster(mt, floor, s.cfg.Combat.MonsterScaling)
if s.state.SoloMode {
m.HP = m.HP / 2
m.HP = int(float64(m.HP) * s.cfg.Combat.SoloHPReduction)
if m.HP < 1 {
m.HP = 1
}
m.MaxHP = m.HP
m.DEF = m.DEF / 2
m.DEF = int(float64(m.DEF) * s.cfg.Combat.SoloHPReduction)
}
if rand.Float64() < 0.20 {
entity.ApplyPrefix(m, entity.RandomPrefix())
}
s.state.Monsters[i] = m
}
// Reset skill uses for all players at combat start
for _, p := range s.state.Players {
p.SkillUses = 3
p.SkillUses = s.cfg.Game.SkillUses
}
}
@@ -113,34 +124,34 @@ func (s *GameSession) spawnBoss() {
default:
mt = entity.MonsterBoss5
}
boss := entity.NewMonster(mt, s.state.FloorNum)
boss := entity.NewMonster(mt, s.state.FloorNum, s.cfg.Combat.MonsterScaling)
switch mt {
case entity.MonsterBoss5:
boss.Pattern = entity.PatternAoE
boss.Pattern = entity.PatternPoison // Swamp theme
case entity.MonsterBoss10:
boss.Pattern = entity.PatternPoison
boss.Pattern = entity.PatternBurn // Volcano theme
case entity.MonsterBoss15:
boss.Pattern = entity.PatternBurn
boss.Pattern = entity.PatternFreeze // Glacier theme
case entity.MonsterBoss20:
boss.Pattern = entity.PatternHeal
boss.Pattern = entity.PatternHeal // Inferno theme (+ natural AoE every 3 turns)
}
if s.state.SoloMode {
boss.HP = boss.HP / 2
boss.HP = int(float64(boss.HP) * s.cfg.Combat.SoloHPReduction)
boss.MaxHP = boss.HP
boss.DEF = boss.DEF / 2
boss.DEF = int(float64(boss.DEF) * s.cfg.Combat.SoloHPReduction)
}
s.state.Monsters = []*entity.Monster{boss}
// Reset skill uses for all players at combat start
for _, p := range s.state.Players {
p.SkillUses = 3
p.SkillUses = s.cfg.Game.SkillUses
}
}
func (s *GameSession) grantTreasure() {
floor := s.state.FloorNum
for _, p := range s.state.Players {
if len(p.Inventory) >= 10 {
if len(p.Inventory) >= s.cfg.Game.InventoryLimit {
s.addLog(fmt.Sprintf("%s's inventory is full!", p.Name))
continue
}
@@ -209,21 +220,136 @@ func armorName(floor int) string {
}
func (s *GameSession) triggerEvent() {
event := PickRandomEvent()
s.addLog(fmt.Sprintf("Event: %s — %s", event.Name, event.Description))
// Auto-resolve with a random choice
choice := event.Choices[rand.Intn(len(event.Choices))]
outcome := choice.Resolve(s.state.FloorNum)
s.addLog(fmt.Sprintf(" → %s: %s", choice.Label, outcome.Description))
// Pick a random alive player to apply the outcome
var alive []*entity.Player
for _, p := range s.state.Players {
if p.IsDead() {
continue
if !p.IsDead() {
alive = append(alive, p)
}
if rand.Float64() < 0.5 {
baseDmg := 10 + s.state.FloorNum
dmg := baseDmg + rand.Intn(baseDmg/2+1)
p.TakeDamage(dmg)
s.addLog(fmt.Sprintf("Trap! %s takes %d damage", p.Name, dmg))
}
if len(alive) == 0 {
return
}
target := alive[rand.Intn(len(alive))]
if outcome.HPChange > 0 {
before := target.HP
target.Heal(outcome.HPChange)
s.addLog(fmt.Sprintf(" %s heals %d HP", target.Name, target.HP-before))
} else if outcome.HPChange < 0 {
target.TakeDamage(-outcome.HPChange)
s.addLog(fmt.Sprintf(" %s takes %d damage", target.Name, -outcome.HPChange))
}
if outcome.GoldChange != 0 {
target.Gold += outcome.GoldChange
if target.Gold < 0 {
target.Gold = 0
}
if outcome.GoldChange > 0 {
s.addLog(fmt.Sprintf(" %s gains %d gold", target.Name, outcome.GoldChange))
} else {
baseHeal := 15 + s.state.FloorNum
heal := baseHeal + rand.Intn(baseHeal/2+1)
before := p.HP
p.Heal(heal)
s.addLog(fmt.Sprintf("Blessing! %s heals %d HP", p.Name, p.HP-before))
s.addLog(fmt.Sprintf(" %s loses %d gold", target.Name, -outcome.GoldChange))
}
}
if outcome.ItemDrop {
if len(target.Inventory) < s.cfg.Game.InventoryLimit {
floor := s.state.FloorNum
if rand.Float64() < 0.5 {
bonus := 3 + rand.Intn(6) + floor/3
item := entity.Item{Name: weaponName(floor), Type: entity.ItemWeapon, Bonus: bonus}
target.Inventory = append(target.Inventory, item)
s.addLog(fmt.Sprintf(" %s found %s (ATK+%d)", target.Name, item.Name, item.Bonus))
} else {
bonus := 2 + rand.Intn(4) + floor/4
item := entity.Item{Name: armorName(floor), Type: entity.ItemArmor, Bonus: bonus}
target.Inventory = append(target.Inventory, item)
s.addLog(fmt.Sprintf(" %s found %s (DEF+%d)", target.Name, item.Name, item.Bonus))
}
} else {
s.addLog(fmt.Sprintf(" %s's inventory is full!", target.Name))
}
}
}
func (s *GameSession) grantSecretTreasure() {
s.addLog("You discovered a secret room filled with treasure!")
floor := s.state.FloorNum
// Double treasure: grant two items per player
for _, p := range s.state.Players {
for i := 0; i < 2; i++ {
if len(p.Inventory) >= s.cfg.Game.InventoryLimit {
s.addLog(fmt.Sprintf("%s's inventory is full!", p.Name))
break
}
if rand.Float64() < 0.5 {
bonus := 3 + rand.Intn(6) + floor/3
item := entity.Item{
Name: weaponName(floor), Type: entity.ItemWeapon, Bonus: bonus,
}
p.Inventory = append(p.Inventory, item)
s.addLog(fmt.Sprintf("%s found %s (ATK+%d)", p.Name, item.Name, item.Bonus))
} else {
bonus := 2 + rand.Intn(4) + floor/4
item := entity.Item{
Name: armorName(floor), Type: entity.ItemArmor, Bonus: bonus,
}
p.Inventory = append(p.Inventory, item)
s.addLog(fmt.Sprintf("%s found %s (DEF+%d)", p.Name, item.Name, item.Bonus))
}
}
}
}
func (s *GameSession) spawnMiniBoss() {
var mt entity.MonsterType
floor := s.state.FloorNum
switch {
case floor <= 4:
mt = entity.MonsterMiniBoss5
case floor <= 9:
mt = entity.MonsterMiniBoss10
case floor <= 14:
mt = entity.MonsterMiniBoss15
default:
mt = entity.MonsterMiniBoss20
}
miniBoss := entity.NewMonster(mt, floor, s.cfg.Combat.MonsterScaling)
// Use same pattern as the subsequent boss
switch mt {
case entity.MonsterMiniBoss5:
miniBoss.Pattern = entity.PatternPoison
case entity.MonsterMiniBoss10:
miniBoss.Pattern = entity.PatternBurn
case entity.MonsterMiniBoss15:
miniBoss.Pattern = entity.PatternFreeze
case entity.MonsterMiniBoss20:
miniBoss.Pattern = entity.PatternHeal
}
if s.state.SoloMode {
miniBoss.HP = int(float64(miniBoss.HP) * s.cfg.Combat.SoloHPReduction)
if miniBoss.HP < 1 {
miniBoss.HP = 1
}
miniBoss.MaxHP = miniBoss.HP
miniBoss.DEF = int(float64(miniBoss.DEF) * s.cfg.Combat.SoloHPReduction)
}
s.state.Monsters = []*entity.Monster{miniBoss}
s.addLog(fmt.Sprintf("A mini-boss appears: %s!", miniBoss.Name))
// Reset skill uses for all players at combat start
for _, p := range s.state.Players {
p.SkillUses = s.cfg.Game.SkillUses
}
}

View File

@@ -2,8 +2,11 @@ package game
import (
"fmt"
"log/slog"
"math/rand"
"sync"
"github.com/tolelom/catacombs/config"
)
type RoomStatus int
@@ -36,19 +39,25 @@ type OnlinePlayer struct {
type Lobby struct {
mu sync.RWMutex
cfg *config.Config
rooms map[string]*LobbyRoom
online map[string]*OnlinePlayer // fingerprint -> player
activeSessions map[string]string // fingerprint -> room code (for reconnect)
}
func NewLobby() *Lobby {
func NewLobby(cfg *config.Config) *Lobby {
return &Lobby{
cfg: cfg,
rooms: make(map[string]*LobbyRoom),
online: make(map[string]*OnlinePlayer),
activeSessions: make(map[string]string),
}
}
func (l *Lobby) Cfg() *config.Config {
return l.cfg
}
func (l *Lobby) RegisterSession(fingerprint, roomCode string) {
l.mu.Lock()
defer l.mu.Unlock()
@@ -130,6 +139,7 @@ func (l *Lobby) CreateRoom(name string) string {
Name: name,
Status: RoomWaiting,
}
slog.Info("room created", "code", code, "name", name)
return code
}
@@ -140,13 +150,14 @@ func (l *Lobby) JoinRoom(code, playerName, fingerprint string) error {
if !ok {
return fmt.Errorf("room %s not found", code)
}
if len(room.Players) >= 4 {
if len(room.Players) >= l.cfg.Game.MaxPlayers {
return fmt.Errorf("room %s is full", code)
}
if room.Status != RoomWaiting {
return fmt.Errorf("room %s already in progress", code)
}
room.Players = append(room.Players, LobbyPlayer{Name: playerName, Fingerprint: fingerprint})
slog.Info("player joined", "room", code, "player", playerName)
return nil
}
@@ -210,6 +221,7 @@ func (l *Lobby) StartRoom(code string) {
defer l.mu.Unlock()
if room, ok := l.rooms[code]; ok {
room.Status = RoomPlaying
slog.Info("game started", "room", code, "players", len(room.Players))
}
}

View File

@@ -1,9 +1,19 @@
package game
import "testing"
import (
"testing"
"github.com/tolelom/catacombs/config"
)
func testConfig(t *testing.T) *config.Config {
t.Helper()
cfg, _ := config.Load("")
return cfg
}
func TestCreateRoom(t *testing.T) {
lobby := NewLobby()
lobby := NewLobby(testConfig(t))
code := lobby.CreateRoom("Test Room")
if len(code) != 4 {
t.Errorf("Room code length: got %d, want 4", len(code))
@@ -15,7 +25,7 @@ func TestCreateRoom(t *testing.T) {
}
func TestJoinRoom(t *testing.T) {
lobby := NewLobby()
lobby := NewLobby(testConfig(t))
code := lobby.CreateRoom("Test Room")
err := lobby.JoinRoom(code, "player1", "fp-player1")
if err != nil {
@@ -28,7 +38,7 @@ func TestJoinRoom(t *testing.T) {
}
func TestRoomStatusTransition(t *testing.T) {
l := NewLobby()
l := NewLobby(testConfig(t))
code := l.CreateRoom("Test")
l.JoinRoom(code, "Alice", "fp-alice")
r := l.GetRoom(code)
@@ -47,7 +57,7 @@ func TestRoomStatusTransition(t *testing.T) {
}
func TestJoinRoomFull(t *testing.T) {
lobby := NewLobby()
lobby := NewLobby(testConfig(t))
code := lobby.CreateRoom("Test Room")
for i := 0; i < 4; i++ {
lobby.JoinRoom(code, "player", "fp-player")
@@ -59,7 +69,7 @@ func TestJoinRoomFull(t *testing.T) {
}
func TestSetPlayerClass(t *testing.T) {
l := NewLobby()
l := NewLobby(testConfig(t))
code := l.CreateRoom("Test")
l.JoinRoom(code, "Alice", "fp-alice")
l.SetPlayerClass(code, "fp-alice", "Warrior")
@@ -70,7 +80,7 @@ func TestSetPlayerClass(t *testing.T) {
}
func TestAllReady(t *testing.T) {
l := NewLobby()
l := NewLobby(testConfig(t))
code := l.CreateRoom("Test")
l.JoinRoom(code, "Alice", "fp-alice")
l.JoinRoom(code, "Bob", "fp-bob")
@@ -91,7 +101,7 @@ func TestAllReady(t *testing.T) {
}
func TestAllReadyEmptyRoom(t *testing.T) {
l := NewLobby()
l := NewLobby(testConfig(t))
code := l.CreateRoom("Test")
if l.AllReady(code) {
t.Error("empty room should not be all ready")

41
game/mutation.go Normal file
View File

@@ -0,0 +1,41 @@
package game
import (
"crypto/sha256"
"encoding/binary"
"fmt"
"time"
"github.com/tolelom/catacombs/config"
)
// Mutation represents a weekly gameplay modifier.
type Mutation struct {
ID string
Name string
Description string
Apply func(cfg *config.GameConfig)
}
// Mutations is the list of all available mutations.
var Mutations = []Mutation{
{ID: "no_skills", Name: "Skill Lockout", Description: "Class skills are disabled",
Apply: func(cfg *config.GameConfig) { cfg.SkillUses = 0 }},
{ID: "speed_run", Name: "Speed Run", Description: "Turn timeout halved",
Apply: func(cfg *config.GameConfig) { cfg.TurnTimeoutSec = max(cfg.TurnTimeoutSec/2, 2) }},
{ID: "no_shop", Name: "Shop Closed", Description: "Shops are unavailable",
Apply: func(cfg *config.GameConfig) {}},
{ID: "glass_cannon", Name: "Glass Cannon", Description: "Double damage, half HP",
Apply: func(cfg *config.GameConfig) {}},
{ID: "elite_flood", Name: "Elite Flood", Description: "All monsters are elite",
Apply: func(cfg *config.GameConfig) {}},
}
// GetWeeklyMutation returns the mutation for the current week,
// determined by a SHA-256 hash of the year and ISO week number.
func GetWeeklyMutation() Mutation {
year, week := time.Now().ISOWeek()
h := sha256.Sum256([]byte(fmt.Sprintf("mutation:%d:%d", year, week)))
idx := int(binary.BigEndian.Uint64(h[:8]) % uint64(len(Mutations)))
return Mutations[idx]
}

68
game/mutation_test.go Normal file
View File

@@ -0,0 +1,68 @@
package game
import (
"testing"
"github.com/tolelom/catacombs/config"
)
func TestGetWeeklyMutation(t *testing.T) {
m := GetWeeklyMutation()
if m.ID == "" {
t.Error("expected non-empty mutation ID")
}
if m.Name == "" {
t.Error("expected non-empty mutation Name")
}
if m.Apply == nil {
t.Error("expected non-nil Apply function")
}
// Verify it returns one of the known mutations
found := false
for _, known := range Mutations {
if known.ID == m.ID {
found = true
break
}
}
if !found {
t.Errorf("mutation ID %q not found in Mutations list", m.ID)
}
}
func TestMutationApplyNoSkills(t *testing.T) {
cfg := config.GameConfig{SkillUses: 3}
// Find the no_skills mutation
var m Mutation
for _, mut := range Mutations {
if mut.ID == "no_skills" {
m = mut
break
}
}
if m.ID == "" {
t.Fatal("no_skills mutation not found")
}
m.Apply(&cfg)
if cfg.SkillUses != 0 {
t.Errorf("expected SkillUses=0 after no_skills mutation, got %d", cfg.SkillUses)
}
}
func TestMutationApplySpeedRun(t *testing.T) {
cfg := config.GameConfig{TurnTimeoutSec: 10}
var m Mutation
for _, mut := range Mutations {
if mut.ID == "speed_run" {
m = mut
break
}
}
if m.ID == "" {
t.Fatal("speed_run mutation not found")
}
m.Apply(&cfg)
if cfg.TurnTimeoutSec != 5 {
t.Errorf("expected TurnTimeoutSec=5 after speed_run mutation, got %d", cfg.TurnTimeoutSec)
}
}

247
game/random_event.go Normal file
View File

@@ -0,0 +1,247 @@
package game
import "math/rand"
// EventOutcome describes the result of choosing an event option.
type EventOutcome struct {
HPChange int
GoldChange int
ItemDrop bool
Description string
}
// EventChoice represents a single choice the player can make during an event.
type EventChoice struct {
Label string
Resolve func(floor int) EventOutcome
}
// RandomEvent represents a random event with multiple choices.
type RandomEvent struct {
Name string
Description string
Choices []EventChoice
}
// GetRandomEvents returns all 8 defined random events.
func GetRandomEvents() []RandomEvent {
return []RandomEvent{
{
Name: "altar",
Description: "You discover an ancient altar glowing with strange energy.",
Choices: []EventChoice{
{
Label: "Pray at the altar",
Resolve: func(floor int) EventOutcome {
if rand.Float64() < 0.6 {
heal := 15 + floor*2
return EventOutcome{HPChange: heal, Description: "The altar blesses you with healing light."}
}
dmg := 10 + floor
return EventOutcome{HPChange: -dmg, Description: "The altar's energy lashes out at you!"}
},
},
{
Label: "Offer gold",
Resolve: func(floor int) EventOutcome {
cost := 10 + floor
return EventOutcome{GoldChange: -cost, ItemDrop: true, Description: "You offer gold and receive a divine gift."}
},
},
{
Label: "Walk away",
Resolve: func(floor int) EventOutcome {
return EventOutcome{Description: "You leave the altar undisturbed."}
},
},
},
},
{
Name: "fountain",
Description: "A shimmering fountain bubbles in the center of the room.",
Choices: []EventChoice{
{
Label: "Drink from the fountain",
Resolve: func(floor int) EventOutcome {
heal := 20 + floor*2
return EventOutcome{HPChange: heal, Description: "The water rejuvenates you!"}
},
},
{
Label: "Toss a coin",
Resolve: func(floor int) EventOutcome {
if rand.Float64() < 0.5 {
gold := 15 + floor*3
return EventOutcome{GoldChange: gold, Description: "The fountain rewards your generosity!"}
}
return EventOutcome{GoldChange: -5, Description: "The coin sinks and nothing happens."}
},
},
},
},
{
Name: "merchant",
Description: "A hooded merchant appears from the shadows.",
Choices: []EventChoice{
{
Label: "Trade gold for healing",
Resolve: func(floor int) EventOutcome {
cost := 15 + floor
heal := 25 + floor*2
return EventOutcome{HPChange: heal, GoldChange: -cost, Description: "The merchant sells you a healing draught."}
},
},
{
Label: "Buy a mystery item",
Resolve: func(floor int) EventOutcome {
cost := 20 + floor*2
return EventOutcome{GoldChange: -cost, ItemDrop: true, Description: "The merchant hands you a wrapped package."}
},
},
{
Label: "Decline",
Resolve: func(floor int) EventOutcome {
return EventOutcome{Description: "The merchant vanishes into the shadows."}
},
},
},
},
{
Name: "trap_room",
Description: "The floor is covered with suspicious pressure plates.",
Choices: []EventChoice{
{
Label: "Carefully navigate",
Resolve: func(floor int) EventOutcome {
if rand.Float64() < 0.5 {
return EventOutcome{Description: "You skillfully avoid all the traps!"}
}
dmg := 8 + floor
return EventOutcome{HPChange: -dmg, Description: "You trigger a trap and take damage!"}
},
},
{
Label: "Rush through",
Resolve: func(floor int) EventOutcome {
dmg := 5 + floor/2
gold := 10 + floor*2
return EventOutcome{HPChange: -dmg, GoldChange: gold, Description: "You take minor damage but find hidden gold!"}
},
},
},
},
{
Name: "shrine",
Description: "A glowing shrine hums with divine power.",
Choices: []EventChoice{
{
Label: "Kneel and pray",
Resolve: func(floor int) EventOutcome {
heal := 30 + floor*2
return EventOutcome{HPChange: heal, Description: "The shrine fills you with renewed vigor!"}
},
},
{
Label: "Take the offering",
Resolve: func(floor int) EventOutcome {
gold := 20 + floor*3
dmg := 15 + floor
return EventOutcome{HPChange: -dmg, GoldChange: gold, Description: "You steal the offering but anger the spirits!"}
},
},
},
},
{
Name: "chest",
Description: "An ornate chest sits in the corner of the room.",
Choices: []EventChoice{
{
Label: "Open carefully",
Resolve: func(floor int) EventOutcome {
if rand.Float64() < 0.7 {
gold := 15 + floor*2
return EventOutcome{GoldChange: gold, Description: "The chest contains a pile of gold!"}
}
dmg := 12 + floor
return EventOutcome{HPChange: -dmg, Description: "The chest was a mimic! It bites you!"}
},
},
{
Label: "Smash it open",
Resolve: func(floor int) EventOutcome {
return EventOutcome{ItemDrop: true, Description: "You smash the chest and find equipment inside!"}
},
},
{
Label: "Leave it",
Resolve: func(floor int) EventOutcome {
return EventOutcome{Description: "Better safe than sorry."}
},
},
},
},
{
Name: "ghost",
Description: "A spectral figure materializes before you.",
Choices: []EventChoice{
{
Label: "Speak with the ghost",
Resolve: func(floor int) EventOutcome {
gold := 10 + floor*2
return EventOutcome{GoldChange: gold, Description: "The ghost thanks you for listening and rewards you."}
},
},
{
Label: "Attack the ghost",
Resolve: func(floor int) EventOutcome {
if rand.Float64() < 0.4 {
return EventOutcome{ItemDrop: true, Description: "The ghost drops a spectral weapon as it fades!"}
}
dmg := 15 + floor
return EventOutcome{HPChange: -dmg, Description: "The ghost retaliates with ghostly fury!"}
},
},
},
},
{
Name: "mushroom",
Description: "Strange glowing mushrooms grow in clusters here.",
Choices: []EventChoice{
{
Label: "Eat a mushroom",
Resolve: func(floor int) EventOutcome {
r := rand.Float64()
if r < 0.33 {
heal := 20 + floor*2
return EventOutcome{HPChange: heal, Description: "The mushroom tastes great and heals you!"}
} else if r < 0.66 {
dmg := 10 + floor
return EventOutcome{HPChange: -dmg, Description: "The mushroom was poisonous!"}
}
gold := 10 + floor
return EventOutcome{GoldChange: gold, Description: "The mushroom gives you strange visions... and gold falls from above!"}
},
},
{
Label: "Collect and sell",
Resolve: func(floor int) EventOutcome {
gold := 8 + floor
return EventOutcome{GoldChange: gold, Description: "You carefully harvest the mushrooms for sale."}
},
},
{
Label: "Ignore them",
Resolve: func(floor int) EventOutcome {
return EventOutcome{Description: "You wisely avoid the mysterious fungi."}
},
},
},
},
}
}
// PickRandomEvent returns a random event from the list.
func PickRandomEvent() RandomEvent {
events := GetRandomEvents()
return events[rand.Intn(len(events))]
}

100
game/random_event_test.go Normal file
View File

@@ -0,0 +1,100 @@
package game
import "testing"
func TestGetRandomEvents(t *testing.T) {
events := GetRandomEvents()
if len(events) < 8 {
t.Errorf("Expected at least 8 random events, got %d", len(events))
}
for _, e := range events {
if len(e.Choices) < 2 {
t.Errorf("Event %q should have at least 2 choices, got %d", e.Name, len(e.Choices))
}
if e.Name == "" {
t.Error("Event name should not be empty")
}
if e.Description == "" {
t.Errorf("Event %q description should not be empty", e.Name)
}
}
}
func TestResolveChoice(t *testing.T) {
events := GetRandomEvents()
// Test altar "Walk away" — always safe
var altar RandomEvent
for _, e := range events {
if e.Name == "altar" {
altar = e
break
}
}
if altar.Name == "" {
t.Fatal("altar event not found")
}
// The third choice (index 2) is "Walk away" — should have no HP/gold change
outcome := altar.Choices[2].Resolve(5)
if outcome.HPChange != 0 || outcome.GoldChange != 0 {
t.Errorf("Walk away should have no changes, got HP=%d Gold=%d", outcome.HPChange, outcome.GoldChange)
}
// Test fountain "Drink" — always heals
var fountain RandomEvent
for _, e := range events {
if e.Name == "fountain" {
fountain = e
break
}
}
if fountain.Name == "" {
t.Fatal("fountain event not found")
}
drinkOutcome := fountain.Choices[0].Resolve(10)
if drinkOutcome.HPChange <= 0 {
t.Errorf("Drinking from fountain should heal, got HP=%d", drinkOutcome.HPChange)
}
// Test shrine "Kneel and pray" — always heals
var shrine RandomEvent
for _, e := range events {
if e.Name == "shrine" {
shrine = e
break
}
}
if shrine.Name == "" {
t.Fatal("shrine event not found")
}
prayOutcome := shrine.Choices[0].Resolve(5)
if prayOutcome.HPChange <= 0 {
t.Errorf("Praying at shrine should heal, got HP=%d", prayOutcome.HPChange)
}
// Test chest "Smash it open" — always gives item
var chest RandomEvent
for _, e := range events {
if e.Name == "chest" {
chest = e
break
}
}
if chest.Name == "" {
t.Fatal("chest event not found")
}
smashOutcome := chest.Choices[1].Resolve(5)
if !smashOutcome.ItemDrop {
t.Error("Smashing chest should always give an item drop")
}
}
func TestPickRandomEvent(t *testing.T) {
event := PickRandomEvent()
if event.Name == "" {
t.Error("PickRandomEvent should return a valid event")
}
if len(event.Choices) < 2 {
t.Errorf("Picked event should have at least 2 choices, got %d", len(event.Choices))
}
}

View File

@@ -2,9 +2,12 @@ package game
import (
"fmt"
"log/slog"
"math/rand"
"sync"
"time"
"github.com/tolelom/catacombs/config"
"github.com/tolelom/catacombs/dungeon"
"github.com/tolelom/catacombs/entity"
)
@@ -70,14 +73,19 @@ func (s *GameSession) clearLog() {
}
type GameSession struct {
mu sync.Mutex
state GameState
started bool
actions map[string]PlayerAction // playerName -> action
actionCh chan playerActionMsg
combatSignal chan struct{}
done chan struct{}
lastActivity map[string]time.Time // fingerprint -> last activity time
mu sync.Mutex
cfg *config.Config
state GameState
started bool
actions map[string]PlayerAction // playerName -> action
actionCh chan playerActionMsg
combatSignal chan struct{}
done chan struct{}
lastActivity map[string]time.Time // fingerprint -> last activity time
HardMode bool
ActiveMutation *Mutation
DailyMode bool
DailyDate string
}
type playerActionMsg struct {
@@ -85,8 +93,9 @@ type playerActionMsg struct {
Action PlayerAction
}
func NewGameSession() *GameSession {
func NewGameSession(cfg *config.Config) *GameSession {
return &GameSession{
cfg: cfg,
state: GameState{
FloorNum: 1,
},
@@ -140,6 +149,7 @@ func (s *GameSession) combatLoop() {
s.mu.Unlock()
if gameOver {
slog.Info("game over", "floor", s.state.FloorNum, "victory", s.state.Victory)
return
}
@@ -152,6 +162,7 @@ func (s *GameSession) combatLoop() {
if p.Fingerprint != "" && !p.IsOut() {
if last, ok := s.lastActivity[p.Fingerprint]; ok {
if now.Sub(last) > 60*time.Second {
slog.Warn("player inactive removed", "fingerprint", p.Fingerprint, "name", p.Name)
s.addLog(fmt.Sprintf("%s removed (disconnected)", p.Name))
changed = true
continue
@@ -192,13 +203,21 @@ func (s *GameSession) signalCombat() {
func (s *GameSession) AddPlayer(p *entity.Player) {
s.mu.Lock()
defer s.mu.Unlock()
if p.Skills == nil {
p.Skills = &entity.PlayerSkills{BranchIndex: -1}
}
s.state.Players = append(s.state.Players, p)
}
func (s *GameSession) StartFloor() {
s.mu.Lock()
defer s.mu.Unlock()
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum)
if s.DailyMode {
seed := DailySeed(s.DailyDate) + int64(s.state.FloorNum)
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum, rand.New(rand.NewSource(seed)))
} else {
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum, rand.New(rand.NewSource(time.Now().UnixNano())))
}
s.state.Phase = PhaseExploring
s.state.TurnNum = 0
@@ -224,6 +243,10 @@ func (s *GameSession) GetState() GameState {
copy(cp.Relics, p.Relics)
cp.Effects = make([]entity.ActiveEffect, len(p.Effects))
copy(cp.Effects, p.Effects)
if p.Skills != nil {
skillsCopy := *p.Skills
cp.Skills = &skillsCopy
}
players[i] = &cp
}
@@ -330,6 +353,21 @@ func (s *GameSession) TouchActivity(fingerprint string) {
s.lastActivity[fingerprint] = time.Now()
}
// AllocateSkillPoint spends one skill point into the given branch for the player.
func (s *GameSession) AllocateSkillPoint(fingerprint string, branchIdx int) error {
s.mu.Lock()
defer s.mu.Unlock()
for _, p := range s.state.Players {
if p.Fingerprint == fingerprint {
if p.Skills == nil || p.Skills.Points <= p.Skills.Allocated {
return fmt.Errorf("no skill points available")
}
return p.Skills.Allocate(branchIdx, p.Class)
}
}
return fmt.Errorf("player not found")
}
// BuyItem handles shop purchases
func (s *GameSession) BuyItem(playerID string, itemIdx int) bool {
s.mu.Lock()
@@ -340,7 +378,7 @@ func (s *GameSession) BuyItem(playerID string, itemIdx int) bool {
item := s.state.ShopItems[itemIdx]
for _, p := range s.state.Players {
if p.Fingerprint == playerID && p.Gold >= item.Price {
if len(p.Inventory) >= 10 {
if len(p.Inventory) >= s.cfg.Game.InventoryLimit {
return false
}
p.Gold -= item.Price
@@ -355,7 +393,11 @@ func (s *GameSession) BuyItem(playerID string, itemIdx int) bool {
func (s *GameSession) SendChat(playerName, message string) {
s.mu.Lock()
defer s.mu.Unlock()
s.addLog(fmt.Sprintf("[%s] %s", playerName, message))
if emoteText, ok := ParseEmote(message); ok {
s.addLog(fmt.Sprintf("✨ %s %s", playerName, emoteText))
} else {
s.addLog(fmt.Sprintf("[%s] %s", playerName, message))
}
}
// LeaveShop exits the shop phase

View File

@@ -4,11 +4,18 @@ import (
"testing"
"time"
"github.com/tolelom/catacombs/config"
"github.com/tolelom/catacombs/entity"
)
func testCfg(t *testing.T) *config.Config {
t.Helper()
cfg, _ := config.Load("")
return cfg
}
func TestGetStateNoRace(t *testing.T) {
s := NewGameSession()
s := NewGameSession(testCfg(t))
p := entity.NewPlayer("Racer", entity.ClassWarrior)
p.Fingerprint = "test-fp"
s.AddPlayer(p)
@@ -40,7 +47,7 @@ func TestGetStateNoRace(t *testing.T) {
}
func TestSessionTurnTimeout(t *testing.T) {
s := NewGameSession()
s := NewGameSession(testCfg(t))
p := entity.NewPlayer("test", entity.ClassWarrior)
p.Fingerprint = "test-fp"
s.AddPlayer(p)
@@ -62,7 +69,7 @@ func TestSessionTurnTimeout(t *testing.T) {
}
func TestRevealNextLog(t *testing.T) {
s := NewGameSession()
s := NewGameSession(testCfg(t))
// No logs to reveal
if s.RevealNextLog() {
@@ -95,7 +102,7 @@ func TestRevealNextLog(t *testing.T) {
}
func TestDeepCopyIndependence(t *testing.T) {
s := NewGameSession()
s := NewGameSession(testCfg(t))
p := entity.NewPlayer("Test", entity.ClassWarrior)
p.Fingerprint = "fp-test"
p.Inventory = append(p.Inventory, entity.Item{Name: "Sword", Type: entity.ItemWeapon, Bonus: 5})
@@ -118,7 +125,7 @@ func TestDeepCopyIndependence(t *testing.T) {
}
func TestBuyItemInventoryFull(t *testing.T) {
s := NewGameSession()
s := NewGameSession(testCfg(t))
p := entity.NewPlayer("Buyer", entity.ClassWarrior)
p.Fingerprint = "fp-buyer"
p.Gold = 1000
@@ -141,7 +148,7 @@ func TestBuyItemInventoryFull(t *testing.T) {
}
func TestSendChat(t *testing.T) {
s := NewGameSession()
s := NewGameSession(testCfg(t))
s.SendChat("Alice", "hello")
st := s.GetState()
if len(st.CombatLog) != 1 || st.CombatLog[0] != "[Alice] hello" {

View File

@@ -10,8 +10,6 @@ import (
"github.com/tolelom/catacombs/entity"
)
const TurnTimeout = 5 * time.Second
func (s *GameSession) RunTurn() {
s.mu.Lock()
s.state.TurnNum++
@@ -28,9 +26,10 @@ func (s *GameSession) RunTurn() {
s.mu.Unlock()
// Collect actions with timeout
timer := time.NewTimer(TurnTimeout)
turnTimeout := time.Duration(s.cfg.Game.TurnTimeoutSec) * time.Second
timer := time.NewTimer(turnTimeout)
s.mu.Lock()
s.state.TurnDeadline = time.Now().Add(TurnTimeout)
s.state.TurnDeadline = time.Now().Add(turnTimeout)
s.mu.Unlock()
collected := 0
@@ -73,13 +72,30 @@ collecting:
}
func (s *GameSession) resolvePlayerActions() {
// Tick status effects
// Tick status effects with floor theme damage bonus
theme := dungeon.GetTheme(s.state.FloorNum)
for _, p := range s.state.Players {
if !p.IsOut() {
// Snapshot effects before tick to compute theme bonus
effectsBefore := make([]entity.ActiveEffect, len(p.Effects))
copy(effectsBefore, p.Effects)
msgs := p.TickEffects()
for _, msg := range msgs {
s.addLog(msg)
}
// Apply theme damage bonus for matching status effects
for _, e := range effectsBefore {
if e.Value > 0 && (theme.StatusBoost == entity.StatusEffect(-1) || e.Type == theme.StatusBoost) {
bonus := int(float64(e.Value) * (theme.DamageMult - 1.0))
if bonus > 0 {
p.TakeDamage(bonus)
s.addLog(fmt.Sprintf(" (%s theme: +%d damage)", theme.Name, bonus))
}
}
}
if p.IsDead() {
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
}
@@ -131,10 +147,15 @@ func (s *GameSession) resolvePlayerActions() {
}
s.addLog(fmt.Sprintf("%s used Taunt! Enemies focus on %s for 2 turns", p.Name, p.Name))
case entity.ClassMage:
skillPower := 0
if p.Skills != nil {
skillPower = p.Skills.GetSkillPower(p.Class)
}
multiplier := 0.8 + float64(skillPower)/100.0
intents = append(intents, combat.AttackIntent{
PlayerATK: p.EffectiveATK(),
TargetIdx: -1,
Multiplier: 0.8,
Multiplier: multiplier,
IsAoE: true,
})
intentOwners = append(intentOwners, p.Name)
@@ -154,8 +175,12 @@ func (s *GameSession) resolvePlayerActions() {
}
}
}
healAmount := 30
if p.Skills != nil {
healAmount += p.Skills.GetSkillPower(p.Class) / 2
}
before := target.HP
target.Heal(30)
target.Heal(healAmount)
s.addLog(fmt.Sprintf("%s healed %s for %d HP", p.Name, target.Name, target.HP-before))
case entity.ClassRogue:
currentRoom := s.state.Floor.Rooms[s.state.Floor.CurrentRoom]
@@ -180,7 +205,7 @@ func (s *GameSession) resolvePlayerActions() {
s.addLog(fmt.Sprintf("%s has no items to use!", p.Name))
}
case ActionFlee:
if combat.AttemptFlee() {
if combat.AttemptFlee(s.cfg.Combat.FleeChance) {
s.addLog(fmt.Sprintf("%s fled from battle!", p.Name))
s.state.FleeSucceeded = true
if s.state.SoloMode {
@@ -214,8 +239,43 @@ func (s *GameSession) resolvePlayerActions() {
return
}
// Combo detection: build action map and apply combo effects before resolving attacks
comboActions := make(map[string]combat.ComboAction)
for _, p := range s.state.Players {
if p.IsOut() {
continue
}
action, ok := s.actions[p.Fingerprint]
if !ok {
continue
}
var actionType string
switch action.Type {
case ActionAttack:
actionType = "attack"
case ActionSkill:
actionType = "skill"
case ActionItem:
actionType = "item"
default:
continue
}
comboActions[p.Fingerprint] = combat.ComboAction{Class: p.Class, ActionType: actionType}
}
combos := combat.DetectCombos(comboActions)
for _, combo := range combos {
s.addLog(combo.Effect.Message)
for i := range intents {
if combo.Effect.DamageMultiplier > 0 {
intents[i].Multiplier *= combo.Effect.DamageMultiplier
}
intents[i].PlayerATK += combo.Effect.BonusDamage
}
}
if len(intents) > 0 && len(s.state.Monsters) > 0 {
results := combat.ResolveAttacks(intents, s.state.Monsters)
results := combat.ResolveAttacks(intents, s.state.Monsters, s.cfg.Game.CoopBonus)
for i, r := range results {
owner := intentOwners[i]
if r.IsAoE {
@@ -235,6 +295,17 @@ func (s *GameSession) resolvePlayerActions() {
}
}
// Apply combo HealAll effects after attack resolution
for _, combo := range combos {
if combo.Effect.HealAll > 0 {
for _, p := range s.state.Players {
if !p.IsOut() {
p.Heal(combo.Effect.HealAll)
}
}
}
}
// Award gold only for monsters that JUST died this turn
for i, m := range s.state.Monsters {
if m.IsDead() && aliveBeforeTurn[i] {
@@ -287,15 +358,27 @@ func (s *GameSession) resolvePlayerActions() {
}
func (s *GameSession) advanceFloor() {
if s.state.FloorNum >= 20 {
if s.state.FloorNum >= s.cfg.Game.MaxFloors {
s.state.Phase = PhaseResult
s.state.Victory = true
s.state.GameOver = true
s.addLog("You conquered the Catacombs!")
return
}
// Grant 1 skill point per floor clear
for _, p := range s.state.Players {
if p.Skills == nil {
p.Skills = &entity.PlayerSkills{BranchIndex: -1}
}
p.Skills.Points++
}
s.state.FloorNum++
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum)
if s.DailyMode {
seed := DailySeed(s.DailyDate) + int64(s.state.FloorNum)
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum, rand.New(rand.NewSource(seed)))
} else {
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum, rand.New(rand.NewSource(time.Now().UnixNano())))
}
s.state.Phase = PhaseExploring
s.state.CombatTurn = 0
s.addLog(fmt.Sprintf("Descending to B%d...", s.state.FloorNum))
@@ -347,8 +430,8 @@ func (s *GameSession) resolveMonsterActions() {
}
}
}
if m.IsBoss {
// Boss special pattern
if m.IsBoss || m.IsMiniBoss {
// Boss/mini-boss special pattern
switch m.Pattern {
case entity.PatternPoison:
for _, p := range s.state.Players {
@@ -364,6 +447,13 @@ func (s *GameSession) resolveMonsterActions() {
s.addLog(fmt.Sprintf("%s burns %s!", m.Name, p.Name))
}
}
case entity.PatternFreeze:
for _, p := range s.state.Players {
if !p.IsOut() {
p.AddEffect(entity.ActiveEffect{Type: entity.StatusFreeze, Duration: 1, Value: 0})
s.addLog(fmt.Sprintf("%s freezes %s!", m.Name, p.Name))
}
}
case entity.PatternHeal:
healAmt := m.MaxHP / 10
m.HP += healAmt
@@ -380,6 +470,17 @@ func (s *GameSession) resolveMonsterActions() {
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0)
p.TakeDamage(dmg)
s.addLog(fmt.Sprintf("%s attacks %s for %d dmg", m.Name, p.Name, dmg))
if m.IsElite {
def := entity.ElitePrefixDefs[m.ElitePrefix]
if def.OnHit >= 0 {
p.AddEffect(entity.ActiveEffect{Type: def.OnHit, Duration: 2, Value: 3})
s.addLog(fmt.Sprintf("%s's %s effect afflicts %s!", m.Name, def.Name, p.Name))
} else if m.ElitePrefix == entity.PrefixVampiric {
heal := dmg / 4
m.HP = min(m.HP+heal, m.MaxHP)
s.addLog(fmt.Sprintf("%s drains life from %s! (+%d HP)", m.Name, p.Name, heal))
}
}
if p.IsDead() {
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
}

1
go.mod
View File

@@ -41,4 +41,5 @@ require (
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.23.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

1
go.sum
View File

@@ -79,5 +79,6 @@ golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

114
main.go
View File

@@ -1,9 +1,16 @@
package main
import (
"context"
"fmt"
"log"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/tolelom/catacombs/config"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/server"
"github.com/tolelom/catacombs/store"
@@ -13,23 +20,114 @@ import (
func main() {
os.MkdirAll("data", 0755)
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
cfg, err := config.Load("config.yaml")
if err != nil {
if os.IsNotExist(err) {
cfg, _ = config.Load("")
} else {
log.Fatalf("Failed to load config: %v", err)
}
}
db, err := store.Open("data/catacombs.db")
if err != nil {
log.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
lobby := game.NewLobby()
lobby := game.NewLobby(cfg)
startTime := time.Now()
// Start web terminal server in background
sshAddr := fmt.Sprintf("0.0.0.0:%d", cfg.Server.SSHPort)
webAddr := fmt.Sprintf(":%d", cfg.Server.HTTPPort)
// Start web server (non-blocking, returns *http.Server)
webServer := web.Start(webAddr, cfg.Server.SSHPort, lobby, db, startTime)
// Create SSH server
sshServer, err := server.NewServer(sshAddr, lobby, db)
if err != nil {
log.Fatalf("Failed to create SSH server: %v", err)
}
// Start backup scheduler
backupDone := make(chan struct{})
go backupScheduler(db, cfg.Backup, backupDone)
// Start SSH server in background
sshErrCh := make(chan error, 1)
go func() {
if err := web.Start(":8080", 2222); err != nil {
log.Printf("Web server error: %v", err)
}
slog.Info("starting SSH server", "addr", sshAddr)
sshErrCh <- sshServer.ListenAndServe()
}()
log.Println("Catacombs server starting — SSH :2222, Web :8080")
if err := server.Start("0.0.0.0", 2222, lobby, db); err != nil {
log.Fatal(err)
slog.Info("server starting", "ssh_port", cfg.Server.SSHPort, "http_port", cfg.Server.HTTPPort)
// Wait for shutdown signal or SSH server error
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-sigCh:
slog.Info("shutdown signal received", "signal", sig)
case err := <-sshErrCh:
if err != nil {
slog.Error("SSH server error", "error", err)
}
}
// Graceful shutdown
slog.Info("starting graceful shutdown")
// Stop backup scheduler
close(backupDone)
// Shutdown web server (5s timeout)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := webServer.Shutdown(ctx); err != nil {
slog.Error("web server shutdown error", "error", err)
}
// Shutdown SSH server (10s timeout for active sessions)
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel2()
if err := sshServer.Shutdown(ctx2); err != nil {
slog.Error("SSH server shutdown error", "error", err)
}
// Final backup before exit
if path, err := db.Backup(cfg.Backup.Dir); err != nil {
slog.Error("final backup failed", "error", err)
} else {
slog.Info("final backup completed", "path", path)
}
slog.Info("server stopped")
}
func backupScheduler(db *store.DB, cfg config.BackupConfig, done chan struct{}) {
if cfg.IntervalMin <= 0 {
return
}
ticker := time.NewTicker(time.Duration(cfg.IntervalMin) * time.Minute)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
if path, err := db.Backup(cfg.Dir); err != nil {
slog.Error("scheduled backup failed", "error", err)
} else {
slog.Info("scheduled backup completed", "path", path)
}
}
}
}

View File

@@ -2,7 +2,7 @@ package server
import (
"fmt"
"log"
"log/slog"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
@@ -14,15 +14,17 @@ import (
"github.com/tolelom/catacombs/ui"
)
func Start(host string, port int, lobby *game.Lobby, db *store.DB) error {
// NewServer creates the SSH server but does not start it.
// The caller is responsible for calling ListenAndServe() and Shutdown().
func NewServer(addr string, lobby *game.Lobby, db *store.DB) (*ssh.Server, error) {
s, err := wish.NewServer(
wish.WithAddress(fmt.Sprintf("%s:%d", host, port)),
wish.WithAddress(addr),
wish.WithHostKeyPath(".ssh/catacombs_host_key"),
wish.WithPublicKeyAuth(func(_ ssh.Context, _ ssh.PublicKey) bool {
return true // accept all keys
return true
}),
wish.WithPasswordAuth(func(_ ssh.Context, _ string) bool {
return true // accept any password (game server, not secure shell)
return true
}),
wish.WithMiddleware(
bubbletea.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
@@ -31,15 +33,31 @@ func Start(host string, port int, lobby *game.Lobby, db *store.DB) error {
if s.PublicKey() != nil {
fingerprint = gossh.FingerprintSHA256(s.PublicKey())
}
defer func() {
if r := recover(); r != nil {
slog.Error("session panic recovered", "error", r, "fingerprint", fingerprint)
}
}()
slog.Info("new SSH session", "fingerprint", fingerprint, "width", pty.Window.Width, "height", pty.Window.Height)
m := ui.NewModel(pty.Window.Width, pty.Window.Height, fingerprint, lobby, db)
return m, []tea.ProgramOption{tea.WithAltScreen()}
}),
),
)
if err != nil {
return fmt.Errorf("could not create server: %w", err)
return nil, fmt.Errorf("could not create server: %w", err)
}
return s, nil
}
log.Printf("Starting SSH server on %s:%d", host, port)
// Start creates and starts the SSH server (blocking).
func Start(addr string, lobby *game.Lobby, db *store.DB) error {
s, err := NewServer(addr, lobby, db)
if err != nil {
return err
}
slog.Info("starting SSH server", "addr", addr)
return s.ListenAndServe()
}

40
store/backup.go Normal file
View File

@@ -0,0 +1,40 @@
package store
import (
"fmt"
"os"
"path/filepath"
"time"
bolt "go.etcd.io/bbolt"
)
// Backup creates a consistent snapshot of the database in destDir.
// Returns the path to the backup file.
func (d *DB) Backup(destDir string) (string, error) {
if err := os.MkdirAll(destDir, 0755); err != nil {
return "", fmt.Errorf("create backup dir: %w", err)
}
timestamp := time.Now().Format("20060102-150405")
filename := fmt.Sprintf("catacombs-%s.db", timestamp)
destPath := filepath.Join(destDir, filename)
f, err := os.Create(destPath)
if err != nil {
return "", fmt.Errorf("create backup file: %w", err)
}
defer f.Close()
// BoltDB View transaction provides a consistent snapshot
err = d.db.View(func(tx *bolt.Tx) error {
_, err := tx.WriteTo(f)
return err
})
if err != nil {
os.Remove(destPath)
return "", fmt.Errorf("backup write: %w", err)
}
return destPath, nil
}

68
store/backup_test.go Normal file
View File

@@ -0,0 +1,68 @@
package store
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestBackup(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := Open(dbPath)
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
defer db.Close()
if err := db.SaveProfile("fp1", "player1"); err != nil {
t.Fatalf("failed to save profile: %v", err)
}
backupDir := filepath.Join(tmpDir, "backups")
backupPath, err := db.Backup(backupDir)
if err != nil {
t.Fatalf("backup failed: %v", err)
}
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
t.Fatal("backup file does not exist")
}
base := filepath.Base(backupPath)
if !strings.HasPrefix(base, "catacombs-") || !strings.HasSuffix(base, ".db") {
t.Fatalf("unexpected backup filename: %s", base)
}
backupDB, err := Open(backupPath)
if err != nil {
t.Fatalf("failed to open backup: %v", err)
}
defer backupDB.Close()
name, err := backupDB.GetProfile("fp1")
if err != nil {
t.Fatalf("failed to read from backup: %v", err)
}
if name != "player1" {
t.Fatalf("expected player1, got %s", name)
}
}
func TestBackupCreatesDir(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := Open(dbPath)
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
defer db.Close()
backupDir := filepath.Join(tmpDir, "nested", "backups")
_, err = db.Backup(backupDir)
if err != nil {
t.Fatalf("backup with nested dir failed: %v", err)
}
}

88
store/codex.go Normal file
View File

@@ -0,0 +1,88 @@
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 newCodex() Codex {
return Codex{
Monsters: make(map[string]bool),
Items: make(map[string]bool),
Events: make(map[string]bool),
}
}
func (d *DB) RecordCodexEntry(fingerprint, category, id string) error {
return d.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketCodex)
key := []byte(fingerprint)
codex := newCodex()
v := b.Get(key)
if v != nil {
if err := json.Unmarshal(v, &codex); err != nil {
return err
}
// Ensure maps are initialized after unmarshal
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
}
data, err := json.Marshal(codex)
if err != nil {
return err
}
return b.Put(key, data)
})
}
func (d *DB) GetCodex(fingerprint string) (Codex, error) {
codex := newCodex()
err := d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketCodex)
v := b.Get([]byte(fingerprint))
if v == nil {
return nil
}
if err := json.Unmarshal(v, &codex); err != nil {
return err
}
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 nil
})
return codex, err
}

71
store/codex_test.go Normal file
View File

@@ -0,0 +1,71 @@
package store
import (
"testing"
)
func TestRecordCodexEntry(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir + "/test_codex.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
// Record some entries
db.RecordCodexEntry("fp1", "monster", "goblin")
db.RecordCodexEntry("fp1", "monster", "skeleton")
db.RecordCodexEntry("fp1", "item", "health_potion")
db.RecordCodexEntry("fp1", "event", "altar")
codex, err := db.GetCodex("fp1")
if err != nil {
t.Fatal(err)
}
if len(codex.Monsters) != 2 {
t.Errorf("expected 2 monsters, got %d", len(codex.Monsters))
}
if !codex.Monsters["goblin"] {
t.Error("expected goblin in monsters")
}
if !codex.Monsters["skeleton"] {
t.Error("expected skeleton in monsters")
}
if len(codex.Items) != 1 {
t.Errorf("expected 1 item, got %d", len(codex.Items))
}
if !codex.Items["health_potion"] {
t.Error("expected health_potion in items")
}
if len(codex.Events) != 1 {
t.Errorf("expected 1 event, got %d", len(codex.Events))
}
if !codex.Events["altar"] {
t.Error("expected altar in events")
}
// Duplicate entry should not increase count
db.RecordCodexEntry("fp1", "monster", "goblin")
codex2, _ := db.GetCodex("fp1")
if len(codex2.Monsters) != 2 {
t.Errorf("expected still 2 monsters after duplicate, got %d", len(codex2.Monsters))
}
}
func TestGetCodexEmpty(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir + "/test_codex_empty.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
codex, err := db.GetCodex("fp_unknown")
if err != nil {
t.Fatal(err)
}
if len(codex.Monsters) != 0 || len(codex.Items) != 0 || len(codex.Events) != 0 {
t.Error("expected empty codex for unknown player")
}
}

81
store/daily.go Normal file
View File

@@ -0,0 +1,81 @@
package store
import (
"encoding/json"
"sort"
"time"
bolt "go.etcd.io/bbolt"
)
var bucketDailyRuns = []byte("daily_runs")
type DailyRecord struct {
Date string `json:"date"`
Player string `json:"player"`
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, error) {
streak := 0
date, err := time.Parse("2006-01-02", currentDate)
if err != nil {
return 0, err
}
err = d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketDailyRuns)
for {
key := []byte(date.Format("2006-01-02") + ":" + fingerprint)
if b.Get(key) == nil {
break
}
streak++
date = date.AddDate(0, 0, -1)
}
return nil
})
return streak, err
}

66
store/daily_test.go Normal file
View File

@@ -0,0 +1,66 @@
package store
import (
"testing"
)
func TestSaveAndGetDaily(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir + "/test_daily.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.SaveDaily(DailyRecord{Date: "2026-03-25", Player: "fp1", PlayerName: "Alice", FloorReached: 10, GoldEarned: 200})
db.SaveDaily(DailyRecord{Date: "2026-03-25", Player: "fp2", PlayerName: "Bob", FloorReached: 15, GoldEarned: 100})
db.SaveDaily(DailyRecord{Date: "2026-03-25", Player: "fp3", PlayerName: "Charlie", FloorReached: 15, GoldEarned: 300})
records, err := db.GetDailyLeaderboard("2026-03-25", 10)
if err != nil {
t.Fatal(err)
}
if len(records) != 3 {
t.Fatalf("expected 3 records, got %d", len(records))
}
// Charlie and Bob both floor 15, Charlie has more gold so first
if records[0].PlayerName != "Charlie" {
t.Errorf("expected Charlie first, got %s", records[0].PlayerName)
}
if records[1].PlayerName != "Bob" {
t.Errorf("expected Bob second, got %s", records[1].PlayerName)
}
if records[2].PlayerName != "Alice" {
t.Errorf("expected Alice third, got %s", records[2].PlayerName)
}
}
func TestDailyStreak(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir + "/test_streak.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.SaveDaily(DailyRecord{Date: "2026-03-23", Player: "fp1", PlayerName: "Alice", FloorReached: 5, GoldEarned: 50})
db.SaveDaily(DailyRecord{Date: "2026-03-24", Player: "fp1", PlayerName: "Alice", FloorReached: 8, GoldEarned: 100})
db.SaveDaily(DailyRecord{Date: "2026-03-25", Player: "fp1", PlayerName: "Alice", FloorReached: 10, GoldEarned: 200})
streak, err := db.GetStreak("fp1", "2026-03-25")
if err != nil {
t.Fatal(err)
}
if streak != 3 {
t.Errorf("expected streak 3, got %d", streak)
}
// Gap in streak
streak2, err := db.GetStreak("fp1", "2026-03-27")
if err != nil {
t.Fatal(err)
}
if streak2 != 0 {
t.Errorf("expected streak 0 after gap, got %d", streak2)
}
}

View File

@@ -39,6 +39,18 @@ func Open(path string) (*DB, error) {
if _, err := tx.CreateBucketIfNotExists(bucketAchievements); err != nil {
return err
}
if _, err := tx.CreateBucketIfNotExists(bucketDailyRuns); err != nil {
return err
}
if _, err := tx.CreateBucketIfNotExists(bucketUnlocks); err != nil {
return err
}
if _, err := tx.CreateBucketIfNotExists(bucketTitles); err != nil {
return err
}
if _, err := tx.CreateBucketIfNotExists(bucketCodex); err != nil {
return err
}
return nil
})
return &DB{db: db}, err

48
store/stats.go Normal file
View File

@@ -0,0 +1,48 @@
package store
import (
"encoding/json"
"time"
bolt "go.etcd.io/bbolt"
)
// GetTodayRunCount returns the number of daily challenge runs for today.
func (d *DB) GetTodayRunCount() (int, error) {
today := time.Now().Format("2006-01-02")
count := 0
err := d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketDailyRuns)
c := b.Cursor()
prefix := []byte(today + ":")
for k, _ := c.Seek(prefix); k != nil && len(k) >= len(prefix) && string(k[:len(prefix)]) == string(prefix); k, _ = c.Next() {
count++
}
return nil
})
return count, err
}
// GetTodayAvgFloor returns the average floor reached in today's daily runs.
func (d *DB) GetTodayAvgFloor() (float64, error) {
today := time.Now().Format("2006-01-02")
total := 0
count := 0
err := d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketDailyRuns)
c := b.Cursor()
prefix := []byte(today + ":")
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 {
total += r.FloorReached
count++
}
}
return nil
})
if count == 0 {
return 0, err
}
return float64(total) / float64(count), err
}

69
store/stats_test.go Normal file
View File

@@ -0,0 +1,69 @@
package store
import (
"path/filepath"
"testing"
"time"
)
func TestGetTodayRunCount(t *testing.T) {
tmpDir := t.TempDir()
db, err := Open(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
today := time.Now().Format("2006-01-02")
count, err := db.GetTodayRunCount()
if err != nil {
t.Fatalf("GetTodayRunCount: %v", err)
}
if count != 0 {
t.Fatalf("expected 0, got %d", count)
}
db.SaveDaily(DailyRecord{Date: today, Player: "fp1", PlayerName: "A", FloorReached: 10, GoldEarned: 100})
db.SaveDaily(DailyRecord{Date: today, Player: "fp2", PlayerName: "B", FloorReached: 15, GoldEarned: 200})
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
db.SaveDaily(DailyRecord{Date: yesterday, Player: "fp3", PlayerName: "C", FloorReached: 5, GoldEarned: 50})
count, err = db.GetTodayRunCount()
if err != nil {
t.Fatalf("GetTodayRunCount: %v", err)
}
if count != 2 {
t.Fatalf("expected 2, got %d", count)
}
}
func TestGetTodayAvgFloor(t *testing.T) {
tmpDir := t.TempDir()
db, err := Open(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
today := time.Now().Format("2006-01-02")
avg, err := db.GetTodayAvgFloor()
if err != nil {
t.Fatalf("GetTodayAvgFloor: %v", err)
}
if avg != 0 {
t.Fatalf("expected 0, got %f", avg)
}
db.SaveDaily(DailyRecord{Date: today, Player: "fp1", PlayerName: "A", FloorReached: 10, GoldEarned: 100})
db.SaveDaily(DailyRecord{Date: today, Player: "fp2", PlayerName: "B", FloorReached: 20, GoldEarned: 200})
avg, err = db.GetTodayAvgFloor()
if err != nil {
t.Fatalf("GetTodayAvgFloor: %v", err)
}
if avg != 15.0 {
t.Fatalf("expected 15.0, got %f", avg)
}
}

115
store/titles.go Normal file
View File

@@ -0,0 +1,115 @@
package store
import (
"encoding/json"
bolt "go.etcd.io/bbolt"
)
var bucketTitles = []byte("titles")
type TitleDef struct {
ID string
Name string
Description string
}
var TitleDefs = []TitleDef{
{ID: "novice", Name: "Novice", Description: "Default title for new players"},
{ID: "explorer", Name: "Explorer", Description: "Reach floor 5"},
{ID: "veteran", Name: "Veteran", Description: "Reach floor 10"},
{ID: "champion", Name: "Champion", Description: "Reach floor 20"},
{ID: "gold_king", Name: "Gold King", Description: "Accumulate 500+ gold in one run"},
{ID: "team_player", Name: "Team Player", Description: "Complete 5 multiplayer runs"},
{ID: "survivor", Name: "Survivor", Description: "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) {
newlyEarned := false
err := d.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketTitles)
key := []byte(fingerprint)
var data PlayerTitleData
v := b.Get(key)
if v != nil {
if err := json.Unmarshal(v, &data); err != nil {
return err
}
}
// Check if already earned
for _, e := range data.Earned {
if e == titleID {
return nil
}
}
newlyEarned = true
data.Earned = append(data.Earned, titleID)
// Auto-set first earned title as active
if data.ActiveTitle == "" {
data.ActiveTitle = titleID
}
encoded, err := json.Marshal(data)
if err != nil {
return err
}
return b.Put(key, encoded)
})
return newlyEarned, err
}
func (d *DB) SetActiveTitle(fingerprint, titleID string) error {
return d.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketTitles)
key := []byte(fingerprint)
var data PlayerTitleData
v := b.Get(key)
if v != nil {
if err := json.Unmarshal(v, &data); err != nil {
return err
}
}
// Verify title is earned
found := false
for _, e := range data.Earned {
if e == titleID {
found = true
break
}
}
if !found {
return nil
}
data.ActiveTitle = titleID
encoded, err := json.Marshal(data)
if err != nil {
return err
}
return b.Put(key, encoded)
})
}
func (d *DB) GetTitleData(fingerprint string) (PlayerTitleData, error) {
var data PlayerTitleData
err := d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketTitles)
v := b.Get([]byte(fingerprint))
if v == nil {
return nil
}
return json.Unmarshal(v, &data)
})
return data, err
}

115
store/titles_test.go Normal file
View File

@@ -0,0 +1,115 @@
package store
import (
"testing"
)
func TestEarnTitle(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir + "/test_titles.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
// Earn first title - should be newly earned and set as active
newlyEarned, err := db.EarnTitle("fp1", "novice")
if err != nil {
t.Fatal(err)
}
if !newlyEarned {
t.Error("should be newly earned")
}
data, err := db.GetTitleData("fp1")
if err != nil {
t.Fatal(err)
}
if data.ActiveTitle != "novice" {
t.Errorf("active title should be novice, got %s", data.ActiveTitle)
}
if len(data.Earned) != 1 {
t.Errorf("should have 1 earned title, got %d", len(data.Earned))
}
// Earn second title - active should remain first
newlyEarned2, err := db.EarnTitle("fp1", "explorer")
if err != nil {
t.Fatal(err)
}
if !newlyEarned2 {
t.Error("explorer should be newly earned")
}
data2, err := db.GetTitleData("fp1")
if err != nil {
t.Fatal(err)
}
if data2.ActiveTitle != "novice" {
t.Errorf("active title should remain novice, got %s", data2.ActiveTitle)
}
if len(data2.Earned) != 2 {
t.Errorf("should have 2 earned titles, got %d", len(data2.Earned))
}
// Duplicate earn returns false
dup, err := db.EarnTitle("fp1", "novice")
if err != nil {
t.Fatal(err)
}
if dup {
t.Error("duplicate earn should return false")
}
}
func TestSetActiveTitle(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir + "/test_titles_active.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.EarnTitle("fp1", "novice")
db.EarnTitle("fp1", "explorer")
err = db.SetActiveTitle("fp1", "explorer")
if err != nil {
t.Fatal(err)
}
data, err := db.GetTitleData("fp1")
if err != nil {
t.Fatal(err)
}
if data.ActiveTitle != "explorer" {
t.Errorf("active title should be explorer, got %s", data.ActiveTitle)
}
// Setting unearned title should be a no-op
db.SetActiveTitle("fp1", "champion")
data2, _ := db.GetTitleData("fp1")
if data2.ActiveTitle != "explorer" {
t.Errorf("active title should remain explorer, got %s", data2.ActiveTitle)
}
}
func TestGetTitleDataEmpty(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir + "/test_titles_empty.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
data, err := db.GetTitleData("fp_unknown")
if err != nil {
t.Fatal(err)
}
if data.ActiveTitle != "" {
t.Errorf("expected empty active title, got %s", data.ActiveTitle)
}
if len(data.Earned) != 0 {
t.Errorf("expected no earned titles, got %d", len(data.Earned))
}
}

47
store/unlocks.go Normal file
View File

@@ -0,0 +1,47 @@
package store
import (
bolt "go.etcd.io/bbolt"
)
var bucketUnlocks = []byte("unlocks")
type UnlockDef struct {
ID string
Name string
Description string
Condition string
}
var UnlockDefs = []UnlockDef{
{ID: "fifth_class", Name: "Fifth Class", Description: "Reach floor 10 or higher", Condition: "floor 10+"},
{ID: "hard_mode", Name: "Hard Mode", Description: "Clear the game with 3+ players", Condition: "3+ player clear"},
{ID: "mutations", Name: "Mutations", Description: "Achieve victory on floor 20", Condition: "floor 20 victory"},
}
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)
unlocked := false
d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketUnlocks)
if b.Get(key) != nil {
unlocked = true
}
return nil
})
return unlocked
}

47
store/unlocks_test.go Normal file
View File

@@ -0,0 +1,47 @@
package store
import (
"testing"
)
func TestUnlockContent(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir + "/test_unlocks.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
// Initially not unlocked
if db.IsUnlocked("fp1", "fifth_class") {
t.Error("should not be unlocked initially")
}
// Unlock returns true for new unlock
newlyUnlocked, err := db.UnlockContent("fp1", "fifth_class")
if err != nil {
t.Fatal(err)
}
if !newlyUnlocked {
t.Error("should be newly unlocked")
}
// Now it should be unlocked
if !db.IsUnlocked("fp1", "fifth_class") {
t.Error("should be unlocked after UnlockContent")
}
// Second unlock returns false (already unlocked)
newlyUnlocked2, err := db.UnlockContent("fp1", "fifth_class")
if err != nil {
t.Fatal(err)
}
if newlyUnlocked2 {
t.Error("should not be newly unlocked on second call")
}
// Different player still not unlocked
if db.IsUnlocked("fp2", "fifth_class") {
t.Error("different player should not have unlock")
}
}

View File

@@ -3,10 +3,35 @@ package ui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/store"
)
// AchievementsScreen shows the player's achievements.
type AchievementsScreen struct{}
func NewAchievementsScreen() *AchievementsScreen {
return &AchievementsScreen{}
}
func (s *AchievementsScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isKey(key, "a") || isEnter(key) || isQuit(key) {
return NewTitleScreen(), nil
}
}
return s, nil
}
func (s *AchievementsScreen) View(ctx *Context) string {
var achievements []store.Achievement
if ctx.Store != nil {
achievements, _ = ctx.Store.GetAchievements(ctx.PlayerName)
}
return renderAchievements(ctx.PlayerName, achievements, ctx.Width, ctx.Height)
}
func renderAchievements(playerName string, achievements []store.Achievement, width, height int) string {
title := styleHeader.Render("── Achievements ──")

View File

@@ -3,10 +3,64 @@ package ui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/entity"
"github.com/tolelom/catacombs/game"
)
// ClassSelectScreen lets the player choose a class before entering the game.
type ClassSelectScreen struct {
cursor int
}
func NewClassSelectScreen() *ClassSelectScreen {
return &ClassSelectScreen{}
}
func (s *ClassSelectScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isUp(key) {
if s.cursor > 0 {
s.cursor--
}
} else if isDown(key) {
if s.cursor < len(classOptions)-1 {
s.cursor++
}
} else if isEnter(key) {
if ctx.Lobby != nil {
selectedClass := classOptions[s.cursor].class
ctx.Lobby.SetPlayerClass(ctx.RoomCode, ctx.Fingerprint, selectedClass.String())
room := ctx.Lobby.GetRoom(ctx.RoomCode)
if room != nil {
if room.Session == nil {
room.Session = game.NewGameSession(ctx.Lobby.Cfg())
}
ctx.Session = room.Session
player := entity.NewPlayer(ctx.PlayerName, selectedClass)
player.Fingerprint = ctx.Fingerprint
ctx.Session.AddPlayer(player)
if ctx.Lobby != nil {
ctx.Lobby.RegisterSession(ctx.Fingerprint, ctx.RoomCode)
}
ctx.Session.StartGame()
ctx.Lobby.StartRoom(ctx.RoomCode)
gs := NewGameScreen()
gs.gameState = ctx.Session.GetState()
return gs, gs.pollState()
}
}
}
}
return s, nil
}
func (s *ClassSelectScreen) View(ctx *Context) string {
state := classSelectState{cursor: s.cursor}
return renderClassSelect(state, ctx.Width, ctx.Height)
}
type classSelectState struct {
cursor int
}

169
ui/codex_view.go Normal file
View File

@@ -0,0 +1,169 @@
package ui
import (
"fmt"
"sort"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/store"
)
// Total known entries for completion calculation
const (
totalMonsters = 16 // 8 regular + 4 bosses + 4 mini-bosses
totalItems = 15 // weapons + armor + potions + relics
totalEvents = 8 // random events from game/random_event.go
)
// All known entry names for display
var allMonsters = []string{
"Goblin", "Skeleton", "Bat", "Slime", "Zombie", "Spider", "Rat", "Ghost",
"Dragon", "Lich", "Demon Lord", "Hydra",
"Troll", "Wraith", "Golem", "Minotaur",
}
var allItems = []string{
"Iron Sword", "Steel Axe", "Magic Staff", "Shadow Dagger", "Holy Mace",
"Leather Armor", "Chain Mail", "Plate Armor",
"Health Potion", "Mana Potion", "Strength Potion",
"Shield Relic", "Amulet of Life", "Ring of Power", "Boots of Speed",
}
var allEvents = []string{
"altar", "fountain", "merchant", "trap_room",
"shrine", "chest", "ghost", "mushroom",
}
// CodexScreen displays the player's codex with discovered entries.
type CodexScreen struct {
codex store.Codex
tab int // 0=monsters, 1=items, 2=events
}
func NewCodexScreen(ctx *Context) *CodexScreen {
var codex store.Codex
if ctx.Store != nil {
codex, _ = ctx.Store.GetCodex(ctx.Fingerprint)
} else {
codex = store.Codex{
Monsters: make(map[string]bool),
Items: make(map[string]bool),
Events: make(map[string]bool),
}
}
return &CodexScreen{codex: codex}
}
func (s *CodexScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isKey(key, "esc", "c", "q") || key.Type == tea.KeyEsc {
return NewTitleScreen(), nil
}
if key.Type == tea.KeyTab || isKey(key, "right", "l") || key.Type == tea.KeyRight {
s.tab = (s.tab + 1) % 3
}
if isKey(key, "left", "h") || key.Type == tea.KeyLeft {
s.tab = (s.tab + 2) % 3
}
}
return s, nil
}
func (s *CodexScreen) View(ctx *Context) string {
title := styleHeader.Render("-- Codex --")
// Tab headers
tabNames := []string{"Monsters", "Items", "Events"}
var tabs []string
for i, name := range tabNames {
if i == s.tab {
tabs = append(tabs, lipgloss.NewStyle().
Foreground(colorYellow).Bold(true).
Render(fmt.Sprintf("[ %s ]", name)))
} else {
tabs = append(tabs, lipgloss.NewStyle().
Foreground(colorGray).
Render(fmt.Sprintf(" %s ", name)))
}
}
tabBar := lipgloss.JoinHorizontal(lipgloss.Center, tabs...)
// Entries
var entries string
var discovered map[string]bool
var allNames []string
var total int
switch s.tab {
case 0:
discovered = s.codex.Monsters
allNames = allMonsters
total = totalMonsters
case 1:
discovered = s.codex.Items
allNames = allItems
total = totalItems
case 2:
discovered = s.codex.Events
allNames = allEvents
total = totalEvents
}
count := len(discovered)
pct := 0.0
if total > 0 {
pct = float64(count) / float64(total) * 100
}
completion := lipgloss.NewStyle().Foreground(colorCyan).
Render(fmt.Sprintf("Discovered: %d/%d (%.0f%%)", count, total, pct))
// Sort discovered keys for consistent display
discoveredKeys := make([]string, 0, len(discovered))
for k := range discovered {
discoveredKeys = append(discoveredKeys, k)
}
sort.Strings(discoveredKeys)
// Build a set of discovered for quick lookup
discoveredSet := discovered
for _, name := range allNames {
if discoveredSet[name] {
entries += fmt.Sprintf(" [x] %s\n", lipgloss.NewStyle().Foreground(colorGreen).Render(name))
} else {
entries += fmt.Sprintf(" [ ] %s\n", lipgloss.NewStyle().Foreground(colorGray).Render("???"))
}
}
// Show any discovered entries not in the known list
for _, k := range discoveredKeys {
found := false
for _, name := range allNames {
if name == k {
found = true
break
}
}
if !found {
entries += fmt.Sprintf(" [x] %s\n", lipgloss.NewStyle().Foreground(colorGreen).Render(k))
}
}
footer := styleSystem.Render("[Tab/Left/Right] Switch Tab [Esc] Back")
content := lipgloss.JoinVertical(lipgloss.Center,
title,
"",
tabBar,
"",
completion,
"",
entries,
"",
footer,
)
return lipgloss.Place(ctx.Width, ctx.Height, lipgloss.Center, lipgloss.Center, content)
}

19
ui/context.go Normal file
View File

@@ -0,0 +1,19 @@
package ui
import (
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
)
// Context holds shared state accessible to all screens.
type Context struct {
Width int
Height int
Fingerprint string
PlayerName string
Lobby *game.Lobby
Store *store.DB
Session *game.GameSession
RoomCode string
}

View File

@@ -5,15 +5,325 @@ import (
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/dungeon"
"github.com/tolelom/catacombs/entity"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
)
func renderGame(state game.GameState, width, height int, targetCursor int, moveCursor int, chatting bool, chatInput string) string {
// GameScreen handles the main gameplay: exploration, combat, and chat.
type GameScreen struct {
gameState game.GameState
targetCursor int
moveCursor int
chatting bool
chatInput string
rankingSaved bool
codexRecorded map[string]bool
prevPhase game.GamePhase
}
func NewGameScreen() *GameScreen {
return &GameScreen{
codexRecorded: make(map[string]bool),
}
}
func (s *GameScreen) pollState() tea.Cmd {
return tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
return tickMsg{}
})
}
func (s *GameScreen) getNeighbors() []int {
if s.gameState.Floor == nil {
return nil
}
cur := s.gameState.Floor.CurrentRoom
if cur < 0 || cur >= len(s.gameState.Floor.Rooms) {
return nil
}
return s.gameState.Floor.Rooms[cur].Neighbors
}
func (s *GameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
if ctx.Session != nil && ctx.Fingerprint != "" {
ctx.Session.TouchActivity(ctx.Fingerprint)
}
// Refresh state on every update
if ctx.Session != nil {
s.gameState = ctx.Session.GetState()
// Clamp target cursor to valid range after monsters die
if len(s.gameState.Monsters) > 0 {
if s.targetCursor >= len(s.gameState.Monsters) {
s.targetCursor = len(s.gameState.Monsters) - 1
}
} else {
s.targetCursor = 0
}
// Record codex entries for monsters when entering combat
if ctx.Store != nil && s.gameState.Phase == game.PhaseCombat {
for _, m := range s.gameState.Monsters {
key := "monster:" + m.Name
if !s.codexRecorded[key] {
ctx.Store.RecordCodexEntry(ctx.Fingerprint, "monster", m.Name)
s.codexRecorded[key] = true
}
}
}
// Record codex entries for shop items when entering shop
if ctx.Store != nil && s.gameState.Phase == game.PhaseShop && s.prevPhase != game.PhaseShop {
for _, item := range s.gameState.ShopItems {
key := "item:" + item.Name
if !s.codexRecorded[key] {
ctx.Store.RecordCodexEntry(ctx.Fingerprint, "item", item.Name)
s.codexRecorded[key] = true
}
}
}
s.prevPhase = s.gameState.Phase
}
if s.gameState.GameOver {
if ctx.Store != nil && !s.rankingSaved {
score := 0
for _, p := range s.gameState.Players {
score += p.Gold
}
playerClass := ""
for _, p := range s.gameState.Players {
if p.Fingerprint == ctx.Fingerprint {
playerClass = p.Class.String()
break
}
}
ctx.Store.SaveRun(ctx.PlayerName, s.gameState.FloorNum, score, playerClass)
// Check achievements
if s.gameState.FloorNum >= 5 {
ctx.Store.UnlockAchievement(ctx.PlayerName, "first_clear")
}
if s.gameState.FloorNum >= 10 {
ctx.Store.UnlockAchievement(ctx.PlayerName, "floor10")
}
if s.gameState.Victory {
ctx.Store.UnlockAchievement(ctx.PlayerName, "floor20")
}
if s.gameState.SoloMode && s.gameState.FloorNum >= 5 {
ctx.Store.UnlockAchievement(ctx.PlayerName, "solo_clear")
}
if s.gameState.BossKilled {
ctx.Store.UnlockAchievement(ctx.PlayerName, "boss_slayer")
}
if s.gameState.FleeSucceeded {
ctx.Store.UnlockAchievement(ctx.PlayerName, "flee_master")
}
for _, p := range s.gameState.Players {
if p.Gold >= 200 {
ctx.Store.UnlockAchievement(p.Name, "gold_hoarder")
}
if len(p.Relics) >= 3 {
ctx.Store.UnlockAchievement(p.Name, "relic_collector")
}
}
if len(s.gameState.Players) >= 4 {
ctx.Store.UnlockAchievement(ctx.PlayerName, "full_party")
}
// Unlock triggers
if s.gameState.FloorNum >= 10 {
ctx.Store.UnlockContent(ctx.Fingerprint, "fifth_class")
}
if len(s.gameState.Players) >= 3 && s.gameState.FloorNum >= 5 {
ctx.Store.UnlockContent(ctx.Fingerprint, "hard_mode")
}
if s.gameState.Victory {
ctx.Store.UnlockContent(ctx.Fingerprint, "mutations")
}
// Title triggers
ctx.Store.EarnTitle(ctx.Fingerprint, "novice")
if s.gameState.FloorNum >= 5 {
ctx.Store.EarnTitle(ctx.Fingerprint, "explorer")
}
if s.gameState.FloorNum >= 10 {
ctx.Store.EarnTitle(ctx.Fingerprint, "veteran")
}
if s.gameState.Victory {
ctx.Store.EarnTitle(ctx.Fingerprint, "champion")
}
// Check player gold for gold_king title
for _, p := range s.gameState.Players {
if p.Fingerprint == ctx.Fingerprint && p.Gold >= 500 {
ctx.Store.EarnTitle(ctx.Fingerprint, "gold_king")
}
}
// Save daily record if in daily mode
if ctx.Session != nil && ctx.Session.DailyMode {
playerGold := 0
for _, p := range s.gameState.Players {
if p.Fingerprint == ctx.Fingerprint {
playerGold = p.Gold
break
}
}
ctx.Store.SaveDaily(store.DailyRecord{
Date: ctx.Session.DailyDate,
Player: ctx.Fingerprint,
PlayerName: ctx.PlayerName,
FloorReached: s.gameState.FloorNum,
GoldEarned: playerGold,
})
}
s.rankingSaved = true
}
return NewResultScreen(s.gameState, s.rankingSaved), nil
}
if s.gameState.Phase == game.PhaseShop {
return NewShopScreen(s.gameState), nil
}
switch msg.(type) {
case tickMsg:
if ctx.Session != nil {
ctx.Session.RevealNextLog()
}
if s.gameState.Phase == game.PhaseCombat {
return s, s.pollState()
}
if len(s.gameState.PendingLogs) > 0 {
return s, s.pollState()
}
return s, nil
}
if key, ok := msg.(tea.KeyMsg); ok {
// Chat mode
if s.chatting {
if isEnter(key) && len(s.chatInput) > 0 {
if ctx.Session != nil {
ctx.Session.SendChat(ctx.PlayerName, s.chatInput)
s.gameState = ctx.Session.GetState()
}
s.chatting = false
s.chatInput = ""
} else if isKey(key, "esc") || key.Type == tea.KeyEsc {
s.chatting = false
s.chatInput = ""
} else if key.Type == tea.KeyBackspace && len(s.chatInput) > 0 {
s.chatInput = s.chatInput[:len(s.chatInput)-1]
} else if len(key.Runes) == 1 && len(s.chatInput) < 40 {
s.chatInput += string(key.Runes)
}
if s.gameState.Phase == game.PhaseCombat {
return s, s.pollState()
}
return s, nil
}
if isKey(key, "/") {
s.chatting = true
s.chatInput = ""
if s.gameState.Phase == game.PhaseCombat {
return s, s.pollState()
}
return s, nil
}
switch s.gameState.Phase {
case game.PhaseExploring:
for _, p := range s.gameState.Players {
if p.Fingerprint == ctx.Fingerprint && p.IsDead() {
if isQuit(key) {
return s, tea.Quit
}
return s, nil
}
}
// Skill point allocation
if isKey(key, "[") || isKey(key, "]") {
if ctx.Session != nil {
branchIdx := 0
if isKey(key, "]") {
branchIdx = 1
}
ctx.Session.AllocateSkillPoint(ctx.Fingerprint, branchIdx)
s.gameState = ctx.Session.GetState()
}
return s, nil
}
neighbors := s.getNeighbors()
if isUp(key) {
if s.moveCursor > 0 {
s.moveCursor--
}
} else if isDown(key) {
if s.moveCursor < len(neighbors)-1 {
s.moveCursor++
}
} else if isEnter(key) {
if ctx.Session != nil && len(neighbors) > 0 {
roomIdx := neighbors[s.moveCursor]
ctx.Session.EnterRoom(roomIdx)
s.gameState = ctx.Session.GetState()
s.moveCursor = 0
if s.gameState.Phase == game.PhaseCombat {
return s, s.pollState()
}
}
} else if isQuit(key) {
return s, tea.Quit
}
case game.PhaseCombat:
isPlayerDead := false
for _, p := range s.gameState.Players {
if p.Fingerprint == ctx.Fingerprint && p.IsDead() {
isPlayerDead = true
break
}
}
if isPlayerDead {
return s, s.pollState()
}
if isKey(key, "tab") || key.Type == tea.KeyTab {
if len(s.gameState.Monsters) > 0 {
s.targetCursor = (s.targetCursor + 1) % len(s.gameState.Monsters)
}
return s, s.pollState()
}
if ctx.Session != nil {
switch key.String() {
case "1":
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionAttack, TargetIdx: s.targetCursor})
case "2":
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionSkill, TargetIdx: s.targetCursor})
case "3":
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionItem})
case "4":
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionFlee})
case "5":
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionWait})
}
return s, s.pollState()
}
}
}
return s, nil
}
func (s *GameScreen) View(ctx *Context) string {
return renderGame(s.gameState, ctx.Width, ctx.Height, s.targetCursor, s.moveCursor, s.chatting, s.chatInput, ctx.Fingerprint)
}
func renderGame(state game.GameState, width, height int, targetCursor int, moveCursor int, chatting bool, chatInput string, fingerprint string) string {
mapView := renderMap(state.Floor)
hudView := renderHUD(state, targetCursor, moveCursor)
hudView := renderHUD(state, targetCursor, moveCursor, fingerprint)
logView := renderCombatLog(state.CombatLog)
if chatting {
@@ -49,7 +359,7 @@ func renderMap(floor *dungeon.Floor) string {
return header + "\n" + dungeon.RenderFloor(floor, floor.CurrentRoom, true)
}
func renderHUD(state game.GameState, targetCursor int, moveCursor int) string {
func renderHUD(state game.GameState, targetCursor int, moveCursor int, fingerprint string) string {
var sb strings.Builder
border := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
@@ -157,6 +467,30 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int) string {
}
}
}
// Show skill tree allocation UI if player has unspent points
for _, p := range state.Players {
if p.Fingerprint == fingerprint && p.Skills != nil && p.Skills.Points > p.Skills.Allocated && p.Skills.Allocated < 3 {
branches := entity.GetBranches(p.Class)
sb.WriteString("\n")
skillStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("213")).Bold(true)
sb.WriteString(skillStyle.Render(fmt.Sprintf(" Skill Point Available! (%d unspent)", p.Skills.Points-p.Skills.Allocated)))
sb.WriteString("\n")
for i, branch := range branches {
key := "["
if i == 1 {
key = "]"
}
nextNode := p.Skills.Allocated
if p.Skills.BranchIndex >= 0 && p.Skills.BranchIndex != i {
sb.WriteString(fmt.Sprintf(" [%s] %s (locked)\n", key, branch.Name))
} else if nextNode < 3 {
node := branch.Nodes[nextNode]
sb.WriteString(fmt.Sprintf(" [%s] %s -> %s\n", key, branch.Name, node.Name))
}
}
break
}
}
sb.WriteString("[Up/Down] Select [Enter] Move [Q] Quit")
}

View File

@@ -1,9 +1,30 @@
package ui
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// HelpScreen shows controls and tips.
type HelpScreen struct{}
func NewHelpScreen() *HelpScreen {
return &HelpScreen{}
}
func (s *HelpScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isKey(key, "h") || isEnter(key) || isQuit(key) {
return NewTitleScreen(), nil
}
}
return s, nil
}
func (s *HelpScreen) View(ctx *Context) string {
return renderHelp(ctx.Width, ctx.Height)
}
func renderHelp(width, height int) string {
title := styleHeader.Render("── Controls ──")

View File

@@ -2,50 +2,111 @@ package ui
import (
"fmt"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/store"
)
func renderLeaderboard(byFloor, byGold []store.RunRecord, width, height int) string {
// LeaderboardScreen shows the top runs.
type LeaderboardScreen struct {
tab int // 0=all-time, 1=gold, 2=daily
}
func NewLeaderboardScreen() *LeaderboardScreen {
return &LeaderboardScreen{}
}
func (s *LeaderboardScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isKey(key, "tab") || key.Type == tea.KeyTab {
s.tab = (s.tab + 1) % 3
return s, nil
}
if isKey(key, "l") || isEnter(key) || isQuit(key) {
return NewTitleScreen(), nil
}
}
return s, nil
}
func (s *LeaderboardScreen) View(ctx *Context) string {
var byFloor, byGold []store.RunRecord
var daily []store.DailyRecord
if ctx.Store != nil {
byFloor, _ = ctx.Store.TopRuns(10)
byGold, _ = ctx.Store.TopRunsByGold(10)
daily, _ = ctx.Store.GetDailyLeaderboard(time.Now().Format("2006-01-02"), 20)
}
return renderLeaderboard(byFloor, byGold, daily, s.tab, ctx.Width, ctx.Height)
}
func renderLeaderboard(byFloor, byGold []store.RunRecord, daily []store.DailyRecord, tab, width, height int) string {
title := styleHeader.Render("── Leaderboard ──")
// By Floor
var floorSection string
floorSection += styleCoop.Render(" Top by Floor") + "\n"
for i, r := range byFloor {
if i >= 5 {
break
// Tab header
tabs := []string{"Floor", "Gold", "Daily"}
var tabLine string
for i, t := range tabs {
if i == tab {
tabLine += styleHeader.Render(fmt.Sprintf(" [%s] ", t))
} else {
tabLine += styleSystem.Render(fmt.Sprintf(" %s ", t))
}
medal := fmt.Sprintf(" %d.", i+1)
cls := ""
if r.Class != "" {
cls = fmt.Sprintf(" [%s]", r.Class)
}
floorSection += fmt.Sprintf(" %s %s%s B%d %s\n",
medal, stylePlayer.Render(r.Player), styleSystem.Render(cls),
r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score)))
}
// By Gold
var goldSection string
goldSection += styleCoop.Render("\n Top by Gold") + "\n"
for i, r := range byGold {
if i >= 5 {
break
var content string
switch tab {
case 0: // By Floor
content += styleCoop.Render(" Top by Floor") + "\n"
for i, r := range byFloor {
if i >= 10 {
break
}
medal := fmt.Sprintf(" %d.", i+1)
cls := ""
if r.Class != "" {
cls = fmt.Sprintf(" [%s]", r.Class)
}
content += fmt.Sprintf(" %s %s%s B%d %s\n",
medal, stylePlayer.Render(r.Player), styleSystem.Render(cls),
r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score)))
}
medal := fmt.Sprintf(" %d.", i+1)
cls := ""
if r.Class != "" {
cls = fmt.Sprintf(" [%s]", r.Class)
case 1: // By Gold
content += styleCoop.Render(" Top by Gold") + "\n"
for i, r := range byGold {
if i >= 10 {
break
}
medal := fmt.Sprintf(" %d.", i+1)
cls := ""
if r.Class != "" {
cls = fmt.Sprintf(" [%s]", r.Class)
}
content += fmt.Sprintf(" %s %s%s B%d %s\n",
medal, stylePlayer.Render(r.Player), styleSystem.Render(cls),
r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score)))
}
case 2: // Daily
content += styleCoop.Render(fmt.Sprintf(" Daily Challenge — %s", time.Now().Format("2006-01-02"))) + "\n"
if len(daily) == 0 {
content += " No daily runs yet today.\n"
}
for i, r := range daily {
if i >= 20 {
break
}
medal := fmt.Sprintf(" %d.", i+1)
content += fmt.Sprintf(" %s %s B%d %s\n",
medal, stylePlayer.Render(r.PlayerName),
r.FloorReached, styleGold.Render(fmt.Sprintf("%dg", r.GoldEarned)))
}
goldSection += fmt.Sprintf(" %s %s%s B%d %s\n",
medal, stylePlayer.Render(r.Player), styleSystem.Render(cls),
r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score)))
}
footer := styleSystem.Render("\n[L] Back")
footer := styleSystem.Render("\n[Tab] Switch Tab [L] Back")
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, "", floorSection, goldSection, footer))
lipgloss.JoinVertical(lipgloss.Center, title, tabLine, "", content, footer))
}

View File

@@ -3,21 +3,13 @@ package ui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/game"
)
type lobbyState struct {
rooms []roomInfo
input string
cursor int
creating bool
roomName string
joining bool
codeInput string
online int
}
type roomInfo struct {
Code string
Name string
@@ -31,6 +23,161 @@ type playerInfo struct {
Ready bool
}
// LobbyScreen shows available rooms and lets players create/join.
type LobbyScreen struct {
rooms []roomInfo
input string
cursor int
creating bool
roomName string
joining bool
codeInput string
online int
hardMode bool
hardUnlocked bool
}
func NewLobbyScreen() *LobbyScreen {
return &LobbyScreen{}
}
func (s *LobbyScreen) refreshLobby(ctx *Context) {
if ctx.Lobby == nil {
return
}
rooms := ctx.Lobby.ListRooms()
s.rooms = make([]roomInfo, len(rooms))
for i, r := range rooms {
status := "Waiting"
if r.Status == game.RoomPlaying {
status = "Playing"
}
players := make([]playerInfo, len(r.Players))
for j, p := range r.Players {
players[j] = playerInfo{Name: p.Name, Class: p.Class, Ready: p.Ready}
}
s.rooms[i] = roomInfo{
Code: r.Code,
Name: r.Name,
Players: players,
Status: status,
}
}
s.online = len(ctx.Lobby.ListOnline())
s.cursor = 0
if ctx.Store != nil {
s.hardUnlocked = ctx.Store.IsUnlocked(ctx.Fingerprint, "hard_mode")
}
}
func (s *LobbyScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
// Join-by-code input mode
if s.joining {
if isEnter(key) && len(s.codeInput) == 4 {
if ctx.Lobby != nil {
if err := ctx.Lobby.JoinRoom(s.codeInput, ctx.PlayerName, ctx.Fingerprint); err == nil {
ctx.RoomCode = s.codeInput
return NewClassSelectScreen(), nil
}
}
s.joining = false
s.codeInput = ""
} else if isKey(key, "esc") || key.Type == tea.KeyEsc {
s.joining = false
s.codeInput = ""
} else if key.Type == tea.KeyBackspace && len(s.codeInput) > 0 {
s.codeInput = s.codeInput[:len(s.codeInput)-1]
} else if len(key.Runes) == 1 && len(s.codeInput) < 4 {
ch := strings.ToUpper(string(key.Runes))
s.codeInput += ch
}
return s, nil
}
// Normal lobby key handling
if isKey(key, "c") {
if ctx.Lobby != nil {
code := ctx.Lobby.CreateRoom(ctx.PlayerName + "'s Room")
ctx.Lobby.JoinRoom(code, ctx.PlayerName, ctx.Fingerprint)
ctx.RoomCode = code
return NewClassSelectScreen(), nil
}
} else if isKey(key, "j") {
s.joining = true
s.codeInput = ""
} else if isUp(key) {
if s.cursor > 0 {
s.cursor--
}
} else if isDown(key) {
if s.cursor < len(s.rooms)-1 {
s.cursor++
}
} else if isEnter(key) {
if ctx.Lobby != nil && len(s.rooms) > 0 {
r := s.rooms[s.cursor]
if err := ctx.Lobby.JoinRoom(r.Code, ctx.PlayerName, ctx.Fingerprint); err == nil {
ctx.RoomCode = r.Code
return NewClassSelectScreen(), nil
}
}
} else if isKey(key, "d") {
// Daily Challenge: create a private solo daily session
if ctx.Lobby != nil {
code := ctx.Lobby.CreateRoom(ctx.PlayerName + "'s Daily")
if err := ctx.Lobby.JoinRoom(code, ctx.PlayerName, ctx.Fingerprint); err == nil {
ctx.RoomCode = code
room := ctx.Lobby.GetRoom(code)
if room != nil {
room.Session = game.NewGameSession(ctx.Lobby.Cfg())
room.Session.DailyMode = true
room.Session.DailyDate = time.Now().Format("2006-01-02")
ctx.Session = room.Session
}
return NewClassSelectScreen(), nil
}
}
} else if isKey(key, "h") && s.hardUnlocked {
s.hardMode = !s.hardMode
} else if isKey(key, "q") {
if ctx.Lobby != nil {
ctx.Lobby.PlayerOffline(ctx.Fingerprint)
}
return NewTitleScreen(), nil
}
}
return s, nil
}
func (s *LobbyScreen) View(ctx *Context) string {
state := lobbyState{
rooms: s.rooms,
input: s.input,
cursor: s.cursor,
creating: s.creating,
roomName: s.roomName,
joining: s.joining,
codeInput: s.codeInput,
online: s.online,
hardMode: s.hardMode,
hardUnlocked: s.hardUnlocked,
}
return renderLobby(state, ctx.Width, ctx.Height)
}
type lobbyState struct {
rooms []roomInfo
input string
cursor int
creating bool
roomName string
joining bool
codeInput string
online int
hardMode bool
hardUnlocked bool
}
func renderLobby(state lobbyState, width, height int) string {
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("205")).
@@ -41,7 +188,14 @@ func renderLobby(state lobbyState, width, height int) string {
Padding(0, 1)
header := headerStyle.Render(fmt.Sprintf("── Lobby ── %d online ──", state.online))
menu := "[C] Create Room [J] Join by Code [Up/Down] Select [Enter] Join [Q] Back"
menu := "[C] Create Room [J] Join by Code [D] Daily Challenge [Up/Down] Select [Enter] Join [Q] Back"
if state.hardUnlocked {
hardStatus := "OFF"
if state.hardMode {
hardStatus = "ON"
}
menu += fmt.Sprintf(" [H] Hard Mode: %s", hardStatus)
}
roomList := ""
for i, r := range state.rooms {

View File

@@ -2,61 +2,17 @@ package ui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/tolelom/catacombs/entity"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
)
type screen int
const (
screenTitle screen = iota
screenLobby
screenClassSelect
screenGame
screenShop
screenResult
screenHelp
screenStats
screenAchievements
screenLeaderboard
screenNickname
)
// StateUpdateMsg is sent by GameSession to update the view
type StateUpdateMsg struct {
State game.GameState
}
type tickMsg struct{}
type Model struct {
width int
height int
fingerprint string
playerName string
screen screen
// Shared references (set by server)
lobby *game.Lobby
store *store.DB
// Per-session state
session *game.GameSession
roomCode string
gameState game.GameState
lobbyState lobbyState
classState classSelectState
inputBuffer string
targetCursor int
moveCursor int // selected neighbor index during exploration
chatting bool
chatInput string
rankingSaved bool
shopMsg string
nicknameInput string
currentScreen Screen
ctx *Context
}
func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *store.DB) Model {
@@ -66,13 +22,26 @@ func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *stor
if height == 0 {
height = 24
}
ctx := &Context{
Width: width,
Height: height,
Fingerprint: fingerprint,
Lobby: lobby,
Store: db,
}
// Determine initial screen
var initialScreen Screen
if fingerprint != "" && db != nil {
if name, err := db.GetProfile(fingerprint); err == nil {
ctx.PlayerName = name
}
}
initialScreen = NewTitleScreen()
return Model{
width: width,
height: height,
fingerprint: fingerprint,
screen: screenTitle,
lobby: lobby,
store: db,
currentScreen: initialScreen,
ctx: ctx,
}
}
@@ -83,95 +52,30 @@ func (m Model) Init() tea.Cmd {
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
if m.width == 0 {
m.width = 80
m.ctx.Width = msg.Width
m.ctx.Height = msg.Height
if m.ctx.Width == 0 {
m.ctx.Width = 80
}
if m.height == 0 {
m.height = 24
if m.ctx.Height == 0 {
m.ctx.Height = 24
}
return m, nil
case StateUpdateMsg:
m.gameState = msg.State
return m, nil
}
switch m.screen {
case screenTitle:
return m.updateTitle(msg)
case screenLobby:
return m.updateLobby(msg)
case screenClassSelect:
return m.updateClassSelect(msg)
case screenGame:
return m.updateGame(msg)
case screenShop:
return m.updateShop(msg)
case screenResult:
return m.updateResult(msg)
case screenHelp:
return m.updateHelp(msg)
case screenStats:
return m.updateStats(msg)
case screenAchievements:
return m.updateAchievements(msg)
case screenLeaderboard:
return m.updateLeaderboard(msg)
case screenNickname:
return m.updateNickname(msg)
}
return m, nil
next, cmd := m.currentScreen.Update(msg, m.ctx)
m.currentScreen = next
return m, cmd
}
func (m Model) View() string {
if m.width < 80 || m.height < 24 {
return fmt.Sprintf("Terminal too small (%dx%d). Minimum: 80x24.", m.width, m.height)
if m.ctx.Width < 80 || m.ctx.Height < 24 {
return fmt.Sprintf("Terminal too small (%dx%d). Minimum: 80x24.", m.ctx.Width, m.ctx.Height)
}
switch m.screen {
case screenTitle:
return renderTitle(m.width, m.height)
case screenLobby:
return renderLobby(m.lobbyState, m.width, m.height)
case screenClassSelect:
return renderClassSelect(m.classState, m.width, m.height)
case screenGame:
return renderGame(m.gameState, m.width, m.height, m.targetCursor, m.moveCursor, m.chatting, m.chatInput)
case screenShop:
return renderShop(m.gameState, m.width, m.height, m.shopMsg)
case screenResult:
var rankings []store.RunRecord
if m.store != nil {
rankings, _ = m.store.TopRuns(10)
}
return renderResult(m.gameState, rankings)
case screenHelp:
return renderHelp(m.width, m.height)
case screenStats:
var stats store.PlayerStats
if m.store != nil {
stats, _ = m.store.GetStats(m.playerName)
}
return renderStats(m.playerName, stats, m.width, m.height)
case screenAchievements:
var achievements []store.Achievement
if m.store != nil {
achievements, _ = m.store.GetAchievements(m.playerName)
}
return renderAchievements(m.playerName, achievements, m.width, m.height)
case screenLeaderboard:
var byFloor, byGold []store.RunRecord
if m.store != nil {
byFloor, _ = m.store.TopRuns(10)
byGold, _ = m.store.TopRunsByGold(10)
}
return renderLeaderboard(byFloor, byGold, m.width, m.height)
case screenNickname:
return renderNickname(m.nicknameInput, m.width, m.height)
}
return ""
return m.currentScreen.View(m.ctx)
}
// Key helper functions used by all screens.
func isKey(key tea.KeyMsg, names ...string) bool {
s := key.String()
for _, n := range names {
@@ -198,515 +102,63 @@ func isDown(key tea.KeyMsg) bool {
return isKey(key, "down") || key.Type == tea.KeyDown
}
func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isEnter(key) {
if m.fingerprint == "" {
m.fingerprint = fmt.Sprintf("anon-%d", time.Now().UnixNano())
}
if m.store != nil {
name, err := m.store.GetProfile(m.fingerprint)
if err != nil {
// First time player — show nickname input
m.screen = screenNickname
m.nicknameInput = ""
return m, nil
}
m.playerName = name
} else {
m.playerName = "Adventurer"
}
if m.lobby != nil {
m.lobby.PlayerOnline(m.fingerprint, m.playerName)
}
// Check for active session to reconnect
if m.lobby != nil {
code, session := m.lobby.GetActiveSession(m.fingerprint)
if session != nil {
m.roomCode = code
m.session = session
m.gameState = m.session.GetState()
m.screen = screenGame
m.session.TouchActivity(m.fingerprint)
m.session.SendChat("System", m.playerName+" reconnected!")
return m, m.pollState()
}
}
m.screen = screenLobby
m = m.withRefreshedLobby()
} else if isKey(key, "h") {
m.screen = screenHelp
} else if isKey(key, "s") {
m.screen = screenStats
} else if isKey(key, "a") {
m.screen = screenAchievements
} else if isKey(key, "l") {
m.screen = screenLeaderboard
} else if isQuit(key) {
return m, tea.Quit
}
// Keep these for backward compatibility with tests
// screen enum kept temporarily for test compatibility
type screen int
const (
screenTitle screen = iota
screenLobby
screenClassSelect
screenGame
screenShop
screenResult
screenHelp
screenStats
screenAchievements
screenLeaderboard
screenNickname
)
// screenType returns the screen enum for the current screen (for test compatibility).
func (m Model) screenType() screen {
switch m.currentScreen.(type) {
case *TitleScreen:
return screenTitle
case *LobbyScreen:
return screenLobby
case *ClassSelectScreen:
return screenClassSelect
case *GameScreen:
return screenGame
case *ShopScreen:
return screenShop
case *ResultScreen:
return screenResult
case *HelpScreen:
return screenHelp
case *StatsScreen:
return screenStats
case *AchievementsScreen:
return screenAchievements
case *LeaderboardScreen:
return screenLeaderboard
case *NicknameScreen:
return screenNickname
}
return m, nil
return screenTitle
}
func (m Model) updateNickname(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isEnter(key) && len(m.nicknameInput) > 0 {
m.playerName = m.nicknameInput
if m.store != nil && m.fingerprint != "" {
m.store.SaveProfile(m.fingerprint, m.playerName)
}
m.nicknameInput = ""
if m.lobby != nil {
m.lobby.PlayerOnline(m.fingerprint, m.playerName)
}
// Check for active session to reconnect
if m.lobby != nil {
code, session := m.lobby.GetActiveSession(m.fingerprint)
if session != nil {
m.roomCode = code
m.session = session
m.gameState = m.session.GetState()
m.screen = screenGame
m.session.TouchActivity(m.fingerprint)
m.session.SendChat("System", m.playerName+" reconnected!")
return m, m.pollState()
}
}
m.screen = screenLobby
m = m.withRefreshedLobby()
} else if isKey(key, "esc") || key.Type == tea.KeyEsc {
m.nicknameInput = ""
m.screen = screenTitle
} else if key.Type == tea.KeyBackspace && len(m.nicknameInput) > 0 {
m.nicknameInput = m.nicknameInput[:len(m.nicknameInput)-1]
} else if len(key.Runes) == 1 && len(m.nicknameInput) < 12 {
ch := string(key.Runes)
// Only allow alphanumeric and some special chars
if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 {
m.nicknameInput += ch
}
}
}
return m, nil
// Convenience accessors for test compatibility
func (m Model) playerName() string {
return m.ctx.PlayerName
}
func (m Model) updateStats(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isKey(key, "s") || isEnter(key) || isQuit(key) {
m.screen = screenTitle
}
}
return m, nil
func (m Model) roomCode() string {
return m.ctx.RoomCode
}
func (m Model) updateAchievements(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isKey(key, "a") || isEnter(key) || isQuit(key) {
m.screen = screenTitle
}
}
return m, nil
func (m Model) session() *game.GameSession {
return m.ctx.Session
}
func (m Model) updateLeaderboard(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isKey(key, "l") || isEnter(key) || isQuit(key) {
m.screen = screenTitle
}
}
return m, nil
}
func (m Model) updateHelp(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isKey(key, "h") || isEnter(key) || isQuit(key) {
m.screen = screenTitle
}
}
return m, nil
}
func (m Model) updateLobby(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
// Join-by-code input mode
if m.lobbyState.joining {
if isEnter(key) && len(m.lobbyState.codeInput) == 4 {
if m.lobby != nil {
if err := m.lobby.JoinRoom(m.lobbyState.codeInput, m.playerName, m.fingerprint); err == nil {
m.roomCode = m.lobbyState.codeInput
m.screen = screenClassSelect
}
}
m.lobbyState.joining = false
m.lobbyState.codeInput = ""
} else if isKey(key, "esc") || key.Type == tea.KeyEsc {
m.lobbyState.joining = false
m.lobbyState.codeInput = ""
} else if key.Type == tea.KeyBackspace && len(m.lobbyState.codeInput) > 0 {
m.lobbyState.codeInput = m.lobbyState.codeInput[:len(m.lobbyState.codeInput)-1]
} else if len(key.Runes) == 1 && len(m.lobbyState.codeInput) < 4 {
ch := strings.ToUpper(string(key.Runes))
m.lobbyState.codeInput += ch
}
return m, nil
}
// Normal lobby key handling
if isKey(key, "c") {
if m.lobby != nil {
code := m.lobby.CreateRoom(m.playerName + "'s Room")
m.lobby.JoinRoom(code, m.playerName, m.fingerprint)
m.roomCode = code
m.screen = screenClassSelect
}
} else if isKey(key, "j") {
m.lobbyState.joining = true
m.lobbyState.codeInput = ""
} else if isUp(key) {
if m.lobbyState.cursor > 0 {
m.lobbyState.cursor--
}
} else if isDown(key) {
if m.lobbyState.cursor < len(m.lobbyState.rooms)-1 {
m.lobbyState.cursor++
}
} else if isEnter(key) {
if m.lobby != nil && len(m.lobbyState.rooms) > 0 {
r := m.lobbyState.rooms[m.lobbyState.cursor]
if err := m.lobby.JoinRoom(r.Code, m.playerName, m.fingerprint); err == nil {
m.roomCode = r.Code
m.screen = screenClassSelect
}
}
} else if isKey(key, "q") {
if m.lobby != nil {
m.lobby.PlayerOffline(m.fingerprint)
}
m.screen = screenTitle
}
}
return m, nil
}
func (m Model) updateClassSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isUp(key) {
if m.classState.cursor > 0 {
m.classState.cursor--
}
} else if isDown(key) {
if m.classState.cursor < len(classOptions)-1 {
m.classState.cursor++
}
} else if isEnter(key) {
if m.lobby != nil {
selectedClass := classOptions[m.classState.cursor].class
m.lobby.SetPlayerClass(m.roomCode, m.fingerprint, selectedClass.String())
room := m.lobby.GetRoom(m.roomCode)
if room != nil {
if room.Session == nil {
room.Session = game.NewGameSession()
}
m.session = room.Session
player := entity.NewPlayer(m.playerName, selectedClass)
player.Fingerprint = m.fingerprint
m.session.AddPlayer(player)
if m.lobby != nil {
m.lobby.RegisterSession(m.fingerprint, m.roomCode)
}
m.session.StartGame()
m.lobby.StartRoom(m.roomCode)
m.gameState = m.session.GetState()
m.screen = screenGame
}
}
}
}
return m, nil
}
// pollState returns a Cmd that waits briefly then refreshes game state
func (m Model) pollState() tea.Cmd {
return tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
return tickMsg{}
})
}
type tickMsg struct{}
func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.session != nil && m.fingerprint != "" {
m.session.TouchActivity(m.fingerprint)
}
// Refresh state on every update
if m.session != nil {
m.gameState = m.session.GetState()
// Clamp target cursor to valid range after monsters die
if len(m.gameState.Monsters) > 0 {
if m.targetCursor >= len(m.gameState.Monsters) {
m.targetCursor = len(m.gameState.Monsters) - 1
}
} else {
m.targetCursor = 0
}
}
if m.gameState.GameOver {
if m.store != nil && !m.rankingSaved {
score := 0
for _, p := range m.gameState.Players {
score += p.Gold
}
// Find the current player's class
playerClass := ""
for _, p := range m.gameState.Players {
if p.Fingerprint == m.fingerprint {
playerClass = p.Class.String()
break
}
}
m.store.SaveRun(m.playerName, m.gameState.FloorNum, score, playerClass)
// Check achievements
if m.gameState.FloorNum >= 5 {
m.store.UnlockAchievement(m.playerName, "first_clear")
}
if m.gameState.FloorNum >= 10 {
m.store.UnlockAchievement(m.playerName, "floor10")
}
if m.gameState.Victory {
m.store.UnlockAchievement(m.playerName, "floor20")
}
if m.gameState.SoloMode && m.gameState.FloorNum >= 5 {
m.store.UnlockAchievement(m.playerName, "solo_clear")
}
if m.gameState.BossKilled {
m.store.UnlockAchievement(m.playerName, "boss_slayer")
}
if m.gameState.FleeSucceeded {
m.store.UnlockAchievement(m.playerName, "flee_master")
}
for _, p := range m.gameState.Players {
if p.Gold >= 200 {
m.store.UnlockAchievement(p.Name, "gold_hoarder")
}
if len(p.Relics) >= 3 {
m.store.UnlockAchievement(p.Name, "relic_collector")
}
}
if len(m.gameState.Players) >= 4 {
m.store.UnlockAchievement(m.playerName, "full_party")
}
m.rankingSaved = true
}
m.screen = screenResult
return m, nil
}
if m.gameState.Phase == game.PhaseShop {
m.screen = screenShop
return m, nil
}
switch msg.(type) {
case tickMsg:
if m.session != nil {
m.session.RevealNextLog()
}
// Keep polling during combat or while there are pending logs to reveal
if m.gameState.Phase == game.PhaseCombat {
return m, m.pollState()
}
if len(m.gameState.PendingLogs) > 0 {
return m, m.pollState()
}
return m, nil
}
if key, ok := msg.(tea.KeyMsg); ok {
// Chat mode
if m.chatting {
if isEnter(key) && len(m.chatInput) > 0 {
if m.session != nil {
m.session.SendChat(m.playerName, m.chatInput)
m.gameState = m.session.GetState()
}
m.chatting = false
m.chatInput = ""
} else if isKey(key, "esc") || key.Type == tea.KeyEsc {
m.chatting = false
m.chatInput = ""
} else if key.Type == tea.KeyBackspace && len(m.chatInput) > 0 {
m.chatInput = m.chatInput[:len(m.chatInput)-1]
} else if len(key.Runes) == 1 && len(m.chatInput) < 40 {
m.chatInput += string(key.Runes)
}
if m.gameState.Phase == game.PhaseCombat {
return m, m.pollState()
}
return m, nil
}
if isKey(key, "/") {
m.chatting = true
m.chatInput = ""
if m.gameState.Phase == game.PhaseCombat {
return m, m.pollState()
}
return m, nil
}
switch m.gameState.Phase {
case game.PhaseExploring:
// Dead players can only observe, not move
for _, p := range m.gameState.Players {
if p.Fingerprint == m.fingerprint && p.IsDead() {
if isQuit(key) {
return m, tea.Quit
}
return m, nil
}
}
neighbors := m.getNeighbors()
if isUp(key) {
if m.moveCursor > 0 {
m.moveCursor--
}
} else if isDown(key) {
if m.moveCursor < len(neighbors)-1 {
m.moveCursor++
}
} else if isEnter(key) {
if m.session != nil && len(neighbors) > 0 {
roomIdx := neighbors[m.moveCursor]
m.session.EnterRoom(roomIdx)
m.gameState = m.session.GetState()
m.moveCursor = 0
if m.gameState.Phase == game.PhaseCombat {
return m, m.pollState()
}
}
} else if isQuit(key) {
return m, tea.Quit
}
case game.PhaseCombat:
isPlayerDead := false
for _, p := range m.gameState.Players {
if p.Fingerprint == m.fingerprint && p.IsDead() {
isPlayerDead = true
break
}
}
if isPlayerDead {
return m, m.pollState()
}
if isKey(key, "tab") || key.Type == tea.KeyTab {
if len(m.gameState.Monsters) > 0 {
m.targetCursor = (m.targetCursor + 1) % len(m.gameState.Monsters)
}
return m, m.pollState()
}
if m.session != nil {
switch key.String() {
case "1":
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionAttack, TargetIdx: m.targetCursor})
case "2":
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionSkill, TargetIdx: m.targetCursor})
case "3":
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionItem})
case "4":
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionFlee})
case "5":
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionWait})
}
// After submitting, poll for turn resolution
return m, m.pollState()
}
}
}
return m, nil
}
func (m Model) getNeighbors() []int {
if m.gameState.Floor == nil {
return nil
}
cur := m.gameState.Floor.CurrentRoom
if cur < 0 || cur >= len(m.gameState.Floor.Rooms) {
return nil
}
return m.gameState.Floor.Rooms[cur].Neighbors
}
func (m Model) updateShop(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "1", "2", "3":
if m.session != nil {
idx := int(key.String()[0] - '1')
if m.session.BuyItem(m.fingerprint, idx) {
m.shopMsg = "Purchased!"
} else {
m.shopMsg = "Not enough gold!"
}
m.gameState = m.session.GetState()
}
case "q":
if m.session != nil {
m.session.LeaveShop()
m.gameState = m.session.GetState()
m.screen = screenGame
}
}
}
return m, nil
}
func (m Model) updateResult(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isEnter(key) {
if m.lobby != nil && m.fingerprint != "" {
m.lobby.UnregisterSession(m.fingerprint)
}
if m.session != nil {
m.session.Stop()
m.session = nil
}
if m.lobby != nil && m.roomCode != "" {
m.lobby.RemoveRoom(m.roomCode)
}
m.roomCode = ""
m.rankingSaved = false
m.screen = screenLobby
m = m.withRefreshedLobby()
} else if isQuit(key) {
return m, tea.Quit
}
}
return m, nil
}
func (m Model) withRefreshedLobby() Model {
if m.lobby == nil {
return m
}
rooms := m.lobby.ListRooms()
m.lobbyState.rooms = make([]roomInfo, len(rooms))
for i, r := range rooms {
status := "Waiting"
if r.Status == game.RoomPlaying {
status = "Playing"
}
players := make([]playerInfo, len(r.Players))
for j, p := range r.Players {
players[j] = playerInfo{Name: p.Name, Class: p.Class, Ready: p.Ready}
}
m.lobbyState.rooms[i] = roomInfo{
Code: r.Code,
Name: r.Name,
Players: players,
Status: status,
}
}
m.lobbyState.online = len(m.lobby.ListOnline())
m.lobbyState.cursor = 0
return m
}

View File

@@ -1,12 +1,13 @@
package ui
import (
"os"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/tolelom/catacombs/config"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
"os"
)
func testDB(t *testing.T) *store.DB {
@@ -18,22 +19,22 @@ func testDB(t *testing.T) *store.DB {
}
func TestTitleToLobby(t *testing.T) {
lobby := game.NewLobby()
lobby := game.NewLobby(func() *config.Config { c, _ := config.Load(""); return c }())
db := testDB(t)
defer func() { db.Close(); os.Remove("test_ui.db") }()
m := NewModel(80, 24, "testfp", lobby, db)
if m.screen != screenTitle {
t.Fatalf("initial screen: got %d, want screenTitle(0)", m.screen)
if m.screenType() != screenTitle {
t.Fatalf("initial screen: got %d, want screenTitle(0)", m.screenType())
}
// First-time player: Enter goes to nickname screen
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m2 := result.(Model)
if m2.screen != screenNickname {
t.Errorf("after Enter (first time): screen=%d, want screenNickname(%d)", m2.screen, screenNickname)
if m2.screenType() != screenNickname {
t.Errorf("after Enter (first time): screen=%d, want screenNickname(%d)", m2.screenType(), screenNickname)
}
// Type a name
@@ -46,16 +47,16 @@ func TestTitleToLobby(t *testing.T) {
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyEnter})
m3 := result.(Model)
if m3.screen != screenLobby {
t.Errorf("after nickname Enter: screen=%d, want screenLobby(1)", m3.screen)
if m3.screenType() != screenLobby {
t.Errorf("after nickname Enter: screen=%d, want screenLobby(1)", m3.screenType())
}
if m3.playerName == "" {
if m3.playerName() == "" {
t.Error("playerName should be set")
}
}
func TestLobbyCreateRoom(t *testing.T) {
lobby := game.NewLobby()
lobby := game.NewLobby(func() *config.Config { c, _ := config.Load(""); return c }())
db := testDB(t)
defer func() { db.Close(); os.Remove("test_ui.db") }()
@@ -77,16 +78,16 @@ func TestLobbyCreateRoom(t *testing.T) {
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
m3 := result.(Model)
if m3.screen != screenClassSelect {
t.Errorf("after 'c': screen=%d, want screenClassSelect(2)", m3.screen)
if m3.screenType() != screenClassSelect {
t.Errorf("after 'c': screen=%d, want screenClassSelect(2)", m3.screenType())
}
if m3.roomCode == "" {
if m3.roomCode() == "" {
t.Error("roomCode should be set")
}
}
func TestClassSelectToGame(t *testing.T) {
lobby := game.NewLobby()
lobby := game.NewLobby(func() *config.Config { c, _ := config.Load(""); return c }())
db := testDB(t)
defer func() { db.Close(); os.Remove("test_ui.db") }()
@@ -106,18 +107,18 @@ func TestClassSelectToGame(t *testing.T) {
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
m3 := result.(Model)
if m3.screen != screenClassSelect {
t.Fatalf("should be at class select, got %d", m3.screen)
if m3.screenType() != screenClassSelect {
t.Fatalf("should be at class select, got %d", m3.screenType())
}
// Press Enter to select Warrior (default cursor=0)
result, _ = m3.Update(tea.KeyMsg{Type: tea.KeyEnter})
m4 := result.(Model)
if m4.screen != screenGame {
t.Errorf("after class select Enter: screen=%d, want screenGame(3)", m4.screen)
if m4.screenType() != screenGame {
t.Errorf("after class select Enter: screen=%d, want screenGame(3)", m4.screenType())
}
if m4.session == nil {
if m4.session() == nil {
t.Error("session should be set")
}
}

View File

@@ -2,11 +2,69 @@ package ui
import (
"fmt"
"log/slog"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// NicknameScreen handles first-time player name input.
type NicknameScreen struct {
input string
}
func NewNicknameScreen() *NicknameScreen {
return &NicknameScreen{}
}
func (s *NicknameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isEnter(key) && len(s.input) > 0 {
ctx.PlayerName = s.input
if ctx.Store != nil && ctx.Fingerprint != "" {
if err := ctx.Store.SaveProfile(ctx.Fingerprint, ctx.PlayerName); err != nil {
slog.Error("failed to save profile", "error", err)
}
}
if ctx.Lobby != nil {
ctx.Lobby.PlayerOnline(ctx.Fingerprint, ctx.PlayerName)
}
// Check for active session to reconnect
if ctx.Lobby != nil {
code, session := ctx.Lobby.GetActiveSession(ctx.Fingerprint)
if session != nil {
ctx.RoomCode = code
ctx.Session = session
gs := NewGameScreen()
gs.gameState = ctx.Session.GetState()
ctx.Session.TouchActivity(ctx.Fingerprint)
ctx.Session.SendChat("System", ctx.PlayerName+" reconnected!")
return gs, gs.pollState()
}
}
ls := NewLobbyScreen()
ls.refreshLobby(ctx)
return ls, nil
} else if isKey(key, "esc") || key.Type == tea.KeyEsc {
s.input = ""
return NewTitleScreen(), nil
} else if key.Type == tea.KeyBackspace && len(s.input) > 0 {
s.input = s.input[:len(s.input)-1]
} else if len(key.Runes) == 1 && len(s.input) < 12 {
ch := string(key.Runes)
if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 {
s.input += ch
}
}
}
return s, nil
}
func (s *NicknameScreen) View(ctx *Context) string {
return renderNickname(s.input, ctx.Width, ctx.Height)
}
func renderNickname(input string, width, height int) string {
title := styleHeader.Render("── Enter Your Name ──")

View File

@@ -4,10 +4,53 @@ import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
)
// ResultScreen shows the end-of-run summary and rankings.
type ResultScreen struct {
gameState game.GameState
rankingSaved bool
}
func NewResultScreen(state game.GameState, rankingSaved bool) *ResultScreen {
return &ResultScreen{gameState: state, rankingSaved: rankingSaved}
}
func (s *ResultScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isEnter(key) {
if ctx.Lobby != nil && ctx.Fingerprint != "" {
ctx.Lobby.UnregisterSession(ctx.Fingerprint)
}
if ctx.Session != nil {
ctx.Session.Stop()
ctx.Session = nil
}
if ctx.Lobby != nil && ctx.RoomCode != "" {
ctx.Lobby.RemoveRoom(ctx.RoomCode)
}
ctx.RoomCode = ""
ls := NewLobbyScreen()
ls.refreshLobby(ctx)
return ls, nil
} else if isQuit(key) {
return s, tea.Quit
}
}
return s, nil
}
func (s *ResultScreen) View(ctx *Context) string {
var rankings []store.RunRecord
if ctx.Store != nil {
rankings, _ = ctx.Store.TopRuns(10)
}
return renderResult(s.gameState, rankings)
}
func renderResult(state game.GameState, rankings []store.RunRecord) string {
var sb strings.Builder

11
ui/screen.go Normal file
View File

@@ -0,0 +1,11 @@
package ui
import tea "github.com/charmbracelet/bubbletea"
// Screen represents an independent screen with its own Update and View logic.
// Update returns the next Screen (can return itself or a different screen for transitions)
// plus a tea.Cmd for async operations.
type Screen interface {
Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd)
View(ctx *Context) string
}

View File

@@ -3,11 +3,51 @@ package ui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/entity"
"github.com/tolelom/catacombs/game"
)
// ShopScreen handles the shop between floors.
type ShopScreen struct {
gameState game.GameState
shopMsg string
}
func NewShopScreen(state game.GameState) *ShopScreen {
return &ShopScreen{gameState: state}
}
func (s *ShopScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "1", "2", "3":
if ctx.Session != nil {
idx := int(key.String()[0] - '1')
if ctx.Session.BuyItem(ctx.Fingerprint, idx) {
s.shopMsg = "Purchased!"
} else {
s.shopMsg = "Not enough gold!"
}
s.gameState = ctx.Session.GetState()
}
case "q":
if ctx.Session != nil {
ctx.Session.LeaveShop()
gs := NewGameScreen()
gs.gameState = ctx.Session.GetState()
return gs, nil
}
}
}
return s, nil
}
func (s *ShopScreen) View(ctx *Context) string {
return renderShop(s.gameState, ctx.Width, ctx.Height, s.shopMsg)
}
func itemTypeLabel(item entity.Item) string {
switch item.Type {
case entity.ItemWeapon:

View File

@@ -3,10 +3,35 @@ package ui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/store"
)
// StatsScreen shows player statistics.
type StatsScreen struct{}
func NewStatsScreen() *StatsScreen {
return &StatsScreen{}
}
func (s *StatsScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isKey(key, "s") || isEnter(key) || isQuit(key) {
return NewTitleScreen(), nil
}
}
return s, nil
}
func (s *StatsScreen) View(ctx *Context) string {
var stats store.PlayerStats
if ctx.Store != nil {
stats, _ = ctx.Store.GetStats(ctx.PlayerName)
}
return renderStats(ctx.PlayerName, stats, ctx.Width, ctx.Height)
}
func renderStats(playerName string, stats store.PlayerStats, width, height int) string {
title := styleHeader.Render("── Player Statistics ──")

View File

@@ -1,11 +1,77 @@
package ui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// TitleScreen is the main menu screen.
type TitleScreen struct{}
func NewTitleScreen() *TitleScreen {
return &TitleScreen{}
}
func (s *TitleScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isEnter(key) {
if ctx.Fingerprint == "" {
ctx.Fingerprint = fmt.Sprintf("anon-%d", time.Now().UnixNano())
}
if ctx.Store != nil {
name, err := ctx.Store.GetProfile(ctx.Fingerprint)
if err != nil {
// First time player — show nickname input
return NewNicknameScreen(), nil
}
ctx.PlayerName = name
} else {
ctx.PlayerName = "Adventurer"
}
if ctx.Lobby != nil {
ctx.Lobby.PlayerOnline(ctx.Fingerprint, ctx.PlayerName)
}
// Check for active session to reconnect
if ctx.Lobby != nil {
code, session := ctx.Lobby.GetActiveSession(ctx.Fingerprint)
if session != nil {
ctx.RoomCode = code
ctx.Session = session
gs := NewGameScreen()
gs.gameState = ctx.Session.GetState()
ctx.Session.TouchActivity(ctx.Fingerprint)
ctx.Session.SendChat("System", ctx.PlayerName+" reconnected!")
return gs, gs.pollState()
}
}
ls := NewLobbyScreen()
ls.refreshLobby(ctx)
return ls, nil
} else if isKey(key, "h") {
return NewHelpScreen(), nil
} else if isKey(key, "s") {
return NewStatsScreen(), nil
} else if isKey(key, "a") {
return NewAchievementsScreen(), nil
} else if isKey(key, "l") {
return NewLeaderboardScreen(), nil
} else if isKey(key, "c") {
return NewCodexScreen(ctx), nil
} else if isQuit(key) {
return s, tea.Quit
}
}
return s, nil
}
func (s *TitleScreen) View(ctx *Context) string {
return renderTitle(ctx.Width, ctx.Height)
}
var titleLines = []string{
` ██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗`,
`██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝`,
@@ -44,7 +110,7 @@ func renderTitle(width, height int) string {
menu := lipgloss.NewStyle().
Foreground(colorWhite).
Bold(true).
Render("[Enter] Start [H] Help [S] Stats [A] Achievements [L] Leaderboard [Q] Quit")
Render("[Enter] Start [H] Help [S] Stats [A] Achievements [L] Leaderboard [C] Codex [Q] Quit")
content := lipgloss.JoinVertical(lipgloss.Center,
logo,

55
web/admin.go Normal file
View File

@@ -0,0 +1,55 @@
package web
import (
"encoding/json"
"net/http"
"time"
"github.com/tolelom/catacombs/config"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
)
// AdminStats is the JSON response for the /admin endpoint.
type AdminStats struct {
OnlinePlayers int `json:"online_players"`
ActiveRooms int `json:"active_rooms"`
TodayRuns int `json:"today_runs"`
AvgFloorReach float64 `json:"avg_floor_reached"`
UptimeSeconds int64 `json:"uptime_seconds"`
}
// AdminHandler returns an http.Handler for the /admin stats endpoint.
// It requires Basic Auth using credentials from config.
func AdminHandler(lobby *game.Lobby, db *store.DB, startTime time.Time) http.Handler {
cfg := lobby.Cfg()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !checkAuth(r, cfg.Admin) {
w.Header().Set("WWW-Authenticate", `Basic realm="Catacombs Admin"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
todayRuns, _ := db.GetTodayRunCount()
avgFloor, _ := db.GetTodayAvgFloor()
stats := AdminStats{
OnlinePlayers: len(lobby.ListOnline()),
ActiveRooms: len(lobby.ListRooms()),
TodayRuns: todayRuns,
AvgFloorReach: avgFloor,
UptimeSeconds: int64(time.Since(startTime).Seconds()),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
})
}
func checkAuth(r *http.Request, cfg config.AdminConfig) bool {
username, password, ok := r.BasicAuth()
if !ok {
return false
}
return username == cfg.Username && password == cfg.Password
}

64
web/admin_test.go Normal file
View File

@@ -0,0 +1,64 @@
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/tolelom/catacombs/config"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
)
func TestAdminEndpoint(t *testing.T) {
tmpDir := t.TempDir()
db, err := store.Open(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close()
cfg, _ := config.Load("")
cfg.Admin = config.AdminConfig{Username: "admin", Password: "secret"}
lobby := game.NewLobby(cfg)
handler := AdminHandler(lobby, db, time.Now())
// Test without auth → 401
req := httptest.NewRequest("GET", "/admin", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
// Test with wrong auth → 401
req = httptest.NewRequest("GET", "/admin", nil)
req.SetBasicAuth("admin", "wrong")
w = httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
// Test with correct auth → 200 + JSON
req = httptest.NewRequest("GET", "/admin", nil)
req.SetBasicAuth("admin", "secret")
w = httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var stats AdminStats
if err := json.Unmarshal(w.Body.Bytes(), &stats); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if stats.OnlinePlayers != 0 {
t.Fatalf("expected 0 online, got %d", stats.OnlinePlayers)
}
}

View File

@@ -5,12 +5,15 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"log/slog"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"golang.org/x/crypto/ssh"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
)
//go:embed static
@@ -26,8 +29,8 @@ type resizeMsg struct {
Rows int `json:"rows"`
}
// Start launches the HTTP server for the web terminal.
func Start(addr string, sshPort int) error {
// Start launches the HTTP server for the web terminal and returns the server handle.
func Start(addr string, sshPort int, lobby *game.Lobby, db *store.DB, startTime time.Time) *http.Server {
mux := http.NewServeMux()
// Serve static files from embedded FS
@@ -38,14 +41,25 @@ func Start(addr string, sshPort int) error {
handleWS(w, r, sshPort)
})
log.Printf("Starting web terminal on %s", addr)
return http.ListenAndServe(addr, mux)
// Admin endpoint
mux.Handle("/admin", AdminHandler(lobby, db, startTime))
srv := &http.Server{Addr: addr, Handler: mux}
slog.Info("starting web terminal", "addr", addr)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("web server error", "error", err)
}
}()
return srv
}
func handleWS(w http.ResponseWriter, r *http.Request, sshPort int) {
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade error: %v", err)
slog.Error("WebSocket upgrade error", "error", err)
return
}
defer ws.Close()
@@ -62,7 +76,7 @@ func handleWS(w http.ResponseWriter, r *http.Request, sshPort int) {
sshAddr := fmt.Sprintf("localhost:%d", sshPort)
client, err := ssh.Dial("tcp", sshAddr, sshConfig)
if err != nil {
log.Printf("SSH dial error: %v", err)
slog.Error("SSH dial error", "error", err)
ws.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("Failed to connect to game server: %v\r\n", err)))
return
}
@@ -70,7 +84,7 @@ func handleWS(w http.ResponseWriter, r *http.Request, sshPort int) {
session, err := client.NewSession()
if err != nil {
log.Printf("SSH session error: %v", err)
slog.Error("SSH session error", "error", err)
return
}
defer session.Close()
@@ -81,24 +95,24 @@ func handleWS(w http.ResponseWriter, r *http.Request, sshPort int) {
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
}); err != nil {
log.Printf("PTY request error: %v", err)
slog.Error("PTY request error", "error", err)
return
}
stdin, err := session.StdinPipe()
if err != nil {
log.Printf("stdin pipe error: %v", err)
slog.Error("stdin pipe error", "error", err)
return
}
stdout, err := session.StdoutPipe()
if err != nil {
log.Printf("stdout pipe error: %v", err)
slog.Error("stdout pipe error", "error", err)
return
}
if err := session.Shell(); err != nil {
log.Printf("shell error: %v", err)
slog.Error("shell error", "error", err)
return
}