Compare commits
39 Commits
604ca00e8b
...
97aa4667a1
| Author | SHA1 | Date | |
|---|---|---|---|
| 97aa4667a1 | |||
| ee4147b255 | |||
| 7c9a12cd6e | |||
| 6e78d8a073 | |||
| a38cf804ef | |||
| ae8ed8a8ae | |||
| 6c749ba591 | |||
| 4c006df27e | |||
| a7bca9d2f2 | |||
| 00581880f2 | |||
| cf37eef1b1 | |||
| caefaff200 | |||
| 6c3188e747 | |||
| 8f899a5afd | |||
| 253d1f80d3 | |||
| cd0d6c1c4c | |||
| 5ff82120ff | |||
| b8697e414a | |||
| 65c062a1f7 | |||
| 7f29995833 | |||
| e167165bbc | |||
| 1e155c62fb | |||
| 69ac6cd383 | |||
| 22ebeb1d48 | |||
| 8ef3d9dd13 | |||
| 05cf59c659 | |||
| fa78bfecee | |||
| fc0c5edc38 | |||
| 083a895be2 | |||
| ba01c11d36 | |||
| 7cb9290798 | |||
| afe4ee1056 | |||
| f85775dd3e | |||
| ad1482ae03 | |||
| 0f524779c0 | |||
| 089d5c76ed | |||
| 7064544693 | |||
| dfdc18a444 | |||
| 07587b103e |
55
CLAUDE.md
Normal file
55
CLAUDE.md
Normal 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
|
||||||
@@ -31,7 +31,7 @@ type AttackResult struct {
|
|||||||
IsAoE bool
|
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)
|
targetCount := make(map[int]int)
|
||||||
targetOrder := make(map[int]int)
|
targetOrder := make(map[int]int)
|
||||||
for i, intent := range intents {
|
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)
|
dmg := CalcDamage(intent.PlayerATK, m.DEF, intent.Multiplier)
|
||||||
coopApplied := false
|
coopApplied := false
|
||||||
if targetCount[intent.TargetIdx] >= 2 && targetOrder[intent.TargetIdx] != i {
|
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
|
coopApplied = true
|
||||||
}
|
}
|
||||||
m.TakeDamage(dmg)
|
m.TakeDamage(dmg)
|
||||||
@@ -77,13 +77,13 @@ func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster) []Attack
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
func AttemptFlee() bool {
|
func AttemptFlee(fleeChance float64) bool {
|
||||||
return rand.Float64() < 0.5
|
return rand.Float64() < fleeChance
|
||||||
}
|
}
|
||||||
|
|
||||||
func MonsterAI(m *entity.Monster, players []*entity.Player, turnNumber int) (targetIdx int, isAoE bool) {
|
func MonsterAI(m *entity.Monster, players []*entity.Player, turnNumber int) (targetIdx int, isAoE bool) {
|
||||||
if m.IsBoss && turnNumber > 0 && turnNumber%3 == 0 {
|
if (m.IsBoss || m.IsMiniBoss) && turnNumber > 0 && turnNumber%3 == 0 {
|
||||||
return -1, true // AoE every 3 turns for all bosses
|
return -1, true // AoE every 3 turns for all bosses and mini-bosses
|
||||||
}
|
}
|
||||||
if m.TauntTarget {
|
if m.TauntTarget {
|
||||||
for i, p := range players {
|
for i, p := range players {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func TestCoopBonus(t *testing.T) {
|
|||||||
{PlayerATK: 12, TargetIdx: 0, Multiplier: 1.0, IsAoE: false},
|
{PlayerATK: 12, TargetIdx: 0, Multiplier: 1.0, IsAoE: false},
|
||||||
{PlayerATK: 15, 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 {
|
if !results[1].CoopApplied {
|
||||||
t.Error("Second attacker should get co-op bonus")
|
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},
|
{PlayerATK: 20, TargetIdx: -1, Multiplier: 0.8, IsAoE: true},
|
||||||
}
|
}
|
||||||
monsters := []*entity.Monster{
|
monsters := []*entity.Monster{
|
||||||
entity.NewMonster(entity.MonsterSlime, 1),
|
entity.NewMonster(entity.MonsterSlime, 1, 1.15),
|
||||||
entity.NewMonster(entity.MonsterSlime, 1),
|
entity.NewMonster(entity.MonsterSlime, 1, 1.15),
|
||||||
}
|
}
|
||||||
results := ResolveAttacks(attackers, monsters)
|
results := ResolveAttacks(attackers, monsters, 0.10)
|
||||||
if results[0].CoopApplied {
|
if results[0].CoopApplied {
|
||||||
t.Error("AoE should not trigger co-op bonus")
|
t.Error("AoE should not trigger co-op bonus")
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,7 @@ func TestMonsterAITauntDeadWarrior(t *testing.T) {
|
|||||||
func TestFleeChance(t *testing.T) {
|
func TestFleeChance(t *testing.T) {
|
||||||
successes := 0
|
successes := 0
|
||||||
for i := 0; i < 100; i++ {
|
for i := 0; i < 100; i++ {
|
||||||
if AttemptFlee() {
|
if AttemptFlee(0.50) {
|
||||||
successes++
|
successes++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
91
combat/combo.go
Normal file
91
combat/combo.go
Normal 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
45
combat/combo_test.go
Normal 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
57
config.yaml
Normal 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
90
config/config.go
Normal 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
94
config/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
1023
docs/superpowers/plans/2026-03-25-phase1-foundation.md
Normal file
1023
docs/superpowers/plans/2026-03-25-phase1-foundation.md
Normal file
File diff suppressed because it is too large
Load Diff
1304
docs/superpowers/plans/2026-03-25-phase2-combat-dungeon.md
Normal file
1304
docs/superpowers/plans/2026-03-25-phase2-combat-dungeon.md
Normal file
File diff suppressed because it is too large
Load Diff
786
docs/superpowers/plans/2026-03-25-phase3-retention.md
Normal file
786
docs/superpowers/plans/2026-03-25-phase3-retention.md
Normal file
@@ -0,0 +1,786 @@
|
|||||||
|
# Phase 3: Retention Systems Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add retention mechanics (daily challenges, meta progression, difficulty modes) that give players reasons to keep coming back.
|
||||||
|
|
||||||
|
**Architecture:** Extend the BoltDB store with 4 new buckets (daily_runs, unlocks, titles, codex). Daily challenges use the Phase 2 seed-based generator with a date-derived seed. Unlock/title/codex systems follow the existing achievements pattern (key-value pairs per player). Hard mode and weekly mutations modify game config at session creation. New UI screens (codex_view) follow the Phase 1 Screen interface pattern.
|
||||||
|
|
||||||
|
**Tech Stack:** Go 1.25.1, BoltDB, `math/rand`, `crypto/sha256` for date→seed hashing
|
||||||
|
|
||||||
|
**Module path:** `github.com/tolelom/catacombs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|---------------|
|
||||||
|
| `store/daily.go` | DailyRecord struct; SaveDaily, GetDailyLeaderboard, GetStreak methods |
|
||||||
|
| `store/daily_test.go` | Daily record persistence tests |
|
||||||
|
| `store/unlocks.go` | Unlock definitions; CheckUnlock, GetUnlocks methods |
|
||||||
|
| `store/unlocks_test.go` | Unlock condition tests |
|
||||||
|
| `store/titles.go` | Title definitions; EarnTitle, SetActiveTitle, GetTitles methods |
|
||||||
|
| `store/titles_test.go` | Title persistence tests |
|
||||||
|
| `store/codex.go` | Codex struct; RecordMonster/Item/Event, GetCodex methods |
|
||||||
|
| `store/codex_test.go` | Codex tracking tests |
|
||||||
|
| `game/daily.go` | DailySeed generation; CreateDailySession helper |
|
||||||
|
| `game/daily_test.go` | Seed determinism tests |
|
||||||
|
| `game/mutation.go` | Mutation definitions; GetWeeklyMutation, ApplyMutation |
|
||||||
|
| `game/mutation_test.go` | Mutation application tests |
|
||||||
|
| `ui/codex_view.go` | CodexScreen implementing Screen interface |
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `store/db.go` | Create 4 new buckets in Open(); use cfg.Game.MaxFloors in GetStats victory check |
|
||||||
|
| `config/config.go` | Add DifficultyConfig (HardMode multipliers) |
|
||||||
|
| `config.yaml` | Add difficulty section |
|
||||||
|
| `game/session.go` | Add DailyMode/HardMode/Mutation fields to GameSession; seed-based generation for daily; codex recording hooks |
|
||||||
|
| `game/event.go` | Record codex entries on monster spawn, treasure, events |
|
||||||
|
| `game/turn.go` | Record codex on monster kills; apply hard mode multipliers |
|
||||||
|
| `ui/lobby_view.go` | Add daily challenge button; show hard mode option if unlocked |
|
||||||
|
| `ui/game_view.go` | Show daily/hard mode indicators in HUD; save daily record on game over |
|
||||||
|
| `ui/title.go` | Show active title next to player name on title screen |
|
||||||
|
| `ui/leaderboard_view.go` | Add daily leaderboard tab |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Daily Challenge Seed System
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `game/daily.go`
|
||||||
|
- Create: `game/daily_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write determinism test**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// game/daily_test.go
|
||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tolelom/catacombs/dungeon"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDailySeed(t *testing.T) {
|
||||||
|
seed1 := DailySeed("2026-03-25")
|
||||||
|
seed2 := DailySeed("2026-03-25")
|
||||||
|
if seed1 != seed2 {
|
||||||
|
t.Errorf("same date should produce same seed: %d vs %d", seed1, seed2)
|
||||||
|
}
|
||||||
|
seed3 := DailySeed("2026-03-26")
|
||||||
|
if seed1 == seed3 {
|
||||||
|
t.Error("different dates should produce different seeds")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyFloorDeterminism(t *testing.T) {
|
||||||
|
seed := DailySeed("2026-03-25")
|
||||||
|
rng1 := rand.New(rand.NewSource(seed))
|
||||||
|
rng2 := rand.New(rand.NewSource(seed))
|
||||||
|
f1 := dungeon.GenerateFloor(1, rng1)
|
||||||
|
f2 := dungeon.GenerateFloor(1, rng2)
|
||||||
|
if len(f1.Rooms) != len(f2.Rooms) {
|
||||||
|
t.Fatal("daily floors should be identical")
|
||||||
|
}
|
||||||
|
for i := range f1.Rooms {
|
||||||
|
if f1.Rooms[i].Type != f2.Rooms[i].Type {
|
||||||
|
t.Errorf("room %d type differs", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement DailySeed**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// game/daily.go
|
||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DailySeed returns a deterministic seed for the given date string (YYYY-MM-DD).
|
||||||
|
func DailySeed(date string) int64 {
|
||||||
|
h := sha256.Sum256([]byte("catacombs:" + date))
|
||||||
|
return int64(binary.BigEndian.Uint64(h[:8]))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests**
|
||||||
|
|
||||||
|
Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./game/ -run TestDaily -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add game/daily.go game/daily_test.go
|
||||||
|
git commit -m "feat: add daily challenge seed generation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Daily Record Store
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `store/daily.go`
|
||||||
|
- Create: `store/daily_test.go`
|
||||||
|
- Modify: `store/db.go` (add bucket creation)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write tests**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// store/daily_test.go
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveAndGetDaily(t *testing.T) {
|
||||||
|
f, _ := os.CreateTemp("", "daily-*.db")
|
||||||
|
f.Close()
|
||||||
|
defer os.Remove(f.Name())
|
||||||
|
|
||||||
|
db, err := Open(f.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
err = db.SaveDaily(DailyRecord{
|
||||||
|
Date: "2026-03-25", Player: "Alice", PlayerName: "Alice",
|
||||||
|
FloorReached: 10, GoldEarned: 200,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := db.GetDailyLeaderboard("2026-03-25", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(records) != 1 {
|
||||||
|
t.Fatalf("expected 1 record, got %d", len(records))
|
||||||
|
}
|
||||||
|
if records[0].FloorReached != 10 {
|
||||||
|
t.Errorf("expected floor 10, got %d", records[0].FloorReached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyStreak(t *testing.T) {
|
||||||
|
f, _ := os.CreateTemp("", "streak-*.db")
|
||||||
|
f.Close()
|
||||||
|
defer os.Remove(f.Name())
|
||||||
|
|
||||||
|
db, _ := Open(f.Name())
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
db.SaveDaily(DailyRecord{Date: "2026-03-23", Player: "fp1", PlayerName: "A", FloorReached: 5, GoldEarned: 50})
|
||||||
|
db.SaveDaily(DailyRecord{Date: "2026-03-24", Player: "fp1", PlayerName: "A", FloorReached: 8, GoldEarned: 100})
|
||||||
|
db.SaveDaily(DailyRecord{Date: "2026-03-25", Player: "fp1", PlayerName: "A", FloorReached: 12, GoldEarned: 150})
|
||||||
|
|
||||||
|
streak := db.GetStreak("fp1", "2026-03-25")
|
||||||
|
if streak != 3 {
|
||||||
|
t.Errorf("expected streak 3, got %d", streak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement daily store**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// store/daily.go
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
bolt "go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var bucketDailyRuns = []byte("daily_runs")
|
||||||
|
|
||||||
|
type DailyRecord struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Player string `json:"player"` // fingerprint
|
||||||
|
PlayerName string `json:"player_name"`
|
||||||
|
FloorReached int `json:"floor_reached"`
|
||||||
|
GoldEarned int `json:"gold_earned"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) SaveDaily(record DailyRecord) error {
|
||||||
|
return d.db.Update(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(bucketDailyRuns)
|
||||||
|
key := []byte(record.Date + ":" + record.Player)
|
||||||
|
data, err := json.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return b.Put(key, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetDailyLeaderboard(date string, limit int) ([]DailyRecord, error) {
|
||||||
|
var records []DailyRecord
|
||||||
|
prefix := []byte(date + ":")
|
||||||
|
err := d.db.View(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(bucketDailyRuns)
|
||||||
|
c := b.Cursor()
|
||||||
|
for k, v := c.Seek(prefix); k != nil && len(k) >= len(prefix) && string(k[:len(prefix)]) == string(prefix); k, v = c.Next() {
|
||||||
|
var r DailyRecord
|
||||||
|
if json.Unmarshal(v, &r) == nil {
|
||||||
|
records = append(records, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Slice(records, func(i, j int) bool {
|
||||||
|
if records[i].FloorReached != records[j].FloorReached {
|
||||||
|
return records[i].FloorReached > records[j].FloorReached
|
||||||
|
}
|
||||||
|
return records[i].GoldEarned > records[j].GoldEarned
|
||||||
|
})
|
||||||
|
if len(records) > limit {
|
||||||
|
records = records[:limit]
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetStreak(fingerprint, currentDate string) int {
|
||||||
|
streak := 0
|
||||||
|
date, err := time.Parse("2006-01-02", currentDate)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for i := 0; i < 365; i++ {
|
||||||
|
checkDate := date.AddDate(0, 0, -i).Format("2006-01-02")
|
||||||
|
key := []byte(checkDate + ":" + fingerprint)
|
||||||
|
found := false
|
||||||
|
d.db.View(func(tx *bolt.Tx) error {
|
||||||
|
if tx.Bucket(bucketDailyRuns).Get(key) != nil {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if found {
|
||||||
|
streak++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return streak
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `store/db.go` `Open()`, add bucket creation:
|
||||||
|
```go
|
||||||
|
if _, err := tx.CreateBucketIfNotExists(bucketDailyRuns); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests**
|
||||||
|
|
||||||
|
Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./store/ -v`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add store/daily.go store/daily_test.go store/db.go
|
||||||
|
git commit -m "feat: add daily challenge record storage and leaderboard"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Unlock System
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `store/unlocks.go`
|
||||||
|
- Create: `store/unlocks_test.go`
|
||||||
|
- Modify: `store/db.go` (add bucket)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement unlock system**
|
||||||
|
|
||||||
|
Follow the `achievements.go` pattern. Define 3 unlocks:
|
||||||
|
- `"fifth_class"`: "Clear floor 10+" → unlocks 5th class (future)
|
||||||
|
- `"hard_mode"`: "Clear with 3+ players" → unlocks hard mode
|
||||||
|
- `"mutations"`: "Clear floor 20" → unlocks weekly mutations
|
||||||
|
|
||||||
|
```go
|
||||||
|
// store/unlocks.go
|
||||||
|
package store
|
||||||
|
|
||||||
|
import bolt "go.etcd.io/bbolt"
|
||||||
|
|
||||||
|
var bucketUnlocks = []byte("unlocks")
|
||||||
|
|
||||||
|
type UnlockDef struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
var UnlockDefs = []UnlockDef{
|
||||||
|
{ID: "fifth_class", Name: "New Recruit", Description: "Clear floor 10 to unlock a new class"},
|
||||||
|
{ID: "hard_mode", Name: "Hardened", Description: "Complete a run with 3+ players to unlock Hard Mode"},
|
||||||
|
{ID: "mutations", Name: "Chaos Unleashed", Description: "Conquer floor 20 to unlock Weekly Mutations"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) UnlockContent(fingerprint, unlockID string) (bool, error) {
|
||||||
|
key := []byte(fingerprint + ":" + unlockID)
|
||||||
|
alreadyUnlocked := false
|
||||||
|
err := d.db.Update(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(bucketUnlocks)
|
||||||
|
if b.Get(key) != nil {
|
||||||
|
alreadyUnlocked = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return b.Put(key, []byte("1"))
|
||||||
|
})
|
||||||
|
return !alreadyUnlocked, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) IsUnlocked(fingerprint, unlockID string) bool {
|
||||||
|
key := []byte(fingerprint + ":" + unlockID)
|
||||||
|
found := false
|
||||||
|
d.db.View(func(tx *bolt.Tx) error {
|
||||||
|
if tx.Bucket(bucketUnlocks).Get(key) != nil {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetUnlocks(fingerprint string) []UnlockDef {
|
||||||
|
result := make([]UnlockDef, len(UnlockDefs))
|
||||||
|
copy(result, UnlockDefs)
|
||||||
|
// Mark unlocked ones (reuse the struct, no Unlocked field — caller checks IsUnlocked)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `bucketUnlocks` creation in `store/db.go` `Open()`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write tests and verify**
|
||||||
|
|
||||||
|
Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./store/ -v`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add store/unlocks.go store/unlocks_test.go store/db.go
|
||||||
|
git commit -m "feat: add unlock system with 3 unlockable contents"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Player Titles
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `store/titles.go`
|
||||||
|
- Create: `store/titles_test.go`
|
||||||
|
- Modify: `store/db.go` (add bucket)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement titles**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// store/titles.go
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
bolt "go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var bucketTitles = []byte("titles")
|
||||||
|
|
||||||
|
type TitleDef struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
var TitleDefs = []TitleDef{
|
||||||
|
{ID: "novice", Name: "Novice"},
|
||||||
|
{ID: "explorer", Name: "Explorer"}, // reach floor 5
|
||||||
|
{ID: "veteran", Name: "Veteran"}, // reach floor 10
|
||||||
|
{ID: "champion", Name: "Champion"}, // reach floor 20
|
||||||
|
{ID: "gold_king", Name: "Gold King"}, // earn 500+ gold in one run
|
||||||
|
{ID: "team_player", Name: "Team Player"}, // complete 5 multiplayer runs
|
||||||
|
{ID: "survivor", Name: "Survivor"}, // complete a run without dying
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerTitleData struct {
|
||||||
|
ActiveTitle string `json:"active_title"`
|
||||||
|
Earned []string `json:"earned"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) EarnTitle(fingerprint, titleID string) (bool, error) {
|
||||||
|
data := d.loadTitleData(fingerprint)
|
||||||
|
for _, t := range data.Earned {
|
||||||
|
if t == titleID {
|
||||||
|
return false, nil // already earned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.Earned = append(data.Earned, titleID)
|
||||||
|
if data.ActiveTitle == "" {
|
||||||
|
data.ActiveTitle = titleID
|
||||||
|
}
|
||||||
|
return true, d.saveTitleData(fingerprint, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) SetActiveTitle(fingerprint, titleID string) error {
|
||||||
|
data := d.loadTitleData(fingerprint)
|
||||||
|
data.ActiveTitle = titleID
|
||||||
|
return d.saveTitleData(fingerprint, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetTitleData(fingerprint string) PlayerTitleData {
|
||||||
|
return d.loadTitleData(fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) loadTitleData(fingerprint string) PlayerTitleData {
|
||||||
|
var data PlayerTitleData
|
||||||
|
d.db.View(func(tx *bolt.Tx) error {
|
||||||
|
v := tx.Bucket(bucketTitles).Get([]byte(fingerprint))
|
||||||
|
if v != nil {
|
||||||
|
json.Unmarshal(v, &data)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) saveTitleData(fingerprint string, data PlayerTitleData) error {
|
||||||
|
return d.db.Update(func(tx *bolt.Tx) error {
|
||||||
|
raw, _ := json.Marshal(data)
|
||||||
|
return tx.Bucket(bucketTitles).Put([]byte(fingerprint), raw)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `bucketTitles` to `store/db.go` `Open()`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write tests and verify**
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add store/titles.go store/titles_test.go store/db.go
|
||||||
|
git commit -m "feat: add player title system with 7 titles"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Codex System
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `store/codex.go`
|
||||||
|
- Create: `store/codex_test.go`
|
||||||
|
- Modify: `store/db.go` (add bucket)
|
||||||
|
- Modify: `game/event.go` (record monster/item/event encounters)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement codex store**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// store/codex.go
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
bolt "go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var bucketCodex = []byte("codex")
|
||||||
|
|
||||||
|
type Codex struct {
|
||||||
|
Monsters map[string]bool `json:"monsters"`
|
||||||
|
Items map[string]bool `json:"items"`
|
||||||
|
Events map[string]bool `json:"events"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) RecordCodexEntry(fingerprint, category, id string) error {
|
||||||
|
return d.db.Update(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(bucketCodex)
|
||||||
|
var codex Codex
|
||||||
|
if v := b.Get([]byte(fingerprint)); v != nil {
|
||||||
|
json.Unmarshal(v, &codex)
|
||||||
|
}
|
||||||
|
if codex.Monsters == nil {
|
||||||
|
codex.Monsters = make(map[string]bool)
|
||||||
|
}
|
||||||
|
if codex.Items == nil {
|
||||||
|
codex.Items = make(map[string]bool)
|
||||||
|
}
|
||||||
|
if codex.Events == nil {
|
||||||
|
codex.Events = make(map[string]bool)
|
||||||
|
}
|
||||||
|
switch category {
|
||||||
|
case "monster":
|
||||||
|
codex.Monsters[id] = true
|
||||||
|
case "item":
|
||||||
|
codex.Items[id] = true
|
||||||
|
case "event":
|
||||||
|
codex.Events[id] = true
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(codex)
|
||||||
|
return b.Put([]byte(fingerprint), raw)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetCodex(fingerprint string) Codex {
|
||||||
|
var codex Codex
|
||||||
|
d.db.View(func(tx *bolt.Tx) error {
|
||||||
|
if v := tx.Bucket(bucketCodex).Get([]byte(fingerprint)); v != nil {
|
||||||
|
json.Unmarshal(v, &codex)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if codex.Monsters == nil {
|
||||||
|
codex.Monsters = make(map[string]bool)
|
||||||
|
}
|
||||||
|
if codex.Items == nil {
|
||||||
|
codex.Items = make(map[string]bool)
|
||||||
|
}
|
||||||
|
if codex.Events == nil {
|
||||||
|
codex.Events = make(map[string]bool)
|
||||||
|
}
|
||||||
|
return codex
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `bucketCodex` to `store/db.go`. The codex recording hooks in `game/event.go` will be added — but since GameSession doesn't have a DB reference, recording will happen from the UI layer (GameScreen) which has access to `ctx.Store`. Add codex recording in `ui/game_view.go` when the gameState changes (new monsters, items, events).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write tests and verify**
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add store/codex.go store/codex_test.go store/db.go
|
||||||
|
git commit -m "feat: add codex system for monster/item/event tracking"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Codex UI Screen
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ui/codex_view.go`
|
||||||
|
- Modify: `ui/lobby_view.go` or `ui/title.go` (add navigation to codex)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create CodexScreen**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ui/codex_view.go
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/tolelom/catacombs/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CodexScreen struct {
|
||||||
|
codex store.Codex
|
||||||
|
tab int // 0=monsters, 1=items, 2=events
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCodexScreen(ctx *Context) *CodexScreen {
|
||||||
|
codex := ctx.Store.GetCodex(ctx.Fingerprint)
|
||||||
|
return &CodexScreen{codex: codex}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implement `Update` (Tab to switch tabs, Esc to go back) and `View` (show discovered entries with completion percentage per category).
|
||||||
|
|
||||||
|
Add a keybinding in the title screen or lobby to open the codex (e.g., `c` key).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests**
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ui/codex_view.go ui/title.go
|
||||||
|
git commit -m "feat: add codex UI screen with completion tracking"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Hard Mode and Weekly Mutations
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `game/mutation.go`
|
||||||
|
- Create: `game/mutation_test.go`
|
||||||
|
- Modify: `config/config.go` (add DifficultyConfig)
|
||||||
|
- Modify: `game/session.go` (add HardMode/Mutation fields, apply at session start)
|
||||||
|
- Modify: `ui/lobby_view.go` (show hard mode toggle if unlocked)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add DifficultyConfig**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Add to config/config.go
|
||||||
|
type DifficultyConfig struct {
|
||||||
|
HardModeMonsterMult float64 `yaml:"hard_mode_monster_mult"` // default: 1.5
|
||||||
|
HardModeShopMult float64 `yaml:"hard_mode_shop_mult"` // default: 2.0
|
||||||
|
HardModeHealMult float64 `yaml:"hard_mode_heal_mult"` // default: 0.5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to Config struct and defaults.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement mutations**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// game/mutation.go
|
||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tolelom/catacombs/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Mutation struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Apply func(cfg *config.GameConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mutations = []Mutation{
|
||||||
|
{ID: "no_skills", Name: "Skill Lockout", Description: "Class skills are disabled",
|
||||||
|
Apply: func(cfg *config.GameConfig) { cfg.SkillUses = 0 }},
|
||||||
|
{ID: "elite_flood", Name: "Elite Flood", Description: "All monsters are elite",
|
||||||
|
Apply: func(cfg *config.GameConfig) {}}, // handled in event.go spawn logic
|
||||||
|
{ID: "no_shop", Name: "Shop Closed", Description: "Shops are unavailable",
|
||||||
|
Apply: func(cfg *config.GameConfig) {}}, // handled in event.go room generation
|
||||||
|
{ID: "glass_cannon", Name: "Glass Cannon", Description: "Double ATK, half HP",
|
||||||
|
Apply: func(cfg *config.GameConfig) {}}, // handled at player creation
|
||||||
|
{ID: "speed_run", Name: "Speed Run", Description: "Turn timeout halved",
|
||||||
|
Apply: func(cfg *config.GameConfig) { cfg.TurnTimeoutSec = cfg.TurnTimeoutSec / 2 }},
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetWeeklyMutation() Mutation {
|
||||||
|
_, week := time.Now().ISOWeek()
|
||||||
|
year, _ := time.Now().ISOWeek()
|
||||||
|
h := sha256.Sum256([]byte(fmt.Sprintf("mutation:%d:%d", year, week)))
|
||||||
|
idx := int(binary.BigEndian.Uint64(h[:8])) % len(mutations)
|
||||||
|
return mutations[idx]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire into session**
|
||||||
|
|
||||||
|
Add `HardMode bool` and `ActiveMutation *Mutation` fields to `GameSession`. When creating a session for hard mode, apply difficulty multipliers to a cloned config. When creating a mutation session, call `mutation.Apply(&cfg.Game)`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add hard mode toggle in lobby UI**
|
||||||
|
|
||||||
|
In `LobbyScreen`, if `ctx.Store.IsUnlocked(ctx.Fingerprint, "hard_mode")`, show a `[H] Hard Mode` toggle. Store the selection and pass to session creation.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run all tests**
|
||||||
|
|
||||||
|
Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./... -v`
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add game/mutation.go game/mutation_test.go config/ game/session.go ui/lobby_view.go config.yaml
|
||||||
|
git commit -m "feat: add hard mode and weekly mutation system"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Integration — Daily Challenge Flow + Codex Recording
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/lobby_view.go` (add daily challenge button)
|
||||||
|
- Modify: `ui/game_view.go` (save daily record on game over; record codex entries)
|
||||||
|
- Modify: `ui/leaderboard_view.go` (add daily tab)
|
||||||
|
- Modify: `game/session.go` (add DailyMode field, use daily seed)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add DailyMode to GameSession**
|
||||||
|
|
||||||
|
Add `DailyMode bool` and `DailyDate string` fields. When DailyMode, use `DailySeed(date)` to create the RNG for `GenerateFloor`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add daily challenge in lobby**
|
||||||
|
|
||||||
|
In `LobbyScreen`, add `[D] Daily Challenge` button. Creates a solo session with DailyMode=true using today's date.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Save daily record on game over**
|
||||||
|
|
||||||
|
In `GameScreen`, when game ends and `ctx.Session.DailyMode`, call `ctx.Store.SaveDaily(...)`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Record codex entries**
|
||||||
|
|
||||||
|
In `GameScreen.Update()`, when gameState shows new monsters (entering combat), record them to codex. When getting items (treasure/shop), record items. When events trigger, record event IDs.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add daily leaderboard tab**
|
||||||
|
|
||||||
|
In `LeaderboardScreen`, add a tab for daily leaderboard using `ctx.Store.GetDailyLeaderboard(today, 20)`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Trigger unlock checks**
|
||||||
|
|
||||||
|
In `GameScreen`, after game over:
|
||||||
|
- Floor >= 10 → unlock "fifth_class"
|
||||||
|
- Players >= 3 && floor >= 5 → unlock "hard_mode"
|
||||||
|
- Victory (floor >= maxFloors) → unlock "mutations"
|
||||||
|
|
||||||
|
Trigger title earning based on conditions.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run all tests**
|
||||||
|
|
||||||
|
Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./... -v`
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ui/ game/session.go
|
||||||
|
git commit -m "feat: integrate daily challenges, codex recording, and unlock triggers"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Final Verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run full test suite**
|
||||||
|
|
||||||
|
Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./... -v`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run go vet**
|
||||||
|
|
||||||
|
Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go vet ./...`
|
||||||
|
Expected: No issues
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build**
|
||||||
|
|
||||||
|
Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go build -o catacombs.exe .`
|
||||||
|
Expected: Success
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify new BoltDB buckets**
|
||||||
|
|
||||||
|
Confirm `store/db.go` `Open()` creates all 7 buckets: profiles, rankings, achievements, daily_runs, unlocks, titles, codex.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit if cleanup needed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: phase 3 complete — retention systems verified"
|
||||||
|
```
|
||||||
941
docs/superpowers/plans/2026-03-25-phase4-operations.md
Normal file
941
docs/superpowers/plans/2026-03-25-phase4-operations.md
Normal 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.
|
||||||
358
docs/superpowers/specs/2026-03-25-game-enhancement-design.md
Normal file
358
docs/superpowers/specs/2026-03-25-game-enhancement-design.md
Normal 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 유지)
|
||||||
@@ -19,7 +19,7 @@ type bspNode struct {
|
|||||||
roomIdx int
|
roomIdx int
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateFloor(floorNum int) *Floor {
|
func GenerateFloor(floorNum int, rng *rand.Rand) *Floor {
|
||||||
// Create tile map filled with walls
|
// Create tile map filled with walls
|
||||||
tiles := make([][]Tile, MapHeight)
|
tiles := make([][]Tile, MapHeight)
|
||||||
for y := 0; y < MapHeight; y++ {
|
for y := 0; y < MapHeight; y++ {
|
||||||
@@ -29,21 +29,21 @@ func GenerateFloor(floorNum int) *Floor {
|
|||||||
|
|
||||||
// BSP tree
|
// BSP tree
|
||||||
root := &bspNode{x: 0, y: 0, w: MapWidth, h: MapHeight}
|
root := &bspNode{x: 0, y: 0, w: MapWidth, h: MapHeight}
|
||||||
splitBSP(root, 0)
|
splitBSP(root, 0, rng)
|
||||||
|
|
||||||
// Collect leaf nodes
|
// Collect leaf nodes
|
||||||
var leaves []*bspNode
|
var leaves []*bspNode
|
||||||
collectLeaves(root, &leaves)
|
collectLeaves(root, &leaves)
|
||||||
|
|
||||||
// Shuffle leaves so room assignment is varied
|
// 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]
|
leaves[i], leaves[j] = leaves[j], leaves[i]
|
||||||
})
|
})
|
||||||
|
|
||||||
// We want 5-8 rooms. If we have more leaves, merge some; if fewer, accept it.
|
// 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).
|
// Ensure at least 5 leaves by re-generating if needed (BSP should produce enough).
|
||||||
// Cap at 8 rooms.
|
// Cap at 8 rooms.
|
||||||
targetRooms := 5 + rand.Intn(4) // 5..8
|
targetRooms := 5 + rng.Intn(4) // 5..8
|
||||||
if len(leaves) > targetRooms {
|
if len(leaves) > targetRooms {
|
||||||
leaves = leaves[:targetRooms]
|
leaves = leaves[:targetRooms]
|
||||||
}
|
}
|
||||||
@@ -64,21 +64,21 @@ func GenerateFloor(floorNum int) *Floor {
|
|||||||
|
|
||||||
rw := MinRoomW
|
rw := MinRoomW
|
||||||
if maxW > MinRoomW {
|
if maxW > MinRoomW {
|
||||||
rw = MinRoomW + rand.Intn(maxW-MinRoomW+1)
|
rw = MinRoomW + rng.Intn(maxW-MinRoomW+1)
|
||||||
}
|
}
|
||||||
rh := MinRoomH
|
rh := MinRoomH
|
||||||
if maxH > MinRoomH {
|
if maxH > MinRoomH {
|
||||||
rh = MinRoomH + rand.Intn(maxH-MinRoomH+1)
|
rh = MinRoomH + rng.Intn(maxH-MinRoomH+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position room within the leaf
|
// Position room within the leaf
|
||||||
rx := leaf.x + RoomPad
|
rx := leaf.x + RoomPad
|
||||||
if leaf.w-2*RoomPad > rw {
|
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
|
ry := leaf.y + RoomPad
|
||||||
if leaf.h-2*RoomPad > rh {
|
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
|
// Clamp to map bounds
|
||||||
@@ -95,7 +95,7 @@ func GenerateFloor(floorNum int) *Floor {
|
|||||||
ry = 1
|
ry = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
rt := RandomRoomType()
|
rt := RandomRoomType(rng)
|
||||||
rooms[i] = &Room{
|
rooms[i] = &Room{
|
||||||
Type: rt,
|
Type: rt,
|
||||||
X: rx,
|
X: rx,
|
||||||
@@ -111,6 +111,14 @@ func GenerateFloor(floorNum int) *Floor {
|
|||||||
// Last room is boss
|
// Last room is boss
|
||||||
rooms[len(rooms)-1].Type = RoomBoss
|
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
|
// Carve rooms into tile map
|
||||||
for _, room := range rooms {
|
for _, room := range rooms {
|
||||||
for dy := 0; dy < room.H; dy++ {
|
for dy := 0; dy < room.H; dy++ {
|
||||||
@@ -132,10 +140,10 @@ func GenerateFloor(floorNum int) *Floor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add 1-2 extra connections
|
// Add 1-2 extra connections
|
||||||
extras := 1 + rand.Intn(2)
|
extras := 1 + rng.Intn(2)
|
||||||
for e := 0; e < extras; e++ {
|
for e := 0; e < extras; e++ {
|
||||||
a := rand.Intn(len(rooms))
|
a := rng.Intn(len(rooms))
|
||||||
b := rand.Intn(len(rooms))
|
b := rng.Intn(len(rooms))
|
||||||
if a != b && !hasNeighbor(rooms[a], b) {
|
if a != b && !hasNeighbor(rooms[a], b) {
|
||||||
rooms[a].Neighbors = append(rooms[a].Neighbors, b)
|
rooms[a].Neighbors = append(rooms[a].Neighbors, b)
|
||||||
rooms[b].Neighbors = append(rooms[b].Neighbors, a)
|
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
|
// Stop conditions
|
||||||
if depth > 4 {
|
if depth > 4 {
|
||||||
return
|
return
|
||||||
@@ -163,12 +171,12 @@ func splitBSP(node *bspNode, depth int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Random chance to stop splitting (more likely at deeper levels)
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decide split direction
|
// Decide split direction
|
||||||
horizontal := rand.Float64() < 0.5
|
horizontal := rng.Float64() < 0.5
|
||||||
if node.w < MinLeafW*2 {
|
if node.w < MinLeafW*2 {
|
||||||
horizontal = true
|
horizontal = true
|
||||||
}
|
}
|
||||||
@@ -180,20 +188,20 @@ func splitBSP(node *bspNode, depth int) {
|
|||||||
if node.h < MinLeafH*2 {
|
if node.h < MinLeafH*2 {
|
||||||
return
|
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.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}
|
node.right = &bspNode{x: node.x, y: node.y + split, w: node.w, h: node.h - split}
|
||||||
} else {
|
} else {
|
||||||
if node.w < MinLeafW*2 {
|
if node.w < MinLeafW*2 {
|
||||||
return
|
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.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}
|
node.right = &bspNode{x: node.x + split, y: node.y, w: node.w - split, h: node.h}
|
||||||
}
|
}
|
||||||
|
|
||||||
splitBSP(node.left, depth+1)
|
splitBSP(node.left, depth+1, rng)
|
||||||
splitBSP(node.right, depth+1)
|
splitBSP(node.right, depth+1, rng)
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectLeaves(node *bspNode, leaves *[]*bspNode) {
|
func collectLeaves(node *bspNode, leaves *[]*bspNode) {
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
package dungeon
|
package dungeon
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestRng() *rand.Rand {
|
||||||
|
return rand.New(rand.NewSource(rand.Int63()))
|
||||||
|
}
|
||||||
|
|
||||||
func TestGenerateFloor(t *testing.T) {
|
func TestGenerateFloor(t *testing.T) {
|
||||||
floor := GenerateFloor(1)
|
floor := GenerateFloor(1, newTestRng())
|
||||||
if len(floor.Rooms) < 5 || len(floor.Rooms) > 8 {
|
if len(floor.Rooms) < 5 || len(floor.Rooms) > 8 {
|
||||||
t.Errorf("Room count: got %d, want 5~8", len(floor.Rooms))
|
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) {
|
func TestRoomTypeProbability(t *testing.T) {
|
||||||
counts := make(map[RoomType]int)
|
counts := make(map[RoomType]int)
|
||||||
n := 10000
|
n := 10000
|
||||||
|
rng := rand.New(rand.NewSource(12345))
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
counts[RandomRoomType()]++
|
counts[RandomRoomType(rng)]++
|
||||||
}
|
}
|
||||||
combatPct := float64(counts[RoomCombat]) / float64(n) * 100
|
combatPct := float64(counts[RoomCombat]) / float64(n) * 100
|
||||||
if combatPct < 40 || combatPct > 50 {
|
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) {
|
func TestFloorHasTileMap(t *testing.T) {
|
||||||
floor := GenerateFloor(1)
|
floor := GenerateFloor(1, newTestRng())
|
||||||
if floor.Tiles == nil {
|
if floor.Tiles == nil {
|
||||||
t.Fatal("Floor should have tile map")
|
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)
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ const (
|
|||||||
RoomEvent
|
RoomEvent
|
||||||
RoomEmpty
|
RoomEmpty
|
||||||
RoomBoss
|
RoomBoss
|
||||||
|
RoomSecret
|
||||||
|
RoomMiniBoss
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r RoomType) String() string {
|
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
|
type Tile int
|
||||||
@@ -44,16 +46,18 @@ type Floor struct {
|
|||||||
Height int
|
Height int
|
||||||
}
|
}
|
||||||
|
|
||||||
func RandomRoomType() RoomType {
|
func RandomRoomType(rng *rand.Rand) RoomType {
|
||||||
r := rand.Float64() * 100
|
r := rng.Float64() * 100
|
||||||
switch {
|
switch {
|
||||||
case r < 45:
|
case r < 5:
|
||||||
|
return RoomSecret
|
||||||
|
case r < 50:
|
||||||
return RoomCombat
|
return RoomCombat
|
||||||
case r < 60:
|
case r < 65:
|
||||||
return RoomTreasure
|
return RoomTreasure
|
||||||
case r < 70:
|
case r < 75:
|
||||||
return RoomShop
|
return RoomShop
|
||||||
case r < 85:
|
case r < 90:
|
||||||
return RoomEvent
|
return RoomEvent
|
||||||
default:
|
default:
|
||||||
return RoomEmpty
|
return RoomEmpty
|
||||||
|
|||||||
32
dungeon/theme.go
Normal file
32
dungeon/theme.go
Normal 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
32
dungeon/theme_test.go
Normal 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
47
entity/elite.go
Normal 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
94
entity/elite_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ const (
|
|||||||
MonsterBoss10
|
MonsterBoss10
|
||||||
MonsterBoss15
|
MonsterBoss15
|
||||||
MonsterBoss20
|
MonsterBoss20
|
||||||
|
MonsterMiniBoss5
|
||||||
|
MonsterMiniBoss10
|
||||||
|
MonsterMiniBoss15
|
||||||
|
MonsterMiniBoss20
|
||||||
)
|
)
|
||||||
|
|
||||||
type monsterBase struct {
|
type monsterBase struct {
|
||||||
@@ -31,6 +35,10 @@ var monsterDefs = map[MonsterType]monsterBase{
|
|||||||
MonsterBoss10: {"Warden", 250, 22, 12, 10, true},
|
MonsterBoss10: {"Warden", 250, 22, 12, 10, true},
|
||||||
MonsterBoss15: {"Overlord", 400, 30, 16, 15, true},
|
MonsterBoss15: {"Overlord", 400, 30, 16, 15, true},
|
||||||
MonsterBoss20: {"Archlich", 600, 40, 20, 20, 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
|
type BossPattern int
|
||||||
@@ -41,6 +49,7 @@ const (
|
|||||||
PatternPoison // applies poison
|
PatternPoison // applies poison
|
||||||
PatternBurn // applies burn to random player
|
PatternBurn // applies burn to random player
|
||||||
PatternHeal // heals self
|
PatternHeal // heals self
|
||||||
|
PatternFreeze // applies freeze to all players
|
||||||
)
|
)
|
||||||
|
|
||||||
type Monster struct {
|
type Monster struct {
|
||||||
@@ -49,28 +58,34 @@ type Monster struct {
|
|||||||
HP, MaxHP int
|
HP, MaxHP int
|
||||||
ATK, DEF int
|
ATK, DEF int
|
||||||
IsBoss bool
|
IsBoss bool
|
||||||
|
IsMiniBoss bool
|
||||||
|
IsElite bool
|
||||||
|
ElitePrefix ElitePrefixType
|
||||||
TauntTarget bool
|
TauntTarget bool
|
||||||
TauntTurns int
|
TauntTurns int
|
||||||
Pattern BossPattern
|
Pattern BossPattern
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMonster(mt MonsterType, floor int) *Monster {
|
func NewMonster(mt MonsterType, floor int, scaling float64) *Monster {
|
||||||
base := monsterDefs[mt]
|
base := monsterDefs[mt]
|
||||||
|
isMiniBoss := mt == MonsterMiniBoss5 || mt == MonsterMiniBoss10 ||
|
||||||
|
mt == MonsterMiniBoss15 || mt == MonsterMiniBoss20
|
||||||
scale := 1.0
|
scale := 1.0
|
||||||
if !base.IsBoss && floor > base.MinFloor {
|
if !base.IsBoss && !isMiniBoss && floor > base.MinFloor {
|
||||||
scale = math.Pow(1.15, float64(floor-base.MinFloor))
|
scale = math.Pow(scaling, float64(floor-base.MinFloor))
|
||||||
}
|
}
|
||||||
hp := int(math.Round(float64(base.HP) * scale))
|
hp := int(math.Round(float64(base.HP) * scale))
|
||||||
atk := int(math.Round(float64(base.ATK) * scale))
|
atk := int(math.Round(float64(base.ATK) * scale))
|
||||||
def := int(math.Round(float64(base.DEF) * scale))
|
def := int(math.Round(float64(base.DEF) * scale))
|
||||||
return &Monster{
|
return &Monster{
|
||||||
Name: base.Name,
|
Name: base.Name,
|
||||||
Type: mt,
|
Type: mt,
|
||||||
HP: hp,
|
HP: hp,
|
||||||
MaxHP: hp,
|
MaxHP: hp,
|
||||||
ATK: atk,
|
ATK: atk,
|
||||||
DEF: def,
|
DEF: def,
|
||||||
IsBoss: base.IsBoss,
|
IsBoss: base.IsBoss,
|
||||||
|
IsMiniBoss: isMiniBoss,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestMonsterScaling(t *testing.T) {
|
func TestMonsterScaling(t *testing.T) {
|
||||||
slime := NewMonster(MonsterSlime, 1)
|
slime := NewMonster(MonsterSlime, 1, 1.15)
|
||||||
if slime.HP != 20 || slime.ATK != 5 {
|
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)
|
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)))
|
expectedHP := int(math.Round(20 * math.Pow(1.15, 2)))
|
||||||
if slimeF3.HP != expectedHP {
|
if slimeF3.HP != expectedHP {
|
||||||
t.Errorf("Slime floor 3: got HP=%d, want %d", 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) {
|
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 {
|
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)
|
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) {
|
func TestMonsterDEFScaling(t *testing.T) {
|
||||||
// Slime base DEF=1, minFloor=1. At floor 5, scale = 1.15^4 ≈ 1.749
|
// 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 {
|
if m.DEF <= 1 {
|
||||||
t.Errorf("Slime DEF at floor 5 should be scaled above base 1, got %d", m.DEF)
|
t.Errorf("Slime DEF at floor 5 should be scaled above base 1, got %d", m.DEF)
|
||||||
}
|
}
|
||||||
// Boss DEF should NOT scale
|
// Boss DEF should NOT scale
|
||||||
boss := NewMonster(MonsterBoss5, 5)
|
boss := NewMonster(MonsterBoss5, 5, 1.15)
|
||||||
if boss.DEF != 8 {
|
if boss.DEF != 8 {
|
||||||
t.Errorf("Boss5 DEF should be base 8, got %d", boss.DEF)
|
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) {
|
func TestMonsterAtMinFloor(t *testing.T) {
|
||||||
// Slime at floor 1 (minFloor=1) should have base stats
|
// 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 {
|
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)
|
t.Errorf("Slime at min floor should be base stats, got HP=%d ATK=%d DEF=%d", m.HP, m.ATK, m.DEF)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ const (
|
|||||||
StatusPoison StatusEffect = iota
|
StatusPoison StatusEffect = iota
|
||||||
StatusBurn
|
StatusBurn
|
||||||
StatusFreeze
|
StatusFreeze
|
||||||
|
StatusBleed
|
||||||
|
StatusCurse
|
||||||
)
|
)
|
||||||
|
|
||||||
type ActiveEffect struct {
|
type ActiveEffect struct {
|
||||||
@@ -53,6 +55,7 @@ type Player struct {
|
|||||||
Dead bool
|
Dead bool
|
||||||
Fled bool
|
Fled bool
|
||||||
SkillUses int // remaining skill uses this combat
|
SkillUses int // remaining skill uses this combat
|
||||||
|
Skills *PlayerSkills
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPlayer(name string, class Class) *Player {
|
func NewPlayer(name string, class Class) *Player {
|
||||||
@@ -76,6 +79,12 @@ func (p *Player) TakeDamage(dmg int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Player) Heal(amount 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
|
p.HP += amount
|
||||||
if p.HP > p.MaxHP {
|
if p.HP > p.MaxHP {
|
||||||
p.HP = p.MaxHP
|
p.HP = p.MaxHP
|
||||||
@@ -110,6 +119,7 @@ func (p *Player) EffectiveATK() int {
|
|||||||
atk += r.Value
|
atk += r.Value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
atk += p.Skills.GetATKBonus(p.Class)
|
||||||
return atk
|
return atk
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +135,7 @@ func (p *Player) EffectiveDEF() int {
|
|||||||
def += r.Value
|
def += r.Value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
def += p.Skills.GetDEFBonus(p.Class)
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,29 +168,45 @@ func (p *Player) HasEffect(t StatusEffect) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Player) TickEffects() (damages []string) {
|
func (p *Player) TickEffects() []string {
|
||||||
var remaining []ActiveEffect
|
var msgs []string
|
||||||
for _, e := range p.Effects {
|
remaining := p.Effects[:0] // reuse underlying array
|
||||||
|
for i := 0; i < len(p.Effects); i++ {
|
||||||
|
e := &p.Effects[i]
|
||||||
switch e.Type {
|
switch e.Type {
|
||||||
case StatusPoison:
|
case StatusPoison:
|
||||||
p.HP -= e.Value
|
p.HP -= e.Value
|
||||||
if p.HP <= 0 {
|
if p.HP <= 0 {
|
||||||
p.HP = 1 // Poison can't kill, leaves at 1 HP
|
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:
|
case StatusBurn:
|
||||||
p.HP -= e.Value
|
p.HP -= e.Value
|
||||||
if p.HP <= 0 {
|
if p.HP <= 0 {
|
||||||
p.HP = 0
|
p.HP = 0
|
||||||
p.Dead = true
|
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--
|
e.Duration--
|
||||||
if e.Duration > 0 {
|
if e.Duration > 0 {
|
||||||
remaining = append(remaining, e)
|
remaining = append(remaining, *e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.Effects = remaining
|
p.Effects = remaining
|
||||||
return
|
if p.HP <= 0 && !p.Dead {
|
||||||
|
p.Dead = true
|
||||||
|
}
|
||||||
|
return msgs
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package entity
|
package entity
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestNewPlayer(t *testing.T) {
|
func TestNewPlayer(t *testing.T) {
|
||||||
p := NewPlayer("testuser", ClassWarrior)
|
p := NewPlayer("testuser", ClassWarrior)
|
||||||
@@ -190,3 +193,49 @@ func TestEffectOverwrite(t *testing.T) {
|
|||||||
t.Error("should have overwritten with new values")
|
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
196
entity/skill_tree.go
Normal 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
148
entity/skill_tree_test.go
Normal 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
11
game/daily.go
Normal 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
42
game/daily_test.go
Normal 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
17
game/emote.go
Normal 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
31
game/emote_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
176
game/event.go
176
game/event.go
@@ -48,6 +48,14 @@ func (s *GameSession) EnterRoom(roomIdx int) {
|
|||||||
case dungeon.RoomEvent:
|
case dungeon.RoomEvent:
|
||||||
s.triggerEvent()
|
s.triggerEvent()
|
||||||
room.Cleared = true
|
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:
|
case dungeon.RoomEmpty:
|
||||||
room.Cleared = true
|
room.Cleared = true
|
||||||
}
|
}
|
||||||
@@ -81,21 +89,24 @@ func (s *GameSession) spawnMonsters() {
|
|||||||
|
|
||||||
for i := 0; i < count; i++ {
|
for i := 0; i < count; i++ {
|
||||||
mt := valid[rand.Intn(len(valid))]
|
mt := valid[rand.Intn(len(valid))]
|
||||||
m := entity.NewMonster(mt, floor)
|
m := entity.NewMonster(mt, floor, s.cfg.Combat.MonsterScaling)
|
||||||
if s.state.SoloMode {
|
if s.state.SoloMode {
|
||||||
m.HP = m.HP / 2
|
m.HP = int(float64(m.HP) * s.cfg.Combat.SoloHPReduction)
|
||||||
if m.HP < 1 {
|
if m.HP < 1 {
|
||||||
m.HP = 1
|
m.HP = 1
|
||||||
}
|
}
|
||||||
m.MaxHP = m.HP
|
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
|
s.state.Monsters[i] = m
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset skill uses for all players at combat start
|
// Reset skill uses for all players at combat start
|
||||||
for _, p := range s.state.Players {
|
for _, p := range s.state.Players {
|
||||||
p.SkillUses = 3
|
p.SkillUses = s.cfg.Game.SkillUses
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,34 +124,34 @@ func (s *GameSession) spawnBoss() {
|
|||||||
default:
|
default:
|
||||||
mt = entity.MonsterBoss5
|
mt = entity.MonsterBoss5
|
||||||
}
|
}
|
||||||
boss := entity.NewMonster(mt, s.state.FloorNum)
|
boss := entity.NewMonster(mt, s.state.FloorNum, s.cfg.Combat.MonsterScaling)
|
||||||
switch mt {
|
switch mt {
|
||||||
case entity.MonsterBoss5:
|
case entity.MonsterBoss5:
|
||||||
boss.Pattern = entity.PatternAoE
|
boss.Pattern = entity.PatternPoison // Swamp theme
|
||||||
case entity.MonsterBoss10:
|
case entity.MonsterBoss10:
|
||||||
boss.Pattern = entity.PatternPoison
|
boss.Pattern = entity.PatternBurn // Volcano theme
|
||||||
case entity.MonsterBoss15:
|
case entity.MonsterBoss15:
|
||||||
boss.Pattern = entity.PatternBurn
|
boss.Pattern = entity.PatternFreeze // Glacier theme
|
||||||
case entity.MonsterBoss20:
|
case entity.MonsterBoss20:
|
||||||
boss.Pattern = entity.PatternHeal
|
boss.Pattern = entity.PatternHeal // Inferno theme (+ natural AoE every 3 turns)
|
||||||
}
|
}
|
||||||
if s.state.SoloMode {
|
if s.state.SoloMode {
|
||||||
boss.HP = boss.HP / 2
|
boss.HP = int(float64(boss.HP) * s.cfg.Combat.SoloHPReduction)
|
||||||
boss.MaxHP = boss.HP
|
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}
|
s.state.Monsters = []*entity.Monster{boss}
|
||||||
|
|
||||||
// Reset skill uses for all players at combat start
|
// Reset skill uses for all players at combat start
|
||||||
for _, p := range s.state.Players {
|
for _, p := range s.state.Players {
|
||||||
p.SkillUses = 3
|
p.SkillUses = s.cfg.Game.SkillUses
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameSession) grantTreasure() {
|
func (s *GameSession) grantTreasure() {
|
||||||
floor := s.state.FloorNum
|
floor := s.state.FloorNum
|
||||||
for _, p := range s.state.Players {
|
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))
|
s.addLog(fmt.Sprintf("%s's inventory is full!", p.Name))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -209,21 +220,136 @@ func armorName(floor int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameSession) triggerEvent() {
|
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 {
|
for _, p := range s.state.Players {
|
||||||
if p.IsDead() {
|
if !p.IsDead() {
|
||||||
continue
|
alive = append(alive, p)
|
||||||
}
|
}
|
||||||
if rand.Float64() < 0.5 {
|
}
|
||||||
baseDmg := 10 + s.state.FloorNum
|
if len(alive) == 0 {
|
||||||
dmg := baseDmg + rand.Intn(baseDmg/2+1)
|
return
|
||||||
p.TakeDamage(dmg)
|
}
|
||||||
s.addLog(fmt.Sprintf("Trap! %s takes %d damage", p.Name, dmg))
|
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 {
|
} else {
|
||||||
baseHeal := 15 + s.state.FloorNum
|
s.addLog(fmt.Sprintf(" %s loses %d gold", target.Name, -outcome.GoldChange))
|
||||||
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))
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package game
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/tolelom/catacombs/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RoomStatus int
|
type RoomStatus int
|
||||||
@@ -36,19 +39,25 @@ type OnlinePlayer struct {
|
|||||||
|
|
||||||
type Lobby struct {
|
type Lobby struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
cfg *config.Config
|
||||||
rooms map[string]*LobbyRoom
|
rooms map[string]*LobbyRoom
|
||||||
online map[string]*OnlinePlayer // fingerprint -> player
|
online map[string]*OnlinePlayer // fingerprint -> player
|
||||||
activeSessions map[string]string // fingerprint -> room code (for reconnect)
|
activeSessions map[string]string // fingerprint -> room code (for reconnect)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLobby() *Lobby {
|
func NewLobby(cfg *config.Config) *Lobby {
|
||||||
return &Lobby{
|
return &Lobby{
|
||||||
|
cfg: cfg,
|
||||||
rooms: make(map[string]*LobbyRoom),
|
rooms: make(map[string]*LobbyRoom),
|
||||||
online: make(map[string]*OnlinePlayer),
|
online: make(map[string]*OnlinePlayer),
|
||||||
activeSessions: make(map[string]string),
|
activeSessions: make(map[string]string),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *Lobby) Cfg() *config.Config {
|
||||||
|
return l.cfg
|
||||||
|
}
|
||||||
|
|
||||||
func (l *Lobby) RegisterSession(fingerprint, roomCode string) {
|
func (l *Lobby) RegisterSession(fingerprint, roomCode string) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
@@ -130,6 +139,7 @@ func (l *Lobby) CreateRoom(name string) string {
|
|||||||
Name: name,
|
Name: name,
|
||||||
Status: RoomWaiting,
|
Status: RoomWaiting,
|
||||||
}
|
}
|
||||||
|
slog.Info("room created", "code", code, "name", name)
|
||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,13 +150,14 @@ func (l *Lobby) JoinRoom(code, playerName, fingerprint string) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("room %s not found", code)
|
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)
|
return fmt.Errorf("room %s is full", code)
|
||||||
}
|
}
|
||||||
if room.Status != RoomWaiting {
|
if room.Status != RoomWaiting {
|
||||||
return fmt.Errorf("room %s already in progress", code)
|
return fmt.Errorf("room %s already in progress", code)
|
||||||
}
|
}
|
||||||
room.Players = append(room.Players, LobbyPlayer{Name: playerName, Fingerprint: fingerprint})
|
room.Players = append(room.Players, LobbyPlayer{Name: playerName, Fingerprint: fingerprint})
|
||||||
|
slog.Info("player joined", "room", code, "player", playerName)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,6 +221,7 @@ func (l *Lobby) StartRoom(code string) {
|
|||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
if room, ok := l.rooms[code]; ok {
|
if room, ok := l.rooms[code]; ok {
|
||||||
room.Status = RoomPlaying
|
room.Status = RoomPlaying
|
||||||
|
slog.Info("game started", "room", code, "players", len(room.Players))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
package game
|
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) {
|
func TestCreateRoom(t *testing.T) {
|
||||||
lobby := NewLobby()
|
lobby := NewLobby(testConfig(t))
|
||||||
code := lobby.CreateRoom("Test Room")
|
code := lobby.CreateRoom("Test Room")
|
||||||
if len(code) != 4 {
|
if len(code) != 4 {
|
||||||
t.Errorf("Room code length: got %d, want 4", len(code))
|
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) {
|
func TestJoinRoom(t *testing.T) {
|
||||||
lobby := NewLobby()
|
lobby := NewLobby(testConfig(t))
|
||||||
code := lobby.CreateRoom("Test Room")
|
code := lobby.CreateRoom("Test Room")
|
||||||
err := lobby.JoinRoom(code, "player1", "fp-player1")
|
err := lobby.JoinRoom(code, "player1", "fp-player1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -28,7 +38,7 @@ func TestJoinRoom(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRoomStatusTransition(t *testing.T) {
|
func TestRoomStatusTransition(t *testing.T) {
|
||||||
l := NewLobby()
|
l := NewLobby(testConfig(t))
|
||||||
code := l.CreateRoom("Test")
|
code := l.CreateRoom("Test")
|
||||||
l.JoinRoom(code, "Alice", "fp-alice")
|
l.JoinRoom(code, "Alice", "fp-alice")
|
||||||
r := l.GetRoom(code)
|
r := l.GetRoom(code)
|
||||||
@@ -47,7 +57,7 @@ func TestRoomStatusTransition(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestJoinRoomFull(t *testing.T) {
|
func TestJoinRoomFull(t *testing.T) {
|
||||||
lobby := NewLobby()
|
lobby := NewLobby(testConfig(t))
|
||||||
code := lobby.CreateRoom("Test Room")
|
code := lobby.CreateRoom("Test Room")
|
||||||
for i := 0; i < 4; i++ {
|
for i := 0; i < 4; i++ {
|
||||||
lobby.JoinRoom(code, "player", "fp-player")
|
lobby.JoinRoom(code, "player", "fp-player")
|
||||||
@@ -59,7 +69,7 @@ func TestJoinRoomFull(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSetPlayerClass(t *testing.T) {
|
func TestSetPlayerClass(t *testing.T) {
|
||||||
l := NewLobby()
|
l := NewLobby(testConfig(t))
|
||||||
code := l.CreateRoom("Test")
|
code := l.CreateRoom("Test")
|
||||||
l.JoinRoom(code, "Alice", "fp-alice")
|
l.JoinRoom(code, "Alice", "fp-alice")
|
||||||
l.SetPlayerClass(code, "fp-alice", "Warrior")
|
l.SetPlayerClass(code, "fp-alice", "Warrior")
|
||||||
@@ -70,7 +80,7 @@ func TestSetPlayerClass(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAllReady(t *testing.T) {
|
func TestAllReady(t *testing.T) {
|
||||||
l := NewLobby()
|
l := NewLobby(testConfig(t))
|
||||||
code := l.CreateRoom("Test")
|
code := l.CreateRoom("Test")
|
||||||
l.JoinRoom(code, "Alice", "fp-alice")
|
l.JoinRoom(code, "Alice", "fp-alice")
|
||||||
l.JoinRoom(code, "Bob", "fp-bob")
|
l.JoinRoom(code, "Bob", "fp-bob")
|
||||||
@@ -91,7 +101,7 @@ func TestAllReady(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAllReadyEmptyRoom(t *testing.T) {
|
func TestAllReadyEmptyRoom(t *testing.T) {
|
||||||
l := NewLobby()
|
l := NewLobby(testConfig(t))
|
||||||
code := l.CreateRoom("Test")
|
code := l.CreateRoom("Test")
|
||||||
if l.AllReady(code) {
|
if l.AllReady(code) {
|
||||||
t.Error("empty room should not be all ready")
|
t.Error("empty room should not be all ready")
|
||||||
|
|||||||
41
game/mutation.go
Normal file
41
game/mutation.go
Normal 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
68
game/mutation_test.go
Normal 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
247
game/random_event.go
Normal 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
100
game/random_event_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ package game
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tolelom/catacombs/config"
|
||||||
"github.com/tolelom/catacombs/dungeon"
|
"github.com/tolelom/catacombs/dungeon"
|
||||||
"github.com/tolelom/catacombs/entity"
|
"github.com/tolelom/catacombs/entity"
|
||||||
)
|
)
|
||||||
@@ -70,14 +73,19 @@ func (s *GameSession) clearLog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GameSession struct {
|
type GameSession struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
state GameState
|
cfg *config.Config
|
||||||
started bool
|
state GameState
|
||||||
actions map[string]PlayerAction // playerName -> action
|
started bool
|
||||||
actionCh chan playerActionMsg
|
actions map[string]PlayerAction // playerName -> action
|
||||||
combatSignal chan struct{}
|
actionCh chan playerActionMsg
|
||||||
done chan struct{}
|
combatSignal chan struct{}
|
||||||
lastActivity map[string]time.Time // fingerprint -> last activity time
|
done chan struct{}
|
||||||
|
lastActivity map[string]time.Time // fingerprint -> last activity time
|
||||||
|
HardMode bool
|
||||||
|
ActiveMutation *Mutation
|
||||||
|
DailyMode bool
|
||||||
|
DailyDate string
|
||||||
}
|
}
|
||||||
|
|
||||||
type playerActionMsg struct {
|
type playerActionMsg struct {
|
||||||
@@ -85,8 +93,9 @@ type playerActionMsg struct {
|
|||||||
Action PlayerAction
|
Action PlayerAction
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGameSession() *GameSession {
|
func NewGameSession(cfg *config.Config) *GameSession {
|
||||||
return &GameSession{
|
return &GameSession{
|
||||||
|
cfg: cfg,
|
||||||
state: GameState{
|
state: GameState{
|
||||||
FloorNum: 1,
|
FloorNum: 1,
|
||||||
},
|
},
|
||||||
@@ -140,6 +149,7 @@ func (s *GameSession) combatLoop() {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if gameOver {
|
if gameOver {
|
||||||
|
slog.Info("game over", "floor", s.state.FloorNum, "victory", s.state.Victory)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +162,7 @@ func (s *GameSession) combatLoop() {
|
|||||||
if p.Fingerprint != "" && !p.IsOut() {
|
if p.Fingerprint != "" && !p.IsOut() {
|
||||||
if last, ok := s.lastActivity[p.Fingerprint]; ok {
|
if last, ok := s.lastActivity[p.Fingerprint]; ok {
|
||||||
if now.Sub(last) > 60*time.Second {
|
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))
|
s.addLog(fmt.Sprintf("%s removed (disconnected)", p.Name))
|
||||||
changed = true
|
changed = true
|
||||||
continue
|
continue
|
||||||
@@ -192,13 +203,21 @@ func (s *GameSession) signalCombat() {
|
|||||||
func (s *GameSession) AddPlayer(p *entity.Player) {
|
func (s *GameSession) AddPlayer(p *entity.Player) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
if p.Skills == nil {
|
||||||
|
p.Skills = &entity.PlayerSkills{BranchIndex: -1}
|
||||||
|
}
|
||||||
s.state.Players = append(s.state.Players, p)
|
s.state.Players = append(s.state.Players, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameSession) StartFloor() {
|
func (s *GameSession) StartFloor() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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.Phase = PhaseExploring
|
||||||
s.state.TurnNum = 0
|
s.state.TurnNum = 0
|
||||||
|
|
||||||
@@ -224,6 +243,10 @@ func (s *GameSession) GetState() GameState {
|
|||||||
copy(cp.Relics, p.Relics)
|
copy(cp.Relics, p.Relics)
|
||||||
cp.Effects = make([]entity.ActiveEffect, len(p.Effects))
|
cp.Effects = make([]entity.ActiveEffect, len(p.Effects))
|
||||||
copy(cp.Effects, p.Effects)
|
copy(cp.Effects, p.Effects)
|
||||||
|
if p.Skills != nil {
|
||||||
|
skillsCopy := *p.Skills
|
||||||
|
cp.Skills = &skillsCopy
|
||||||
|
}
|
||||||
players[i] = &cp
|
players[i] = &cp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +353,21 @@ func (s *GameSession) TouchActivity(fingerprint string) {
|
|||||||
s.lastActivity[fingerprint] = time.Now()
|
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
|
// BuyItem handles shop purchases
|
||||||
func (s *GameSession) BuyItem(playerID string, itemIdx int) bool {
|
func (s *GameSession) BuyItem(playerID string, itemIdx int) bool {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -340,7 +378,7 @@ func (s *GameSession) BuyItem(playerID string, itemIdx int) bool {
|
|||||||
item := s.state.ShopItems[itemIdx]
|
item := s.state.ShopItems[itemIdx]
|
||||||
for _, p := range s.state.Players {
|
for _, p := range s.state.Players {
|
||||||
if p.Fingerprint == playerID && p.Gold >= item.Price {
|
if p.Fingerprint == playerID && p.Gold >= item.Price {
|
||||||
if len(p.Inventory) >= 10 {
|
if len(p.Inventory) >= s.cfg.Game.InventoryLimit {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
p.Gold -= item.Price
|
p.Gold -= item.Price
|
||||||
@@ -355,7 +393,11 @@ func (s *GameSession) BuyItem(playerID string, itemIdx int) bool {
|
|||||||
func (s *GameSession) SendChat(playerName, message string) {
|
func (s *GameSession) SendChat(playerName, message string) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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
|
// LeaveShop exits the shop phase
|
||||||
|
|||||||
@@ -4,11 +4,18 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tolelom/catacombs/config"
|
||||||
"github.com/tolelom/catacombs/entity"
|
"github.com/tolelom/catacombs/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func testCfg(t *testing.T) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
cfg, _ := config.Load("")
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetStateNoRace(t *testing.T) {
|
func TestGetStateNoRace(t *testing.T) {
|
||||||
s := NewGameSession()
|
s := NewGameSession(testCfg(t))
|
||||||
p := entity.NewPlayer("Racer", entity.ClassWarrior)
|
p := entity.NewPlayer("Racer", entity.ClassWarrior)
|
||||||
p.Fingerprint = "test-fp"
|
p.Fingerprint = "test-fp"
|
||||||
s.AddPlayer(p)
|
s.AddPlayer(p)
|
||||||
@@ -40,7 +47,7 @@ func TestGetStateNoRace(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionTurnTimeout(t *testing.T) {
|
func TestSessionTurnTimeout(t *testing.T) {
|
||||||
s := NewGameSession()
|
s := NewGameSession(testCfg(t))
|
||||||
p := entity.NewPlayer("test", entity.ClassWarrior)
|
p := entity.NewPlayer("test", entity.ClassWarrior)
|
||||||
p.Fingerprint = "test-fp"
|
p.Fingerprint = "test-fp"
|
||||||
s.AddPlayer(p)
|
s.AddPlayer(p)
|
||||||
@@ -62,7 +69,7 @@ func TestSessionTurnTimeout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRevealNextLog(t *testing.T) {
|
func TestRevealNextLog(t *testing.T) {
|
||||||
s := NewGameSession()
|
s := NewGameSession(testCfg(t))
|
||||||
|
|
||||||
// No logs to reveal
|
// No logs to reveal
|
||||||
if s.RevealNextLog() {
|
if s.RevealNextLog() {
|
||||||
@@ -95,7 +102,7 @@ func TestRevealNextLog(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDeepCopyIndependence(t *testing.T) {
|
func TestDeepCopyIndependence(t *testing.T) {
|
||||||
s := NewGameSession()
|
s := NewGameSession(testCfg(t))
|
||||||
p := entity.NewPlayer("Test", entity.ClassWarrior)
|
p := entity.NewPlayer("Test", entity.ClassWarrior)
|
||||||
p.Fingerprint = "fp-test"
|
p.Fingerprint = "fp-test"
|
||||||
p.Inventory = append(p.Inventory, entity.Item{Name: "Sword", Type: entity.ItemWeapon, Bonus: 5})
|
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) {
|
func TestBuyItemInventoryFull(t *testing.T) {
|
||||||
s := NewGameSession()
|
s := NewGameSession(testCfg(t))
|
||||||
p := entity.NewPlayer("Buyer", entity.ClassWarrior)
|
p := entity.NewPlayer("Buyer", entity.ClassWarrior)
|
||||||
p.Fingerprint = "fp-buyer"
|
p.Fingerprint = "fp-buyer"
|
||||||
p.Gold = 1000
|
p.Gold = 1000
|
||||||
@@ -141,7 +148,7 @@ func TestBuyItemInventoryFull(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSendChat(t *testing.T) {
|
func TestSendChat(t *testing.T) {
|
||||||
s := NewGameSession()
|
s := NewGameSession(testCfg(t))
|
||||||
s.SendChat("Alice", "hello")
|
s.SendChat("Alice", "hello")
|
||||||
st := s.GetState()
|
st := s.GetState()
|
||||||
if len(st.CombatLog) != 1 || st.CombatLog[0] != "[Alice] hello" {
|
if len(st.CombatLog) != 1 || st.CombatLog[0] != "[Alice] hello" {
|
||||||
|
|||||||
127
game/turn.go
127
game/turn.go
@@ -10,8 +10,6 @@ import (
|
|||||||
"github.com/tolelom/catacombs/entity"
|
"github.com/tolelom/catacombs/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
const TurnTimeout = 5 * time.Second
|
|
||||||
|
|
||||||
func (s *GameSession) RunTurn() {
|
func (s *GameSession) RunTurn() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.state.TurnNum++
|
s.state.TurnNum++
|
||||||
@@ -28,9 +26,10 @@ func (s *GameSession) RunTurn() {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
// Collect actions with timeout
|
// 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.mu.Lock()
|
||||||
s.state.TurnDeadline = time.Now().Add(TurnTimeout)
|
s.state.TurnDeadline = time.Now().Add(turnTimeout)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
collected := 0
|
collected := 0
|
||||||
|
|
||||||
@@ -73,13 +72,30 @@ collecting:
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameSession) resolvePlayerActions() {
|
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 {
|
for _, p := range s.state.Players {
|
||||||
if !p.IsOut() {
|
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()
|
msgs := p.TickEffects()
|
||||||
for _, msg := range msgs {
|
for _, msg := range msgs {
|
||||||
s.addLog(msg)
|
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() {
|
if p.IsDead() {
|
||||||
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
|
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))
|
s.addLog(fmt.Sprintf("%s used Taunt! Enemies focus on %s for 2 turns", p.Name, p.Name))
|
||||||
case entity.ClassMage:
|
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{
|
intents = append(intents, combat.AttackIntent{
|
||||||
PlayerATK: p.EffectiveATK(),
|
PlayerATK: p.EffectiveATK(),
|
||||||
TargetIdx: -1,
|
TargetIdx: -1,
|
||||||
Multiplier: 0.8,
|
Multiplier: multiplier,
|
||||||
IsAoE: true,
|
IsAoE: true,
|
||||||
})
|
})
|
||||||
intentOwners = append(intentOwners, p.Name)
|
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
|
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))
|
s.addLog(fmt.Sprintf("%s healed %s for %d HP", p.Name, target.Name, target.HP-before))
|
||||||
case entity.ClassRogue:
|
case entity.ClassRogue:
|
||||||
currentRoom := s.state.Floor.Rooms[s.state.Floor.CurrentRoom]
|
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))
|
s.addLog(fmt.Sprintf("%s has no items to use!", p.Name))
|
||||||
}
|
}
|
||||||
case ActionFlee:
|
case ActionFlee:
|
||||||
if combat.AttemptFlee() {
|
if combat.AttemptFlee(s.cfg.Combat.FleeChance) {
|
||||||
s.addLog(fmt.Sprintf("%s fled from battle!", p.Name))
|
s.addLog(fmt.Sprintf("%s fled from battle!", p.Name))
|
||||||
s.state.FleeSucceeded = true
|
s.state.FleeSucceeded = true
|
||||||
if s.state.SoloMode {
|
if s.state.SoloMode {
|
||||||
@@ -214,8 +239,43 @@ func (s *GameSession) resolvePlayerActions() {
|
|||||||
return
|
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 {
|
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 {
|
for i, r := range results {
|
||||||
owner := intentOwners[i]
|
owner := intentOwners[i]
|
||||||
if r.IsAoE {
|
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
|
// Award gold only for monsters that JUST died this turn
|
||||||
for i, m := range s.state.Monsters {
|
for i, m := range s.state.Monsters {
|
||||||
if m.IsDead() && aliveBeforeTurn[i] {
|
if m.IsDead() && aliveBeforeTurn[i] {
|
||||||
@@ -287,15 +358,27 @@ func (s *GameSession) resolvePlayerActions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameSession) advanceFloor() {
|
func (s *GameSession) advanceFloor() {
|
||||||
if s.state.FloorNum >= 20 {
|
if s.state.FloorNum >= s.cfg.Game.MaxFloors {
|
||||||
s.state.Phase = PhaseResult
|
s.state.Phase = PhaseResult
|
||||||
s.state.Victory = true
|
s.state.Victory = true
|
||||||
s.state.GameOver = true
|
s.state.GameOver = true
|
||||||
s.addLog("You conquered the Catacombs!")
|
s.addLog("You conquered the Catacombs!")
|
||||||
return
|
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.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.Phase = PhaseExploring
|
||||||
s.state.CombatTurn = 0
|
s.state.CombatTurn = 0
|
||||||
s.addLog(fmt.Sprintf("Descending to B%d...", s.state.FloorNum))
|
s.addLog(fmt.Sprintf("Descending to B%d...", s.state.FloorNum))
|
||||||
@@ -347,8 +430,8 @@ func (s *GameSession) resolveMonsterActions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.IsBoss {
|
if m.IsBoss || m.IsMiniBoss {
|
||||||
// Boss special pattern
|
// Boss/mini-boss special pattern
|
||||||
switch m.Pattern {
|
switch m.Pattern {
|
||||||
case entity.PatternPoison:
|
case entity.PatternPoison:
|
||||||
for _, p := range s.state.Players {
|
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))
|
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:
|
case entity.PatternHeal:
|
||||||
healAmt := m.MaxHP / 10
|
healAmt := m.MaxHP / 10
|
||||||
m.HP += healAmt
|
m.HP += healAmt
|
||||||
@@ -380,6 +470,17 @@ func (s *GameSession) resolveMonsterActions() {
|
|||||||
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0)
|
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0)
|
||||||
p.TakeDamage(dmg)
|
p.TakeDamage(dmg)
|
||||||
s.addLog(fmt.Sprintf("%s attacks %s for %d dmg", m.Name, p.Name, 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() {
|
if p.IsDead() {
|
||||||
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
|
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
|
||||||
}
|
}
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -41,4 +41,5 @@ require (
|
|||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
1
go.sum
1
go.sum
@@ -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/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 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
114
main.go
114
main.go
@@ -1,9 +1,16 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tolelom/catacombs/config"
|
||||||
"github.com/tolelom/catacombs/game"
|
"github.com/tolelom/catacombs/game"
|
||||||
"github.com/tolelom/catacombs/server"
|
"github.com/tolelom/catacombs/server"
|
||||||
"github.com/tolelom/catacombs/store"
|
"github.com/tolelom/catacombs/store"
|
||||||
@@ -13,23 +20,114 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
os.MkdirAll("data", 0755)
|
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")
|
db, err := store.Open("data/catacombs.db")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to open database: %v", err)
|
log.Fatalf("Failed to open database: %v", err)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
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() {
|
go func() {
|
||||||
if err := web.Start(":8080", 2222); err != nil {
|
slog.Info("starting SSH server", "addr", sshAddr)
|
||||||
log.Printf("Web server error: %v", err)
|
sshErrCh <- sshServer.ListenAndServe()
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
log.Println("Catacombs server starting — SSH :2222, Web :8080")
|
slog.Info("server starting", "ssh_port", cfg.Server.SSHPort, "http_port", cfg.Server.HTTPPort)
|
||||||
if err := server.Start("0.0.0.0", 2222, lobby, db); err != nil {
|
|
||||||
log.Fatal(err)
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
|
|
||||||
"github.com/charmbracelet/ssh"
|
"github.com/charmbracelet/ssh"
|
||||||
"github.com/charmbracelet/wish"
|
"github.com/charmbracelet/wish"
|
||||||
@@ -14,15 +14,17 @@ import (
|
|||||||
"github.com/tolelom/catacombs/ui"
|
"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(
|
s, err := wish.NewServer(
|
||||||
wish.WithAddress(fmt.Sprintf("%s:%d", host, port)),
|
wish.WithAddress(addr),
|
||||||
wish.WithHostKeyPath(".ssh/catacombs_host_key"),
|
wish.WithHostKeyPath(".ssh/catacombs_host_key"),
|
||||||
wish.WithPublicKeyAuth(func(_ ssh.Context, _ ssh.PublicKey) bool {
|
wish.WithPublicKeyAuth(func(_ ssh.Context, _ ssh.PublicKey) bool {
|
||||||
return true // accept all keys
|
return true
|
||||||
}),
|
}),
|
||||||
wish.WithPasswordAuth(func(_ ssh.Context, _ string) bool {
|
wish.WithPasswordAuth(func(_ ssh.Context, _ string) bool {
|
||||||
return true // accept any password (game server, not secure shell)
|
return true
|
||||||
}),
|
}),
|
||||||
wish.WithMiddleware(
|
wish.WithMiddleware(
|
||||||
bubbletea.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
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 {
|
if s.PublicKey() != nil {
|
||||||
fingerprint = gossh.FingerprintSHA256(s.PublicKey())
|
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)
|
m := ui.NewModel(pty.Window.Width, pty.Window.Height, fingerprint, lobby, db)
|
||||||
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if err != nil {
|
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()
|
return s.ListenAndServe()
|
||||||
}
|
}
|
||||||
|
|||||||
40
store/backup.go
Normal file
40
store/backup.go
Normal 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
68
store/backup_test.go
Normal 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
88
store/codex.go
Normal 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
71
store/codex_test.go
Normal 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
81
store/daily.go
Normal 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
66
store/daily_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
12
store/db.go
12
store/db.go
@@ -39,6 +39,18 @@ func Open(path string) (*DB, error) {
|
|||||||
if _, err := tx.CreateBucketIfNotExists(bucketAchievements); err != nil {
|
if _, err := tx.CreateBucketIfNotExists(bucketAchievements); err != nil {
|
||||||
return err
|
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 nil
|
||||||
})
|
})
|
||||||
return &DB{db: db}, err
|
return &DB{db: db}, err
|
||||||
|
|||||||
48
store/stats.go
Normal file
48
store/stats.go
Normal 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
69
store/stats_test.go
Normal 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
115
store/titles.go
Normal 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
115
store/titles_test.go
Normal 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
47
store/unlocks.go
Normal 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
47
store/unlocks_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,35 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/tolelom/catacombs/store"
|
"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 {
|
func renderAchievements(playerName string, achievements []store.Achievement, width, height int) string {
|
||||||
title := styleHeader.Render("── Achievements ──")
|
title := styleHeader.Render("── Achievements ──")
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,64 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/tolelom/catacombs/entity"
|
"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 {
|
type classSelectState struct {
|
||||||
cursor int
|
cursor int
|
||||||
}
|
}
|
||||||
|
|||||||
169
ui/codex_view.go
Normal file
169
ui/codex_view.go
Normal 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
19
ui/context.go
Normal 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
|
||||||
|
}
|
||||||
340
ui/game_view.go
340
ui/game_view.go
@@ -5,15 +5,325 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/tolelom/catacombs/dungeon"
|
"github.com/tolelom/catacombs/dungeon"
|
||||||
"github.com/tolelom/catacombs/entity"
|
"github.com/tolelom/catacombs/entity"
|
||||||
"github.com/tolelom/catacombs/game"
|
"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)
|
mapView := renderMap(state.Floor)
|
||||||
hudView := renderHUD(state, targetCursor, moveCursor)
|
hudView := renderHUD(state, targetCursor, moveCursor, fingerprint)
|
||||||
logView := renderCombatLog(state.CombatLog)
|
logView := renderCombatLog(state.CombatLog)
|
||||||
|
|
||||||
if chatting {
|
if chatting {
|
||||||
@@ -49,7 +359,7 @@ func renderMap(floor *dungeon.Floor) string {
|
|||||||
return header + "\n" + dungeon.RenderFloor(floor, floor.CurrentRoom, true)
|
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
|
var sb strings.Builder
|
||||||
border := lipgloss.NewStyle().
|
border := lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder()).
|
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")
|
sb.WriteString("[Up/Down] Select [Enter] Move [Q] Quit")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,30 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"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 {
|
func renderHelp(width, height int) string {
|
||||||
title := styleHeader.Render("── Controls ──")
|
title := styleHeader.Render("── Controls ──")
|
||||||
|
|
||||||
|
|||||||
@@ -2,50 +2,111 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/tolelom/catacombs/store"
|
"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 ──")
|
title := styleHeader.Render("── Leaderboard ──")
|
||||||
|
|
||||||
// By Floor
|
// Tab header
|
||||||
var floorSection string
|
tabs := []string{"Floor", "Gold", "Daily"}
|
||||||
floorSection += styleCoop.Render(" Top by Floor") + "\n"
|
var tabLine string
|
||||||
for i, r := range byFloor {
|
for i, t := range tabs {
|
||||||
if i >= 5 {
|
if i == tab {
|
||||||
break
|
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 content string
|
||||||
var goldSection string
|
|
||||||
goldSection += styleCoop.Render("\n Top by Gold") + "\n"
|
switch tab {
|
||||||
for i, r := range byGold {
|
case 0: // By Floor
|
||||||
if i >= 5 {
|
content += styleCoop.Render(" Top by Floor") + "\n"
|
||||||
break
|
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)
|
case 1: // By Gold
|
||||||
cls := ""
|
content += styleCoop.Render(" Top by Gold") + "\n"
|
||||||
if r.Class != "" {
|
for i, r := range byGold {
|
||||||
cls = fmt.Sprintf(" [%s]", r.Class)
|
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,
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
178
ui/lobby_view.go
178
ui/lobby_view.go
@@ -3,21 +3,13 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"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 {
|
type roomInfo struct {
|
||||||
Code string
|
Code string
|
||||||
Name string
|
Name string
|
||||||
@@ -31,6 +23,161 @@ type playerInfo struct {
|
|||||||
Ready bool
|
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 {
|
func renderLobby(state lobbyState, width, height int) string {
|
||||||
headerStyle := lipgloss.NewStyle().
|
headerStyle := lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("205")).
|
Foreground(lipgloss.Color("205")).
|
||||||
@@ -41,7 +188,14 @@ func renderLobby(state lobbyState, width, height int) string {
|
|||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
|
|
||||||
header := headerStyle.Render(fmt.Sprintf("── Lobby ── %d online ──", state.online))
|
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 := ""
|
roomList := ""
|
||||||
for i, r := range state.rooms {
|
for i, r := range state.rooms {
|
||||||
|
|||||||
720
ui/model.go
720
ui/model.go
@@ -2,61 +2,17 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/tolelom/catacombs/entity"
|
|
||||||
"github.com/tolelom/catacombs/game"
|
"github.com/tolelom/catacombs/game"
|
||||||
"github.com/tolelom/catacombs/store"
|
"github.com/tolelom/catacombs/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type screen int
|
type tickMsg struct{}
|
||||||
|
|
||||||
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 Model struct {
|
type Model struct {
|
||||||
width int
|
currentScreen Screen
|
||||||
height int
|
ctx *Context
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *store.DB) Model {
|
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 {
|
if height == 0 {
|
||||||
height = 24
|
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{
|
return Model{
|
||||||
width: width,
|
currentScreen: initialScreen,
|
||||||
height: height,
|
ctx: ctx,
|
||||||
fingerprint: fingerprint,
|
|
||||||
screen: screenTitle,
|
|
||||||
lobby: lobby,
|
|
||||||
store: db,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,95 +52,30 @@ func (m Model) Init() tea.Cmd {
|
|||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.ctx.Width = msg.Width
|
||||||
m.height = msg.Height
|
m.ctx.Height = msg.Height
|
||||||
if m.width == 0 {
|
if m.ctx.Width == 0 {
|
||||||
m.width = 80
|
m.ctx.Width = 80
|
||||||
}
|
}
|
||||||
if m.height == 0 {
|
if m.ctx.Height == 0 {
|
||||||
m.height = 24
|
m.ctx.Height = 24
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case StateUpdateMsg:
|
|
||||||
m.gameState = msg.State
|
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch m.screen {
|
next, cmd := m.currentScreen.Update(msg, m.ctx)
|
||||||
case screenTitle:
|
m.currentScreen = next
|
||||||
return m.updateTitle(msg)
|
return m, cmd
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) View() string {
|
func (m Model) View() string {
|
||||||
if m.width < 80 || m.height < 24 {
|
if m.ctx.Width < 80 || m.ctx.Height < 24 {
|
||||||
return fmt.Sprintf("Terminal too small (%dx%d). Minimum: 80x24.", m.width, m.height)
|
return fmt.Sprintf("Terminal too small (%dx%d). Minimum: 80x24.", m.ctx.Width, m.ctx.Height)
|
||||||
}
|
}
|
||||||
switch m.screen {
|
return m.currentScreen.View(m.ctx)
|
||||||
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 ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Key helper functions used by all screens.
|
||||||
func isKey(key tea.KeyMsg, names ...string) bool {
|
func isKey(key tea.KeyMsg, names ...string) bool {
|
||||||
s := key.String()
|
s := key.String()
|
||||||
for _, n := range names {
|
for _, n := range names {
|
||||||
@@ -198,515 +102,63 @@ func isDown(key tea.KeyMsg) bool {
|
|||||||
return isKey(key, "down") || key.Type == tea.KeyDown
|
return isKey(key, "down") || key.Type == tea.KeyDown
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) {
|
// Keep these for backward compatibility with tests
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
// screen enum kept temporarily for test compatibility
|
||||||
if isEnter(key) {
|
type screen int
|
||||||
if m.fingerprint == "" {
|
|
||||||
m.fingerprint = fmt.Sprintf("anon-%d", time.Now().UnixNano())
|
const (
|
||||||
}
|
screenTitle screen = iota
|
||||||
if m.store != nil {
|
screenLobby
|
||||||
name, err := m.store.GetProfile(m.fingerprint)
|
screenClassSelect
|
||||||
if err != nil {
|
screenGame
|
||||||
// First time player — show nickname input
|
screenShop
|
||||||
m.screen = screenNickname
|
screenResult
|
||||||
m.nicknameInput = ""
|
screenHelp
|
||||||
return m, nil
|
screenStats
|
||||||
}
|
screenAchievements
|
||||||
m.playerName = name
|
screenLeaderboard
|
||||||
} else {
|
screenNickname
|
||||||
m.playerName = "Adventurer"
|
)
|
||||||
}
|
|
||||||
if m.lobby != nil {
|
// screenType returns the screen enum for the current screen (for test compatibility).
|
||||||
m.lobby.PlayerOnline(m.fingerprint, m.playerName)
|
func (m Model) screenType() screen {
|
||||||
}
|
switch m.currentScreen.(type) {
|
||||||
// Check for active session to reconnect
|
case *TitleScreen:
|
||||||
if m.lobby != nil {
|
return screenTitle
|
||||||
code, session := m.lobby.GetActiveSession(m.fingerprint)
|
case *LobbyScreen:
|
||||||
if session != nil {
|
return screenLobby
|
||||||
m.roomCode = code
|
case *ClassSelectScreen:
|
||||||
m.session = session
|
return screenClassSelect
|
||||||
m.gameState = m.session.GetState()
|
case *GameScreen:
|
||||||
m.screen = screenGame
|
return screenGame
|
||||||
m.session.TouchActivity(m.fingerprint)
|
case *ShopScreen:
|
||||||
m.session.SendChat("System", m.playerName+" reconnected!")
|
return screenShop
|
||||||
return m, m.pollState()
|
case *ResultScreen:
|
||||||
}
|
return screenResult
|
||||||
}
|
case *HelpScreen:
|
||||||
m.screen = screenLobby
|
return screenHelp
|
||||||
m = m.withRefreshedLobby()
|
case *StatsScreen:
|
||||||
} else if isKey(key, "h") {
|
return screenStats
|
||||||
m.screen = screenHelp
|
case *AchievementsScreen:
|
||||||
} else if isKey(key, "s") {
|
return screenAchievements
|
||||||
m.screen = screenStats
|
case *LeaderboardScreen:
|
||||||
} else if isKey(key, "a") {
|
return screenLeaderboard
|
||||||
m.screen = screenAchievements
|
case *NicknameScreen:
|
||||||
} else if isKey(key, "l") {
|
return screenNickname
|
||||||
m.screen = screenLeaderboard
|
|
||||||
} else if isQuit(key) {
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return m, nil
|
return screenTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) updateNickname(msg tea.Msg) (tea.Model, tea.Cmd) {
|
// Convenience accessors for test compatibility
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
func (m Model) playerName() string {
|
||||||
if isEnter(key) && len(m.nicknameInput) > 0 {
|
return m.ctx.PlayerName
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) updateStats(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) roomCode() string {
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
return m.ctx.RoomCode
|
||||||
if isKey(key, "s") || isEnter(key) || isQuit(key) {
|
|
||||||
m.screen = screenTitle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) updateAchievements(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) session() *game.GameSession {
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
return m.ctx.Session
|
||||||
if isKey(key, "a") || isEnter(key) || isQuit(key) {
|
|
||||||
m.screen = screenTitle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/tolelom/catacombs/config"
|
||||||
"github.com/tolelom/catacombs/game"
|
"github.com/tolelom/catacombs/game"
|
||||||
"github.com/tolelom/catacombs/store"
|
"github.com/tolelom/catacombs/store"
|
||||||
"os"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func testDB(t *testing.T) *store.DB {
|
func testDB(t *testing.T) *store.DB {
|
||||||
@@ -18,22 +19,22 @@ func testDB(t *testing.T) *store.DB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTitleToLobby(t *testing.T) {
|
func TestTitleToLobby(t *testing.T) {
|
||||||
lobby := game.NewLobby()
|
lobby := game.NewLobby(func() *config.Config { c, _ := config.Load(""); return c }())
|
||||||
db := testDB(t)
|
db := testDB(t)
|
||||||
defer func() { db.Close(); os.Remove("test_ui.db") }()
|
defer func() { db.Close(); os.Remove("test_ui.db") }()
|
||||||
|
|
||||||
m := NewModel(80, 24, "testfp", lobby, db)
|
m := NewModel(80, 24, "testfp", lobby, db)
|
||||||
|
|
||||||
if m.screen != screenTitle {
|
if m.screenType() != screenTitle {
|
||||||
t.Fatalf("initial screen: got %d, want screenTitle(0)", m.screen)
|
t.Fatalf("initial screen: got %d, want screenTitle(0)", m.screenType())
|
||||||
}
|
}
|
||||||
|
|
||||||
// First-time player: Enter goes to nickname screen
|
// First-time player: Enter goes to nickname screen
|
||||||
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
m2 := result.(Model)
|
m2 := result.(Model)
|
||||||
|
|
||||||
if m2.screen != screenNickname {
|
if m2.screenType() != screenNickname {
|
||||||
t.Errorf("after Enter (first time): screen=%d, want screenNickname(%d)", m2.screen, screenNickname)
|
t.Errorf("after Enter (first time): screen=%d, want screenNickname(%d)", m2.screenType(), screenNickname)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type a name
|
// Type a name
|
||||||
@@ -46,16 +47,16 @@ func TestTitleToLobby(t *testing.T) {
|
|||||||
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
m3 := result.(Model)
|
m3 := result.(Model)
|
||||||
|
|
||||||
if m3.screen != screenLobby {
|
if m3.screenType() != screenLobby {
|
||||||
t.Errorf("after nickname Enter: screen=%d, want screenLobby(1)", m3.screen)
|
t.Errorf("after nickname Enter: screen=%d, want screenLobby(1)", m3.screenType())
|
||||||
}
|
}
|
||||||
if m3.playerName == "" {
|
if m3.playerName() == "" {
|
||||||
t.Error("playerName should be set")
|
t.Error("playerName should be set")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLobbyCreateRoom(t *testing.T) {
|
func TestLobbyCreateRoom(t *testing.T) {
|
||||||
lobby := game.NewLobby()
|
lobby := game.NewLobby(func() *config.Config { c, _ := config.Load(""); return c }())
|
||||||
db := testDB(t)
|
db := testDB(t)
|
||||||
defer func() { db.Close(); os.Remove("test_ui.db") }()
|
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'}})
|
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
|
||||||
m3 := result.(Model)
|
m3 := result.(Model)
|
||||||
|
|
||||||
if m3.screen != screenClassSelect {
|
if m3.screenType() != screenClassSelect {
|
||||||
t.Errorf("after 'c': screen=%d, want screenClassSelect(2)", m3.screen)
|
t.Errorf("after 'c': screen=%d, want screenClassSelect(2)", m3.screenType())
|
||||||
}
|
}
|
||||||
if m3.roomCode == "" {
|
if m3.roomCode() == "" {
|
||||||
t.Error("roomCode should be set")
|
t.Error("roomCode should be set")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClassSelectToGame(t *testing.T) {
|
func TestClassSelectToGame(t *testing.T) {
|
||||||
lobby := game.NewLobby()
|
lobby := game.NewLobby(func() *config.Config { c, _ := config.Load(""); return c }())
|
||||||
db := testDB(t)
|
db := testDB(t)
|
||||||
defer func() { db.Close(); os.Remove("test_ui.db") }()
|
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'}})
|
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
|
||||||
m3 := result.(Model)
|
m3 := result.(Model)
|
||||||
|
|
||||||
if m3.screen != screenClassSelect {
|
if m3.screenType() != screenClassSelect {
|
||||||
t.Fatalf("should be at class select, got %d", m3.screen)
|
t.Fatalf("should be at class select, got %d", m3.screenType())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Press Enter to select Warrior (default cursor=0)
|
// Press Enter to select Warrior (default cursor=0)
|
||||||
result, _ = m3.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
result, _ = m3.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
m4 := result.(Model)
|
m4 := result.(Model)
|
||||||
|
|
||||||
if m4.screen != screenGame {
|
if m4.screenType() != screenGame {
|
||||||
t.Errorf("after class select Enter: screen=%d, want screenGame(3)", m4.screen)
|
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")
|
t.Error("session should be set")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,69 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"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 {
|
func renderNickname(input string, width, height int) string {
|
||||||
title := styleHeader.Render("── Enter Your Name ──")
|
title := styleHeader.Render("── Enter Your Name ──")
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,53 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/tolelom/catacombs/game"
|
"github.com/tolelom/catacombs/game"
|
||||||
"github.com/tolelom/catacombs/store"
|
"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 {
|
func renderResult(state game.GameState, rankings []store.RunRecord) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
|
|||||||
11
ui/screen.go
Normal file
11
ui/screen.go
Normal 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
|
||||||
|
}
|
||||||
@@ -3,11 +3,51 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/tolelom/catacombs/entity"
|
"github.com/tolelom/catacombs/entity"
|
||||||
"github.com/tolelom/catacombs/game"
|
"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 {
|
func itemTypeLabel(item entity.Item) string {
|
||||||
switch item.Type {
|
switch item.Type {
|
||||||
case entity.ItemWeapon:
|
case entity.ItemWeapon:
|
||||||
|
|||||||
@@ -3,10 +3,35 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/tolelom/catacombs/store"
|
"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 {
|
func renderStats(playerName string, stats store.PlayerStats, width, height int) string {
|
||||||
title := styleHeader.Render("── Player Statistics ──")
|
title := styleHeader.Render("── Player Statistics ──")
|
||||||
|
|
||||||
|
|||||||
68
ui/title.go
68
ui/title.go
@@ -1,11 +1,77 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"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{
|
var titleLines = []string{
|
||||||
` ██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗`,
|
` ██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗`,
|
||||||
`██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝`,
|
`██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝`,
|
||||||
@@ -44,7 +110,7 @@ func renderTitle(width, height int) string {
|
|||||||
menu := lipgloss.NewStyle().
|
menu := lipgloss.NewStyle().
|
||||||
Foreground(colorWhite).
|
Foreground(colorWhite).
|
||||||
Bold(true).
|
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,
|
content := lipgloss.JoinVertical(lipgloss.Center,
|
||||||
logo,
|
logo,
|
||||||
|
|||||||
55
web/admin.go
Normal file
55
web/admin.go
Normal 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
64
web/admin_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,15 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
|
"github.com/tolelom/catacombs/game"
|
||||||
|
"github.com/tolelom/catacombs/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed static
|
//go:embed static
|
||||||
@@ -26,8 +29,8 @@ type resizeMsg struct {
|
|||||||
Rows int `json:"rows"`
|
Rows int `json:"rows"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start launches the HTTP server for the web terminal.
|
// Start launches the HTTP server for the web terminal and returns the server handle.
|
||||||
func Start(addr string, sshPort int) error {
|
func Start(addr string, sshPort int, lobby *game.Lobby, db *store.DB, startTime time.Time) *http.Server {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Serve static files from embedded FS
|
// Serve static files from embedded FS
|
||||||
@@ -38,14 +41,25 @@ func Start(addr string, sshPort int) error {
|
|||||||
handleWS(w, r, sshPort)
|
handleWS(w, r, sshPort)
|
||||||
})
|
})
|
||||||
|
|
||||||
log.Printf("Starting web terminal on %s", addr)
|
// Admin endpoint
|
||||||
return http.ListenAndServe(addr, mux)
|
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) {
|
func handleWS(w http.ResponseWriter, r *http.Request, sshPort int) {
|
||||||
ws, err := upgrader.Upgrade(w, r, nil)
|
ws, err := upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("WebSocket upgrade error: %v", err)
|
slog.Error("WebSocket upgrade error", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer ws.Close()
|
defer ws.Close()
|
||||||
@@ -62,7 +76,7 @@ func handleWS(w http.ResponseWriter, r *http.Request, sshPort int) {
|
|||||||
sshAddr := fmt.Sprintf("localhost:%d", sshPort)
|
sshAddr := fmt.Sprintf("localhost:%d", sshPort)
|
||||||
client, err := ssh.Dial("tcp", sshAddr, sshConfig)
|
client, err := ssh.Dial("tcp", sshAddr, sshConfig)
|
||||||
if err != nil {
|
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)))
|
ws.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("Failed to connect to game server: %v\r\n", err)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -70,7 +84,7 @@ func handleWS(w http.ResponseWriter, r *http.Request, sshPort int) {
|
|||||||
|
|
||||||
session, err := client.NewSession()
|
session, err := client.NewSession()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("SSH session error: %v", err)
|
slog.Error("SSH session error", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer session.Close()
|
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_ISPEED: 14400,
|
||||||
ssh.TTY_OP_OSPEED: 14400,
|
ssh.TTY_OP_OSPEED: 14400,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("PTY request error: %v", err)
|
slog.Error("PTY request error", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stdin, err := session.StdinPipe()
|
stdin, err := session.StdinPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("stdin pipe error: %v", err)
|
slog.Error("stdin pipe error", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout, err := session.StdoutPipe()
|
stdout, err := session.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("stdout pipe error: %v", err)
|
slog.Error("stdout pipe error", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := session.Shell(); err != nil {
|
if err := session.Shell(); err != nil {
|
||||||
log.Printf("shell error: %v", err)
|
slog.Error("shell error", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user