Compare commits
50 Commits
604ca00e8b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 523f1bc90c | |||
| d44bba5364 | |||
| 087ce31164 | |||
| f28160d4da | |||
| 206ac522c5 | |||
| 24d9982b15 | |||
| f6419b7984 | |||
| 3068fc5550 | |||
| 1efb78149c | |||
| 08d97b3f89 | |||
| 1563091de1 | |||
| 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 |
109
CLAUDE.md
Normal file
109
CLAUDE.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# 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 (local):
|
||||
```bash
|
||||
docker build -t catacombs .
|
||||
docker-compose up # SSH on :2222, HTTP on :8080
|
||||
```
|
||||
|
||||
## Deployment (tolelom.xyz)
|
||||
|
||||
The game runs as a Docker container on the Mac Mini server, behind Caddy reverse proxy.
|
||||
|
||||
**Server structure:**
|
||||
```
|
||||
~/server/
|
||||
├── docker-compose.yml # All services (caddy, gitea, catacombs, etc.)
|
||||
├── caddy/Caddyfile # Reverse proxy config
|
||||
└── apps/catacombs/ # Git clone of this repo
|
||||
```
|
||||
|
||||
**First-time setup:**
|
||||
```bash
|
||||
cd ~/server
|
||||
git clone https://git.tolelom.xyz/tolelom/Catacombs.git apps/catacombs
|
||||
```
|
||||
|
||||
Add to `docker-compose.yml` services:
|
||||
```yaml
|
||||
catacombs:
|
||||
build: ./apps/catacombs
|
||||
restart: always
|
||||
logging: *default-logging
|
||||
volumes:
|
||||
- catacombs_data:/app/data
|
||||
networks:
|
||||
- web
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64m
|
||||
pids: 200
|
||||
```
|
||||
|
||||
Add `catacombs_data:` to the `volumes:` section.
|
||||
|
||||
Add to `caddy/Caddyfile`:
|
||||
```
|
||||
catacombs.tolelom.xyz {
|
||||
encode gzip zstd
|
||||
reverse_proxy catacombs:8080
|
||||
}
|
||||
```
|
||||
|
||||
**Deploy / Update:**
|
||||
```bash
|
||||
cd ~/server/apps/catacombs && git pull
|
||||
cd ~/server && docker compose up -d --build catacombs
|
||||
docker restart server-caddy-1 # only needed if Caddyfile changed
|
||||
```
|
||||
|
||||
**Access:** https://catacombs.tolelom.xyz/static/
|
||||
|
||||
## 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
|
||||
}
|
||||
|
||||
func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster) []AttackResult {
|
||||
func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster, coopBonus float64) []AttackResult {
|
||||
targetCount := make(map[int]int)
|
||||
targetOrder := make(map[int]int)
|
||||
for i, intent := range intents {
|
||||
@@ -57,13 +57,14 @@ func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster) []Attack
|
||||
results[i] = AttackResult{TargetIdx: -1, Damage: totalDmg, IsAoE: true}
|
||||
} else {
|
||||
if intent.TargetIdx < 0 || intent.TargetIdx >= len(monsters) {
|
||||
results[i] = AttackResult{TargetIdx: -1} // mark as invalid
|
||||
continue
|
||||
}
|
||||
m := monsters[intent.TargetIdx]
|
||||
dmg := CalcDamage(intent.PlayerATK, m.DEF, intent.Multiplier)
|
||||
coopApplied := false
|
||||
if targetCount[intent.TargetIdx] >= 2 && targetOrder[intent.TargetIdx] != i {
|
||||
dmg = int(math.Round(float64(dmg) * 1.10))
|
||||
dmg = int(math.Round(float64(dmg) * (1.0 + coopBonus)))
|
||||
coopApplied = true
|
||||
}
|
||||
m.TakeDamage(dmg)
|
||||
@@ -77,13 +78,13 @@ func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster) []Attack
|
||||
return results
|
||||
}
|
||||
|
||||
func AttemptFlee() bool {
|
||||
return rand.Float64() < 0.5
|
||||
func AttemptFlee(fleeChance float64) bool {
|
||||
return rand.Float64() < fleeChance
|
||||
}
|
||||
|
||||
func MonsterAI(m *entity.Monster, players []*entity.Player, turnNumber int) (targetIdx int, isAoE bool) {
|
||||
if m.IsBoss && turnNumber > 0 && turnNumber%3 == 0 {
|
||||
return -1, true // AoE every 3 turns for all bosses
|
||||
if (m.IsBoss || m.IsMiniBoss) && turnNumber > 0 && turnNumber%3 == 0 {
|
||||
return -1, true // AoE every 3 turns for all bosses and mini-bosses
|
||||
}
|
||||
if m.TauntTarget {
|
||||
for i, p := range players {
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestCoopBonus(t *testing.T) {
|
||||
{PlayerATK: 12, TargetIdx: 0, Multiplier: 1.0, IsAoE: false},
|
||||
{PlayerATK: 15, TargetIdx: 0, Multiplier: 1.0, IsAoE: false},
|
||||
}
|
||||
results := ResolveAttacks(attackers, []*entity.Monster{entity.NewMonster(entity.MonsterSlime, 1)})
|
||||
results := ResolveAttacks(attackers, []*entity.Monster{entity.NewMonster(entity.MonsterSlime, 1, 1.15)}, 0.10)
|
||||
if !results[1].CoopApplied {
|
||||
t.Error("Second attacker should get co-op bonus")
|
||||
}
|
||||
@@ -37,10 +37,10 @@ func TestAoENoCoopBonus(t *testing.T) {
|
||||
{PlayerATK: 20, TargetIdx: -1, Multiplier: 0.8, IsAoE: true},
|
||||
}
|
||||
monsters := []*entity.Monster{
|
||||
entity.NewMonster(entity.MonsterSlime, 1),
|
||||
entity.NewMonster(entity.MonsterSlime, 1),
|
||||
entity.NewMonster(entity.MonsterSlime, 1, 1.15),
|
||||
entity.NewMonster(entity.MonsterSlime, 1, 1.15),
|
||||
}
|
||||
results := ResolveAttacks(attackers, monsters)
|
||||
results := ResolveAttacks(attackers, monsters, 0.10)
|
||||
if results[0].CoopApplied {
|
||||
t.Error("AoE should not trigger co-op bonus")
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func TestMonsterAITauntDeadWarrior(t *testing.T) {
|
||||
func TestFleeChance(t *testing.T) {
|
||||
successes := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
if AttemptFlee() {
|
||||
if AttemptFlee(0.50) {
|
||||
successes++
|
||||
}
|
||||
}
|
||||
|
||||
93
combat/combo.go
Normal file
93
combat/combo.go
Normal file
@@ -0,0 +1,93 @@
|
||||
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! 동결된 적이 산산조각!"},
|
||||
},
|
||||
{
|
||||
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! 축복받은 공격이 파티를 치유!"},
|
||||
},
|
||||
{
|
||||
Name: "Shadow Strike",
|
||||
Required: []ComboAction{
|
||||
{Class: entity.ClassRogue, ActionType: "skill"},
|
||||
{Class: entity.ClassMage, ActionType: "attack"},
|
||||
},
|
||||
Effect: ComboEffect{DamageMultiplier: 1.4, Message: "🗡️ SHADOW STRIKE! 마법의 그림자가 공격을 증폭!"},
|
||||
},
|
||||
{
|
||||
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! 합동 공격으로 압도!"},
|
||||
},
|
||||
{
|
||||
Name: "Restoration",
|
||||
Required: []ComboAction{
|
||||
{Class: entity.ClassHealer, ActionType: "skill"},
|
||||
{Class: entity.ClassRogue, ActionType: "item"},
|
||||
},
|
||||
Effect: ComboEffect{HealAll: 20, Message: "💚 RESTORATION! 합동 치유가 폭발적으로 발동!"},
|
||||
},
|
||||
}
|
||||
|
||||
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 {
|
||||
used := make(map[string]bool)
|
||||
for _, req := range required {
|
||||
found := false
|
||||
for id, act := range actions {
|
||||
if !used[id] && act.Class == req.Class && act.ActionType == req.ActionType {
|
||||
used[id] = true
|
||||
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: 10, 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 != 10 {
|
||||
t.Errorf("expected turn timeout 10, 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 유지)
|
||||
@@ -1,6 +1,9 @@
|
||||
package dungeon
|
||||
|
||||
import "math/rand"
|
||||
import (
|
||||
"math/rand"
|
||||
"sort"
|
||||
)
|
||||
|
||||
const (
|
||||
MapWidth = 60
|
||||
@@ -19,7 +22,7 @@ type bspNode struct {
|
||||
roomIdx int
|
||||
}
|
||||
|
||||
func GenerateFloor(floorNum int) *Floor {
|
||||
func GenerateFloor(floorNum int, rng *rand.Rand) *Floor {
|
||||
// Create tile map filled with walls
|
||||
tiles := make([][]Tile, MapHeight)
|
||||
for y := 0; y < MapHeight; y++ {
|
||||
@@ -29,24 +32,31 @@ func GenerateFloor(floorNum int) *Floor {
|
||||
|
||||
// BSP tree
|
||||
root := &bspNode{x: 0, y: 0, w: MapWidth, h: MapHeight}
|
||||
splitBSP(root, 0)
|
||||
splitBSP(root, 0, rng)
|
||||
|
||||
// Collect leaf nodes
|
||||
var leaves []*bspNode
|
||||
collectLeaves(root, &leaves)
|
||||
|
||||
// Shuffle leaves so room assignment is varied
|
||||
rand.Shuffle(len(leaves), func(i, j int) {
|
||||
leaves[i], leaves[j] = leaves[j], leaves[i]
|
||||
})
|
||||
|
||||
// We want 5-8 rooms. If we have more leaves, merge some; if fewer, accept it.
|
||||
// Ensure at least 5 leaves by re-generating if needed (BSP should produce enough).
|
||||
// Cap at 8 rooms.
|
||||
targetRooms := 5 + rand.Intn(4) // 5..8
|
||||
// We want 5-8 rooms. If we have more leaves, shuffle and trim.
|
||||
targetRooms := 5 + rng.Intn(4) // 5..8
|
||||
if len(leaves) > targetRooms {
|
||||
rng.Shuffle(len(leaves), func(i, j int) {
|
||||
leaves[i], leaves[j] = leaves[j], leaves[i]
|
||||
})
|
||||
leaves = leaves[:targetRooms]
|
||||
}
|
||||
|
||||
// Sort leaves by physical position (left-to-right, top-to-bottom)
|
||||
// so room indices match their map positions
|
||||
sort.Slice(leaves, func(i, j int) bool {
|
||||
ci := leaves[i].y + leaves[i].h/2
|
||||
cj := leaves[j].y + leaves[j].h/2
|
||||
if ci != cj {
|
||||
return ci < cj
|
||||
}
|
||||
return leaves[i].x+leaves[i].w/2 < leaves[j].x+leaves[j].w/2
|
||||
})
|
||||
// If we somehow have fewer than 5, that's fine — the BSP with 60x20 and min 12x8 gives ~5-8 naturally.
|
||||
|
||||
// Place rooms inside each leaf
|
||||
@@ -64,21 +74,21 @@ func GenerateFloor(floorNum int) *Floor {
|
||||
|
||||
rw := MinRoomW
|
||||
if maxW > MinRoomW {
|
||||
rw = MinRoomW + rand.Intn(maxW-MinRoomW+1)
|
||||
rw = MinRoomW + rng.Intn(maxW-MinRoomW+1)
|
||||
}
|
||||
rh := MinRoomH
|
||||
if maxH > MinRoomH {
|
||||
rh = MinRoomH + rand.Intn(maxH-MinRoomH+1)
|
||||
rh = MinRoomH + rng.Intn(maxH-MinRoomH+1)
|
||||
}
|
||||
|
||||
// Position room within the leaf
|
||||
rx := leaf.x + RoomPad
|
||||
if leaf.w-2*RoomPad > rw {
|
||||
rx += rand.Intn(leaf.w - 2*RoomPad - rw + 1)
|
||||
rx += rng.Intn(leaf.w - 2*RoomPad - rw + 1)
|
||||
}
|
||||
ry := leaf.y + RoomPad
|
||||
if leaf.h-2*RoomPad > rh {
|
||||
ry += rand.Intn(leaf.h - 2*RoomPad - rh + 1)
|
||||
ry += rng.Intn(leaf.h - 2*RoomPad - rh + 1)
|
||||
}
|
||||
|
||||
// Clamp to map bounds
|
||||
@@ -95,7 +105,7 @@ func GenerateFloor(floorNum int) *Floor {
|
||||
ry = 1
|
||||
}
|
||||
|
||||
rt := RandomRoomType()
|
||||
rt := RandomRoomType(rng)
|
||||
rooms[i] = &Room{
|
||||
Type: rt,
|
||||
X: rx,
|
||||
@@ -108,9 +118,20 @@ func GenerateFloor(floorNum int) *Floor {
|
||||
leaf.roomIdx = i
|
||||
}
|
||||
|
||||
// First room is always empty (safe starting area)
|
||||
rooms[0].Type = RoomEmpty
|
||||
|
||||
// Last room is boss
|
||||
rooms[len(rooms)-1].Type = RoomBoss
|
||||
|
||||
// On floors 4, 9, 14, 19: assign one room as mini-boss
|
||||
if floorNum == 4 || floorNum == 9 || floorNum == 14 || floorNum == 19 {
|
||||
// Pick the second room (index 1), or any non-first non-last room
|
||||
if len(rooms) > 2 {
|
||||
rooms[1].Type = RoomMiniBoss
|
||||
}
|
||||
}
|
||||
|
||||
// Carve rooms into tile map
|
||||
for _, room := range rooms {
|
||||
for dy := 0; dy < room.H; dy++ {
|
||||
@@ -132,10 +153,10 @@ func GenerateFloor(floorNum int) *Floor {
|
||||
}
|
||||
|
||||
// Add 1-2 extra connections
|
||||
extras := 1 + rand.Intn(2)
|
||||
extras := 1 + rng.Intn(2)
|
||||
for e := 0; e < extras; e++ {
|
||||
a := rand.Intn(len(rooms))
|
||||
b := rand.Intn(len(rooms))
|
||||
a := rng.Intn(len(rooms))
|
||||
b := rng.Intn(len(rooms))
|
||||
if a != b && !hasNeighbor(rooms[a], b) {
|
||||
rooms[a].Neighbors = append(rooms[a].Neighbors, b)
|
||||
rooms[b].Neighbors = append(rooms[b].Neighbors, a)
|
||||
@@ -153,7 +174,7 @@ func GenerateFloor(floorNum int) *Floor {
|
||||
}
|
||||
}
|
||||
|
||||
func splitBSP(node *bspNode, depth int) {
|
||||
func splitBSP(node *bspNode, depth int, rng *rand.Rand) {
|
||||
// Stop conditions
|
||||
if depth > 4 {
|
||||
return
|
||||
@@ -163,12 +184,12 @@ func splitBSP(node *bspNode, depth int) {
|
||||
}
|
||||
|
||||
// Random chance to stop splitting (more likely at deeper levels)
|
||||
if depth > 2 && rand.Float64() < 0.3 {
|
||||
if depth > 2 && rng.Float64() < 0.3 {
|
||||
return
|
||||
}
|
||||
|
||||
// Decide split direction
|
||||
horizontal := rand.Float64() < 0.5
|
||||
horizontal := rng.Float64() < 0.5
|
||||
if node.w < MinLeafW*2 {
|
||||
horizontal = true
|
||||
}
|
||||
@@ -180,20 +201,20 @@ func splitBSP(node *bspNode, depth int) {
|
||||
if node.h < MinLeafH*2 {
|
||||
return
|
||||
}
|
||||
split := MinLeafH + rand.Intn(node.h-MinLeafH*2+1)
|
||||
split := MinLeafH + rng.Intn(node.h-MinLeafH*2+1)
|
||||
node.left = &bspNode{x: node.x, y: node.y, w: node.w, h: split}
|
||||
node.right = &bspNode{x: node.x, y: node.y + split, w: node.w, h: node.h - split}
|
||||
} else {
|
||||
if node.w < MinLeafW*2 {
|
||||
return
|
||||
}
|
||||
split := MinLeafW + rand.Intn(node.w-MinLeafW*2+1)
|
||||
split := MinLeafW + rng.Intn(node.w-MinLeafW*2+1)
|
||||
node.left = &bspNode{x: node.x, y: node.y, w: split, h: node.h}
|
||||
node.right = &bspNode{x: node.x + split, y: node.y, w: node.w - split, h: node.h}
|
||||
}
|
||||
|
||||
splitBSP(node.left, depth+1)
|
||||
splitBSP(node.right, depth+1)
|
||||
splitBSP(node.left, depth+1, rng)
|
||||
splitBSP(node.right, depth+1, rng)
|
||||
}
|
||||
|
||||
func collectLeaves(node *bspNode, leaves *[]*bspNode) {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
package dungeon
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestRng() *rand.Rand {
|
||||
return rand.New(rand.NewSource(rand.Int63()))
|
||||
}
|
||||
|
||||
func TestGenerateFloor(t *testing.T) {
|
||||
floor := GenerateFloor(1)
|
||||
floor := GenerateFloor(1, newTestRng())
|
||||
if len(floor.Rooms) < 5 || len(floor.Rooms) > 8 {
|
||||
t.Errorf("Room count: got %d, want 5~8", len(floor.Rooms))
|
||||
}
|
||||
@@ -32,17 +39,61 @@ func TestGenerateFloor(t *testing.T) {
|
||||
func TestRoomTypeProbability(t *testing.T) {
|
||||
counts := make(map[RoomType]int)
|
||||
n := 10000
|
||||
rng := rand.New(rand.NewSource(12345))
|
||||
for i := 0; i < n; i++ {
|
||||
counts[RandomRoomType()]++
|
||||
counts[RandomRoomType(rng)]++
|
||||
}
|
||||
combatPct := float64(counts[RoomCombat]) / float64(n) * 100
|
||||
if combatPct < 40 || combatPct > 50 {
|
||||
t.Errorf("Combat room probability: got %.1f%%, want ~45%%", combatPct)
|
||||
t.Errorf("Combat room probability: got %.1f%%, want ~45%% (range 5-50)", combatPct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRoomInRandomType(t *testing.T) {
|
||||
counts := make(map[RoomType]int)
|
||||
n := 10000
|
||||
rng := rand.New(rand.NewSource(12345))
|
||||
for i := 0; i < n; i++ {
|
||||
counts[RandomRoomType(rng)]++
|
||||
}
|
||||
secretPct := float64(counts[RoomSecret]) / float64(n) * 100
|
||||
if secretPct < 2 || secretPct > 8 {
|
||||
t.Errorf("Secret room probability: got %.1f%%, want ~5%%", secretPct)
|
||||
}
|
||||
// Verify RoomMiniBoss is never returned by RandomRoomType
|
||||
if counts[RoomMiniBoss] > 0 {
|
||||
t.Errorf("MiniBoss rooms should not be generated randomly, got %d", counts[RoomMiniBoss])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniBossRoomPlacement(t *testing.T) {
|
||||
for _, floorNum := range []int{4, 9, 14, 19} {
|
||||
floor := GenerateFloor(floorNum, newTestRng())
|
||||
found := false
|
||||
for _, r := range floor.Rooms {
|
||||
if r.Type == RoomMiniBoss {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Floor %d should have a mini-boss room", floorNum)
|
||||
}
|
||||
}
|
||||
// Non-miniboss floors should not have mini-boss rooms
|
||||
for _, floorNum := range []int{1, 3, 5, 10} {
|
||||
floor := GenerateFloor(floorNum, newTestRng())
|
||||
for _, r := range floor.Rooms {
|
||||
if r.Type == RoomMiniBoss {
|
||||
t.Errorf("Floor %d should not have a mini-boss room", floorNum)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFloorHasTileMap(t *testing.T) {
|
||||
floor := GenerateFloor(1)
|
||||
floor := GenerateFloor(1, newTestRng())
|
||||
if floor.Tiles == nil {
|
||||
t.Fatal("Floor should have tile map")
|
||||
}
|
||||
@@ -56,3 +107,27 @@ func TestFloorHasTileMap(t *testing.T) {
|
||||
t.Errorf("Room center should be floor tile, got %d", centerTile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeterministicGeneration(t *testing.T) {
|
||||
rng1 := rand.New(rand.NewSource(42))
|
||||
rng2 := rand.New(rand.NewSource(42))
|
||||
f1 := GenerateFloor(5, rng1)
|
||||
f2 := GenerateFloor(5, rng2)
|
||||
if len(f1.Rooms) != len(f2.Rooms) {
|
||||
t.Fatalf("room counts differ: %d vs %d", len(f1.Rooms), len(f2.Rooms))
|
||||
}
|
||||
for i, r := range f1.Rooms {
|
||||
if r.Type != f2.Rooms[i].Type || r.X != f2.Rooms[i].X || r.Y != f2.Rooms[i].Y {
|
||||
t.Errorf("room %d differs: type=%v/%v x=%d/%d y=%d/%d",
|
||||
i, r.Type, f2.Rooms[i].Type, r.X, f2.Rooms[i].X, r.Y, f2.Rooms[i].Y)
|
||||
}
|
||||
}
|
||||
// Also verify tile maps match
|
||||
for y := 0; y < f1.Height; y++ {
|
||||
for x := 0; x < f1.Width; x++ {
|
||||
if f1.Tiles[y][x] != f2.Tiles[y][x] {
|
||||
t.Errorf("tile at (%d,%d) differs: %d vs %d", x, y, f1.Tiles[y][x], f2.Tiles[y][x])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,33 +59,66 @@ func roomOwnership(floor *Floor) [][]int {
|
||||
return owner
|
||||
}
|
||||
|
||||
// corridorVisibility determines if a corridor tile should be visible.
|
||||
// A corridor is visible if it's adjacent to a visible or visited room.
|
||||
func corridorVisible(floor *Floor, owner [][]int, x, y int) Visibility {
|
||||
best := Hidden
|
||||
// Check neighboring tiles for room ownership
|
||||
for dy := -1; dy <= 1; dy++ {
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
ny, nx := y+dy, x+dx
|
||||
if ny >= 0 && ny < floor.Height && nx >= 0 && nx < floor.Width {
|
||||
ri := owner[ny][nx]
|
||||
if ri >= 0 {
|
||||
v := GetRoomVisibility(floor, ri)
|
||||
if v > best {
|
||||
best = v
|
||||
// buildCorridorVisibility flood-fills corridor visibility from visible/visited rooms.
|
||||
// Returns a map of (y,x) → Visibility for all corridor tiles.
|
||||
func buildCorridorVisibility(floor *Floor, owner [][]int) [][]Visibility {
|
||||
vis := make([][]Visibility, floor.Height)
|
||||
for y := 0; y < floor.Height; y++ {
|
||||
vis[y] = make([]Visibility, floor.Width)
|
||||
}
|
||||
|
||||
// Seed: corridor tiles adjacent to visible/visited rooms
|
||||
type pos struct{ y, x int }
|
||||
queue := []pos{}
|
||||
|
||||
for y := 0; y < floor.Height; y++ {
|
||||
for x := 0; x < floor.Width; x++ {
|
||||
if floor.Tiles[y][x] != TileCorridor {
|
||||
continue
|
||||
}
|
||||
best := Hidden
|
||||
for dy := -1; dy <= 1; dy++ {
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
ny, nx := y+dy, x+dx
|
||||
if ny >= 0 && ny < floor.Height && nx >= 0 && nx < floor.Width {
|
||||
ri := owner[ny][nx]
|
||||
if ri >= 0 {
|
||||
v := GetRoomVisibility(floor, ri)
|
||||
if v > best {
|
||||
best = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if best > Hidden {
|
||||
vis[y][x] = best
|
||||
queue = append(queue, pos{y, x})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flood-fill along corridor tiles (4-directional)
|
||||
dirs := [4][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}}
|
||||
for len(queue) > 0 {
|
||||
cur := queue[0]
|
||||
queue = queue[1:]
|
||||
for _, d := range dirs {
|
||||
ny, nx := cur.y+d[0], cur.x+d[1]
|
||||
if ny >= 0 && ny < floor.Height && nx >= 0 && nx < floor.Width {
|
||||
if floor.Tiles[ny][nx] == TileCorridor && vis[ny][nx] < vis[cur.y][cur.x] {
|
||||
vis[ny][nx] = vis[cur.y][cur.x]
|
||||
queue = append(queue, pos{ny, nx})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check along the corridor path: if this corridor connects two rooms,
|
||||
// it should be visible if either room is visible/visited.
|
||||
// The adjacency check above handles most cases.
|
||||
return best
|
||||
|
||||
return vis
|
||||
}
|
||||
|
||||
// wallVisibility determines if a wall tile should be shown based on adjacent rooms.
|
||||
func wallVisible(floor *Floor, owner [][]int, x, y int) Visibility {
|
||||
// wallVisibility determines if a wall tile should be shown based on adjacent rooms/corridors.
|
||||
func wallVisible(floor *Floor, owner [][]int, corrVis [][]Visibility, x, y int) Visibility {
|
||||
best := Hidden
|
||||
for dy := -1; dy <= 1; dy++ {
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
@@ -101,9 +134,8 @@ func wallVisible(floor *Floor, owner [][]int, x, y int) Visibility {
|
||||
}
|
||||
}
|
||||
if floor.Tiles[ny][nx] == TileCorridor {
|
||||
cv := corridorVisible(floor, owner, nx, ny)
|
||||
if cv > best {
|
||||
best = cv
|
||||
if corrVis[ny][nx] > best {
|
||||
best = corrVis[ny][nx]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,6 +187,9 @@ func RenderFloor(floor *Floor, currentRoom int, showFog bool) string {
|
||||
playerPos = [2]int{r.Y + r.H/2, r.X + r.W/2}
|
||||
}
|
||||
|
||||
// Pre-compute corridor visibility via flood-fill
|
||||
corrVis := buildCorridorVisibility(floor, owner)
|
||||
|
||||
buf := make([]byte, 0, floor.Width*floor.Height*4)
|
||||
|
||||
for y := 0; y < floor.Height; y++ {
|
||||
@@ -173,9 +208,9 @@ func RenderFloor(floor *Floor, currentRoom int, showFog bool) string {
|
||||
vis = Hidden
|
||||
}
|
||||
case TileCorridor:
|
||||
vis = corridorVisible(floor, owner, x, y)
|
||||
vis = corrVis[y][x]
|
||||
case TileWall:
|
||||
vis = wallVisible(floor, owner, x, y)
|
||||
vis = wallVisible(floor, owner, corrVis, x, y)
|
||||
default:
|
||||
vis = Hidden
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ const (
|
||||
RoomEvent
|
||||
RoomEmpty
|
||||
RoomBoss
|
||||
RoomSecret
|
||||
RoomMiniBoss
|
||||
)
|
||||
|
||||
func (r RoomType) String() string {
|
||||
return [...]string{"Combat", "Treasure", "Shop", "Event", "Empty", "Boss"}[r]
|
||||
return [...]string{"전투", "보물", "상점", "이벤트", "빈 방", "보스", "비밀", "미니보스"}[r]
|
||||
}
|
||||
|
||||
type Tile int
|
||||
@@ -44,16 +46,18 @@ type Floor struct {
|
||||
Height int
|
||||
}
|
||||
|
||||
func RandomRoomType() RoomType {
|
||||
r := rand.Float64() * 100
|
||||
func RandomRoomType(rng *rand.Rand) RoomType {
|
||||
r := rng.Float64() * 100
|
||||
switch {
|
||||
case r < 45:
|
||||
case r < 5:
|
||||
return RoomSecret
|
||||
case r < 50:
|
||||
return RoomCombat
|
||||
case r < 60:
|
||||
case r < 65:
|
||||
return RoomTreasure
|
||||
case r < 70:
|
||||
case r < 75:
|
||||
return RoomShop
|
||||
case r < 85:
|
||||
case r < 90:
|
||||
return RoomEvent
|
||||
default:
|
||||
return RoomEmpty
|
||||
|
||||
32
dungeon/theme.go
Normal file
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
|
||||
MonsterBoss15
|
||||
MonsterBoss20
|
||||
MonsterMiniBoss5
|
||||
MonsterMiniBoss10
|
||||
MonsterMiniBoss15
|
||||
MonsterMiniBoss20
|
||||
)
|
||||
|
||||
type monsterBase struct {
|
||||
@@ -31,6 +35,10 @@ var monsterDefs = map[MonsterType]monsterBase{
|
||||
MonsterBoss10: {"Warden", 250, 22, 12, 10, true},
|
||||
MonsterBoss15: {"Overlord", 400, 30, 16, 15, true},
|
||||
MonsterBoss20: {"Archlich", 600, 40, 20, 20, true},
|
||||
MonsterMiniBoss5: {"Guardian's Herald", 90, 9, 5, 4, false},
|
||||
MonsterMiniBoss10: {"Warden's Shadow", 150, 13, 7, 9, false},
|
||||
MonsterMiniBoss15: {"Overlord's Lieutenant", 240, 18, 10, 14, false},
|
||||
MonsterMiniBoss20: {"Archlich's Harbinger", 360, 24, 12, 19, false},
|
||||
}
|
||||
|
||||
type BossPattern int
|
||||
@@ -41,6 +49,7 @@ const (
|
||||
PatternPoison // applies poison
|
||||
PatternBurn // applies burn to random player
|
||||
PatternHeal // heals self
|
||||
PatternFreeze // applies freeze to all players
|
||||
)
|
||||
|
||||
type Monster struct {
|
||||
@@ -49,28 +58,34 @@ type Monster struct {
|
||||
HP, MaxHP int
|
||||
ATK, DEF int
|
||||
IsBoss bool
|
||||
IsMiniBoss bool
|
||||
IsElite bool
|
||||
ElitePrefix ElitePrefixType
|
||||
TauntTarget bool
|
||||
TauntTurns int
|
||||
Pattern BossPattern
|
||||
}
|
||||
|
||||
func NewMonster(mt MonsterType, floor int) *Monster {
|
||||
func NewMonster(mt MonsterType, floor int, scaling float64) *Monster {
|
||||
base := monsterDefs[mt]
|
||||
isMiniBoss := mt == MonsterMiniBoss5 || mt == MonsterMiniBoss10 ||
|
||||
mt == MonsterMiniBoss15 || mt == MonsterMiniBoss20
|
||||
scale := 1.0
|
||||
if !base.IsBoss && floor > base.MinFloor {
|
||||
scale = math.Pow(1.15, float64(floor-base.MinFloor))
|
||||
if !base.IsBoss && !isMiniBoss && floor > base.MinFloor {
|
||||
scale = math.Pow(scaling, float64(floor-base.MinFloor))
|
||||
}
|
||||
hp := int(math.Round(float64(base.HP) * scale))
|
||||
atk := int(math.Round(float64(base.ATK) * scale))
|
||||
def := int(math.Round(float64(base.DEF) * scale))
|
||||
return &Monster{
|
||||
Name: base.Name,
|
||||
Type: mt,
|
||||
HP: hp,
|
||||
MaxHP: hp,
|
||||
ATK: atk,
|
||||
DEF: def,
|
||||
IsBoss: base.IsBoss,
|
||||
Name: base.Name,
|
||||
Type: mt,
|
||||
HP: hp,
|
||||
MaxHP: hp,
|
||||
ATK: atk,
|
||||
DEF: def,
|
||||
IsBoss: base.IsBoss,
|
||||
IsMiniBoss: isMiniBoss,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
)
|
||||
|
||||
func TestMonsterScaling(t *testing.T) {
|
||||
slime := NewMonster(MonsterSlime, 1)
|
||||
slime := NewMonster(MonsterSlime, 1, 1.15)
|
||||
if slime.HP != 20 || slime.ATK != 5 {
|
||||
t.Errorf("Slime floor 1: got HP=%d ATK=%d, want HP=20 ATK=5", slime.HP, slime.ATK)
|
||||
}
|
||||
slimeF3 := NewMonster(MonsterSlime, 3)
|
||||
slimeF3 := NewMonster(MonsterSlime, 3, 1.15)
|
||||
expectedHP := int(math.Round(20 * math.Pow(1.15, 2)))
|
||||
if slimeF3.HP != expectedHP {
|
||||
t.Errorf("Slime floor 3: got HP=%d, want %d", slimeF3.HP, expectedHP)
|
||||
@@ -18,7 +18,7 @@ func TestMonsterScaling(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBossStats(t *testing.T) {
|
||||
boss := NewMonster(MonsterBoss5, 5)
|
||||
boss := NewMonster(MonsterBoss5, 5, 1.15)
|
||||
if boss.HP != 150 || boss.ATK != 15 || boss.DEF != 8 {
|
||||
t.Errorf("Boss5: got HP=%d ATK=%d DEF=%d, want 150/15/8", boss.HP, boss.ATK, boss.DEF)
|
||||
}
|
||||
@@ -26,12 +26,12 @@ func TestBossStats(t *testing.T) {
|
||||
|
||||
func TestMonsterDEFScaling(t *testing.T) {
|
||||
// Slime base DEF=1, minFloor=1. At floor 5, scale = 1.15^4 ≈ 1.749
|
||||
m := NewMonster(MonsterSlime, 5)
|
||||
m := NewMonster(MonsterSlime, 5, 1.15)
|
||||
if m.DEF <= 1 {
|
||||
t.Errorf("Slime DEF at floor 5 should be scaled above base 1, got %d", m.DEF)
|
||||
}
|
||||
// Boss DEF should NOT scale
|
||||
boss := NewMonster(MonsterBoss5, 5)
|
||||
boss := NewMonster(MonsterBoss5, 5, 1.15)
|
||||
if boss.DEF != 8 {
|
||||
t.Errorf("Boss5 DEF should be base 8, got %d", boss.DEF)
|
||||
}
|
||||
@@ -49,9 +49,43 @@ func TestTickTaunt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniBossStats(t *testing.T) {
|
||||
tests := []struct {
|
||||
mt MonsterType
|
||||
name string
|
||||
wantHP, wantATK, wantDEF int
|
||||
}{
|
||||
{MonsterMiniBoss5, "Guardian's Herald", 90, 9, 5},
|
||||
{MonsterMiniBoss10, "Warden's Shadow", 150, 13, 7},
|
||||
{MonsterMiniBoss15, "Overlord's Lieutenant", 240, 18, 10},
|
||||
{MonsterMiniBoss20, "Archlich's Harbinger", 360, 24, 12},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
m := NewMonster(tc.mt, tc.wantHP, 1.15) // floor doesn't matter, no scaling
|
||||
if m.Name != tc.name {
|
||||
t.Errorf("%v: name got %q, want %q", tc.mt, m.Name, tc.name)
|
||||
}
|
||||
if m.HP != tc.wantHP {
|
||||
t.Errorf("%v: HP got %d, want %d", tc.mt, m.HP, tc.wantHP)
|
||||
}
|
||||
if m.ATK != tc.wantATK {
|
||||
t.Errorf("%v: ATK got %d, want %d", tc.mt, m.ATK, tc.wantATK)
|
||||
}
|
||||
if m.DEF != tc.wantDEF {
|
||||
t.Errorf("%v: DEF got %d, want %d", tc.mt, m.DEF, tc.wantDEF)
|
||||
}
|
||||
if !m.IsMiniBoss {
|
||||
t.Errorf("%v: IsMiniBoss should be true", tc.mt)
|
||||
}
|
||||
if m.IsBoss {
|
||||
t.Errorf("%v: IsBoss should be false for mini-bosses", tc.mt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonsterAtMinFloor(t *testing.T) {
|
||||
// Slime at floor 1 (minFloor=1) should have base stats
|
||||
m := NewMonster(MonsterSlime, 1)
|
||||
m := NewMonster(MonsterSlime, 1, 1.15)
|
||||
if m.HP != 20 || m.ATK != 5 || m.DEF != 1 {
|
||||
t.Errorf("Slime at min floor should be base stats, got HP=%d ATK=%d DEF=%d", m.HP, m.ATK, m.DEF)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ const (
|
||||
StatusPoison StatusEffect = iota
|
||||
StatusBurn
|
||||
StatusFreeze
|
||||
StatusBleed
|
||||
StatusCurse
|
||||
)
|
||||
|
||||
type ActiveEffect struct {
|
||||
@@ -53,6 +55,7 @@ type Player struct {
|
||||
Dead bool
|
||||
Fled bool
|
||||
SkillUses int // remaining skill uses this combat
|
||||
Skills *PlayerSkills
|
||||
}
|
||||
|
||||
func NewPlayer(name string, class Class) *Player {
|
||||
@@ -76,6 +79,12 @@ func (p *Player) TakeDamage(dmg int) {
|
||||
}
|
||||
|
||||
func (p *Player) Heal(amount int) {
|
||||
for _, e := range p.Effects {
|
||||
if e.Type == StatusCurse {
|
||||
amount = amount * (100 - e.Value) / 100
|
||||
break
|
||||
}
|
||||
}
|
||||
p.HP += amount
|
||||
if p.HP > p.MaxHP {
|
||||
p.HP = p.MaxHP
|
||||
@@ -110,6 +119,7 @@ func (p *Player) EffectiveATK() int {
|
||||
atk += r.Value
|
||||
}
|
||||
}
|
||||
atk += p.Skills.GetATKBonus(p.Class)
|
||||
return atk
|
||||
}
|
||||
|
||||
@@ -125,6 +135,7 @@ func (p *Player) EffectiveDEF() int {
|
||||
def += r.Value
|
||||
}
|
||||
}
|
||||
def += p.Skills.GetDEFBonus(p.Class)
|
||||
return def
|
||||
}
|
||||
|
||||
@@ -157,29 +168,45 @@ func (p *Player) HasEffect(t StatusEffect) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *Player) TickEffects() (damages []string) {
|
||||
var remaining []ActiveEffect
|
||||
for _, e := range p.Effects {
|
||||
func (p *Player) TickEffects() []string {
|
||||
var msgs []string
|
||||
remaining := p.Effects[:0] // reuse underlying array
|
||||
for i := 0; i < len(p.Effects); i++ {
|
||||
e := &p.Effects[i]
|
||||
switch e.Type {
|
||||
case StatusPoison:
|
||||
p.HP -= e.Value
|
||||
if p.HP <= 0 {
|
||||
p.HP = 1 // Poison can't kill, leaves at 1 HP
|
||||
}
|
||||
damages = append(damages, fmt.Sprintf("%s takes %d poison damage", p.Name, e.Value))
|
||||
msgs = append(msgs, fmt.Sprintf("%s 독 피해 %d", p.Name, e.Value))
|
||||
case StatusBurn:
|
||||
p.HP -= e.Value
|
||||
if p.HP <= 0 {
|
||||
p.HP = 0
|
||||
p.Dead = true
|
||||
}
|
||||
damages = append(damages, fmt.Sprintf("%s takes %d burn damage", p.Name, e.Value))
|
||||
msgs = append(msgs, fmt.Sprintf("%s 화상 피해 %d", p.Name, e.Value))
|
||||
case StatusFreeze:
|
||||
msgs = append(msgs, fmt.Sprintf("%s 동결됨!", p.Name))
|
||||
case StatusBleed:
|
||||
p.HP -= e.Value
|
||||
msgs = append(msgs, fmt.Sprintf("%s 출혈 피해 %d", p.Name, e.Value))
|
||||
e.Value++ // Bleed intensifies each turn
|
||||
case StatusCurse:
|
||||
msgs = append(msgs, fmt.Sprintf("%s 저주 상태! 회복량 감소", p.Name))
|
||||
}
|
||||
if p.HP < 0 {
|
||||
p.HP = 0
|
||||
}
|
||||
e.Duration--
|
||||
if e.Duration > 0 {
|
||||
remaining = append(remaining, e)
|
||||
remaining = append(remaining, *e)
|
||||
}
|
||||
}
|
||||
p.Effects = remaining
|
||||
return
|
||||
if p.HP <= 0 && !p.Dead {
|
||||
p.Dead = true
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package entity
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewPlayer(t *testing.T) {
|
||||
p := NewPlayer("testuser", ClassWarrior)
|
||||
@@ -190,3 +193,49 @@ func TestEffectOverwrite(t *testing.T) {
|
||||
t.Error("should have overwritten with new values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBleedEffect(t *testing.T) {
|
||||
p := NewPlayer("Test", ClassWarrior)
|
||||
startHP := p.HP
|
||||
p.AddEffect(ActiveEffect{Type: StatusBleed, Duration: 3, Value: 2})
|
||||
|
||||
msgs := p.TickEffects()
|
||||
if len(msgs) == 0 || !strings.Contains(msgs[0], "출혈") {
|
||||
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], "동결") {
|
||||
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": "👋 인사합니다!",
|
||||
"/gg": "🎉 GG!",
|
||||
"/go": "⚔️ 가자!",
|
||||
"/wait": "✋ 기다려!",
|
||||
"/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, "👋 인사합니다!"},
|
||||
{"/gg", true, "🎉 GG!"},
|
||||
{"/go", true, "⚔️ 가자!"},
|
||||
{"/wait", true, "✋ 기다려!"},
|
||||
{"/help", true, "🆘 도움 요청!"},
|
||||
{"/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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
217
game/event.go
217
game/event.go
@@ -20,6 +20,11 @@ func (s *GameSession) EnterRoom(roomIdx int) {
|
||||
}
|
||||
}
|
||||
|
||||
s.enterRoomLocked(roomIdx)
|
||||
}
|
||||
|
||||
// enterRoomLocked performs room entry logic. Caller must hold s.mu.
|
||||
func (s *GameSession) enterRoomLocked(roomIdx int) {
|
||||
s.state.Floor.CurrentRoom = roomIdx
|
||||
dungeon.UpdateVisibility(s.state.Floor)
|
||||
room := s.state.Floor.Rooms[roomIdx]
|
||||
@@ -40,6 +45,11 @@ func (s *GameSession) EnterRoom(roomIdx int) {
|
||||
s.state.CombatTurn = 0
|
||||
s.signalCombat()
|
||||
case dungeon.RoomShop:
|
||||
if s.hasMutation("no_shop") {
|
||||
s.addLog("상점이 닫혔습니다! (주간 변이)")
|
||||
room.Cleared = true
|
||||
return
|
||||
}
|
||||
s.generateShopItems()
|
||||
s.state.Phase = PhaseShop
|
||||
case dungeon.RoomTreasure:
|
||||
@@ -48,6 +58,14 @@ func (s *GameSession) EnterRoom(roomIdx int) {
|
||||
case dungeon.RoomEvent:
|
||||
s.triggerEvent()
|
||||
room.Cleared = true
|
||||
case dungeon.RoomSecret:
|
||||
s.grantSecretTreasure()
|
||||
room.Cleared = true
|
||||
case dungeon.RoomMiniBoss:
|
||||
s.spawnMiniBoss()
|
||||
s.state.Phase = PhaseCombat
|
||||
s.state.CombatTurn = 0
|
||||
s.signalCombat()
|
||||
case dungeon.RoomEmpty:
|
||||
room.Cleared = true
|
||||
}
|
||||
@@ -81,21 +99,30 @@ func (s *GameSession) spawnMonsters() {
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
mt := valid[rand.Intn(len(valid))]
|
||||
m := entity.NewMonster(mt, floor)
|
||||
m := entity.NewMonster(mt, floor, s.cfg.Combat.MonsterScaling)
|
||||
if s.state.SoloMode {
|
||||
m.HP = m.HP / 2
|
||||
m.HP = int(float64(m.HP) * s.cfg.Combat.SoloHPReduction)
|
||||
if m.HP < 1 {
|
||||
m.HP = 1
|
||||
}
|
||||
m.MaxHP = m.HP
|
||||
m.DEF = m.DEF / 2
|
||||
m.DEF = int(float64(m.DEF) * s.cfg.Combat.SoloHPReduction)
|
||||
}
|
||||
if s.hasMutation("elite_flood") || rand.Float64() < 0.20 {
|
||||
entity.ApplyPrefix(m, entity.RandomPrefix())
|
||||
}
|
||||
if s.HardMode {
|
||||
mult := s.cfg.Difficulty.HardModeMonsterMult
|
||||
m.HP = int(float64(m.HP) * mult)
|
||||
m.MaxHP = m.HP
|
||||
m.ATK = int(float64(m.ATK) * mult)
|
||||
}
|
||||
s.state.Monsters[i] = m
|
||||
}
|
||||
|
||||
// Reset skill uses for all players at combat start
|
||||
for _, p := range s.state.Players {
|
||||
p.SkillUses = 3
|
||||
p.SkillUses = s.cfg.Game.SkillUses
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,35 +140,41 @@ func (s *GameSession) spawnBoss() {
|
||||
default:
|
||||
mt = entity.MonsterBoss5
|
||||
}
|
||||
boss := entity.NewMonster(mt, s.state.FloorNum)
|
||||
boss := entity.NewMonster(mt, s.state.FloorNum, s.cfg.Combat.MonsterScaling)
|
||||
switch mt {
|
||||
case entity.MonsterBoss5:
|
||||
boss.Pattern = entity.PatternAoE
|
||||
boss.Pattern = entity.PatternPoison // Swamp theme
|
||||
case entity.MonsterBoss10:
|
||||
boss.Pattern = entity.PatternPoison
|
||||
boss.Pattern = entity.PatternBurn // Volcano theme
|
||||
case entity.MonsterBoss15:
|
||||
boss.Pattern = entity.PatternBurn
|
||||
boss.Pattern = entity.PatternFreeze // Glacier theme
|
||||
case entity.MonsterBoss20:
|
||||
boss.Pattern = entity.PatternHeal
|
||||
boss.Pattern = entity.PatternHeal // Inferno theme (+ natural AoE every 3 turns)
|
||||
}
|
||||
if s.state.SoloMode {
|
||||
boss.HP = boss.HP / 2
|
||||
boss.HP = int(float64(boss.HP) * s.cfg.Combat.SoloHPReduction)
|
||||
boss.MaxHP = boss.HP
|
||||
boss.DEF = boss.DEF / 2
|
||||
boss.DEF = int(float64(boss.DEF) * s.cfg.Combat.SoloHPReduction)
|
||||
}
|
||||
if s.HardMode {
|
||||
mult := s.cfg.Difficulty.HardModeMonsterMult
|
||||
boss.HP = int(float64(boss.HP) * mult)
|
||||
boss.MaxHP = boss.HP
|
||||
boss.ATK = int(float64(boss.ATK) * mult)
|
||||
}
|
||||
s.state.Monsters = []*entity.Monster{boss}
|
||||
|
||||
// Reset skill uses for all players at combat start
|
||||
for _, p := range s.state.Players {
|
||||
p.SkillUses = 3
|
||||
p.SkillUses = s.cfg.Game.SkillUses
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GameSession) grantTreasure() {
|
||||
floor := s.state.FloorNum
|
||||
for _, p := range s.state.Players {
|
||||
if len(p.Inventory) >= 10 {
|
||||
s.addLog(fmt.Sprintf("%s's inventory is full!", p.Name))
|
||||
if len(p.Inventory) >= s.cfg.Game.InventoryLimit {
|
||||
s.addLog(fmt.Sprintf("%s의 인벤토리가 가득 찼습니다!", p.Name))
|
||||
continue
|
||||
}
|
||||
if rand.Float64() < 0.5 {
|
||||
@@ -150,14 +183,14 @@ func (s *GameSession) grantTreasure() {
|
||||
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))
|
||||
s.addLog(fmt.Sprintf("%s %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))
|
||||
s.addLog(fmt.Sprintf("%s %s 발견 (DEF+%d)", p.Name, item.Name, item.Bonus))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,6 +208,12 @@ func (s *GameSession) generateShopItems() {
|
||||
potionHeal := 30 + floor
|
||||
potionPrice := 20 + floor/2
|
||||
|
||||
if s.HardMode {
|
||||
mult := s.cfg.Difficulty.HardModeShopMult
|
||||
potionPrice = int(float64(potionPrice) * mult)
|
||||
weaponPrice = int(float64(weaponPrice) * mult)
|
||||
armorPrice = int(float64(armorPrice) * mult)
|
||||
}
|
||||
s.state.ShopItems = []entity.Item{
|
||||
{Name: "HP Potion", Type: entity.ItemConsumable, Bonus: potionHeal, Price: potionPrice},
|
||||
{Name: weaponName(floor), Type: entity.ItemWeapon, Bonus: weaponBonus, Price: weaponPrice},
|
||||
@@ -209,21 +248,143 @@ func armorName(floor int) string {
|
||||
}
|
||||
|
||||
func (s *GameSession) triggerEvent() {
|
||||
event := PickRandomEvent()
|
||||
s.state.LastEventName = event.Name
|
||||
s.addLog(fmt.Sprintf("이벤트: %s — %s", event.Name, event.Description))
|
||||
|
||||
// Auto-resolve with a random choice
|
||||
choice := event.Choices[rand.Intn(len(event.Choices))]
|
||||
outcome := choice.Resolve(s.state.FloorNum)
|
||||
s.addLog(fmt.Sprintf(" → %s: %s", choice.Label, outcome.Description))
|
||||
|
||||
// Pick a random alive player to apply the outcome
|
||||
var alive []*entity.Player
|
||||
for _, p := range s.state.Players {
|
||||
if p.IsDead() {
|
||||
continue
|
||||
if !p.IsDead() {
|
||||
alive = append(alive, p)
|
||||
}
|
||||
if rand.Float64() < 0.5 {
|
||||
baseDmg := 10 + s.state.FloorNum
|
||||
dmg := baseDmg + rand.Intn(baseDmg/2+1)
|
||||
p.TakeDamage(dmg)
|
||||
s.addLog(fmt.Sprintf("Trap! %s takes %d damage", p.Name, dmg))
|
||||
}
|
||||
if len(alive) == 0 {
|
||||
return
|
||||
}
|
||||
target := alive[rand.Intn(len(alive))]
|
||||
|
||||
if outcome.HPChange > 0 {
|
||||
before := target.HP
|
||||
target.Heal(outcome.HPChange)
|
||||
s.addLog(fmt.Sprintf(" %s HP %d 회복", target.Name, target.HP-before))
|
||||
} else if outcome.HPChange < 0 {
|
||||
target.TakeDamage(-outcome.HPChange)
|
||||
s.addLog(fmt.Sprintf(" %s %d 피해를 받음", 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 골드 %d 획득", target.Name, outcome.GoldChange))
|
||||
} else {
|
||||
baseHeal := 15 + s.state.FloorNum
|
||||
heal := baseHeal + rand.Intn(baseHeal/2+1)
|
||||
before := p.HP
|
||||
p.Heal(heal)
|
||||
s.addLog(fmt.Sprintf("Blessing! %s heals %d HP", p.Name, p.HP-before))
|
||||
s.addLog(fmt.Sprintf(" %s 골드 %d 잃음", target.Name, -outcome.GoldChange))
|
||||
}
|
||||
}
|
||||
|
||||
if outcome.ItemDrop {
|
||||
if len(target.Inventory) < s.cfg.Game.InventoryLimit {
|
||||
floor := s.state.FloorNum
|
||||
if rand.Float64() < 0.5 {
|
||||
bonus := 3 + rand.Intn(6) + floor/3
|
||||
item := entity.Item{Name: weaponName(floor), Type: entity.ItemWeapon, Bonus: bonus}
|
||||
target.Inventory = append(target.Inventory, item)
|
||||
s.addLog(fmt.Sprintf(" %s %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 %s 발견 (DEF+%d)", target.Name, item.Name, item.Bonus))
|
||||
}
|
||||
} else {
|
||||
s.addLog(fmt.Sprintf(" %s의 인벤토리가 가득 찼습니다!", target.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GameSession) grantSecretTreasure() {
|
||||
s.addLog("보물로 가득 찬 비밀의 방을 발견했습니다!")
|
||||
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의 인벤토리가 가득 찼습니다!", 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 %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 %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)
|
||||
}
|
||||
if s.HardMode {
|
||||
mult := s.cfg.Difficulty.HardModeMonsterMult
|
||||
miniBoss.HP = int(float64(miniBoss.HP) * mult)
|
||||
miniBoss.MaxHP = miniBoss.HP
|
||||
miniBoss.ATK = int(float64(miniBoss.ATK) * mult)
|
||||
}
|
||||
s.state.Monsters = []*entity.Monster{miniBoss}
|
||||
s.addLog(fmt.Sprintf("미니보스 등장: %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 (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"sync"
|
||||
|
||||
"github.com/tolelom/catacombs/config"
|
||||
)
|
||||
|
||||
type RoomStatus int
|
||||
@@ -36,19 +39,25 @@ type OnlinePlayer struct {
|
||||
|
||||
type Lobby struct {
|
||||
mu sync.RWMutex
|
||||
cfg *config.Config
|
||||
rooms map[string]*LobbyRoom
|
||||
online map[string]*OnlinePlayer // fingerprint -> player
|
||||
activeSessions map[string]string // fingerprint -> room code (for reconnect)
|
||||
}
|
||||
|
||||
func NewLobby() *Lobby {
|
||||
func NewLobby(cfg *config.Config) *Lobby {
|
||||
return &Lobby{
|
||||
cfg: cfg,
|
||||
rooms: make(map[string]*LobbyRoom),
|
||||
online: make(map[string]*OnlinePlayer),
|
||||
activeSessions: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Lobby) Cfg() *config.Config {
|
||||
return l.cfg
|
||||
}
|
||||
|
||||
func (l *Lobby) RegisterSession(fingerprint, roomCode string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
@@ -108,10 +117,10 @@ func (l *Lobby) InvitePlayer(roomCode, fingerprint string) error {
|
||||
defer l.mu.Unlock()
|
||||
p, ok := l.online[fingerprint]
|
||||
if !ok {
|
||||
return fmt.Errorf("player not online")
|
||||
return fmt.Errorf("플레이어가 온라인이 아닙니다")
|
||||
}
|
||||
if p.InRoom != "" {
|
||||
return fmt.Errorf("player already in a room")
|
||||
return fmt.Errorf("플레이어가 이미 방에 있습니다")
|
||||
}
|
||||
// Store the invite as a pending field
|
||||
p.InRoom = "invited:" + roomCode
|
||||
@@ -130,6 +139,7 @@ func (l *Lobby) CreateRoom(name string) string {
|
||||
Name: name,
|
||||
Status: RoomWaiting,
|
||||
}
|
||||
slog.Info("room created", "code", code, "name", name)
|
||||
return code
|
||||
}
|
||||
|
||||
@@ -138,18 +148,38 @@ func (l *Lobby) JoinRoom(code, playerName, fingerprint string) error {
|
||||
defer l.mu.Unlock()
|
||||
room, ok := l.rooms[code]
|
||||
if !ok {
|
||||
return fmt.Errorf("room %s not found", code)
|
||||
return fmt.Errorf("방 %s을(를) 찾을 수 없습니다", code)
|
||||
}
|
||||
if len(room.Players) >= 4 {
|
||||
return fmt.Errorf("room %s is full", code)
|
||||
if len(room.Players) >= l.cfg.Game.MaxPlayers {
|
||||
return fmt.Errorf("방 %s이(가) 가득 찼습니다", code)
|
||||
}
|
||||
if room.Status != RoomWaiting {
|
||||
return fmt.Errorf("room %s already in progress", code)
|
||||
return fmt.Errorf("방 %s이(가) 이미 진행 중입니다", code)
|
||||
}
|
||||
room.Players = append(room.Players, LobbyPlayer{Name: playerName, Fingerprint: fingerprint})
|
||||
slog.Info("player joined", "room", code, "player", playerName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Lobby) LeaveRoom(code, fingerprint string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
room, ok := l.rooms[code]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for i, p := range room.Players {
|
||||
if p.Fingerprint == fingerprint {
|
||||
room.Players = append(room.Players[:i], room.Players[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
// Remove empty waiting rooms
|
||||
if len(room.Players) == 0 && room.Status == RoomWaiting {
|
||||
delete(l.rooms, code)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Lobby) SetPlayerClass(code, fingerprint, class string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
@@ -210,6 +240,7 @@ func (l *Lobby) StartRoom(code string) {
|
||||
defer l.mu.Unlock()
|
||||
if room, ok := l.rooms[code]; ok {
|
||||
room.Status = RoomPlaying
|
||||
slog.Info("game started", "room", code, "players", len(room.Players))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
package game
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tolelom/catacombs/config"
|
||||
)
|
||||
|
||||
func testConfig(t *testing.T) *config.Config {
|
||||
t.Helper()
|
||||
cfg, _ := config.Load("")
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestCreateRoom(t *testing.T) {
|
||||
lobby := NewLobby()
|
||||
lobby := NewLobby(testConfig(t))
|
||||
code := lobby.CreateRoom("Test Room")
|
||||
if len(code) != 4 {
|
||||
t.Errorf("Room code length: got %d, want 4", len(code))
|
||||
@@ -15,7 +25,7 @@ func TestCreateRoom(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestJoinRoom(t *testing.T) {
|
||||
lobby := NewLobby()
|
||||
lobby := NewLobby(testConfig(t))
|
||||
code := lobby.CreateRoom("Test Room")
|
||||
err := lobby.JoinRoom(code, "player1", "fp-player1")
|
||||
if err != nil {
|
||||
@@ -28,7 +38,7 @@ func TestJoinRoom(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRoomStatusTransition(t *testing.T) {
|
||||
l := NewLobby()
|
||||
l := NewLobby(testConfig(t))
|
||||
code := l.CreateRoom("Test")
|
||||
l.JoinRoom(code, "Alice", "fp-alice")
|
||||
r := l.GetRoom(code)
|
||||
@@ -47,7 +57,7 @@ func TestRoomStatusTransition(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestJoinRoomFull(t *testing.T) {
|
||||
lobby := NewLobby()
|
||||
lobby := NewLobby(testConfig(t))
|
||||
code := lobby.CreateRoom("Test Room")
|
||||
for i := 0; i < 4; i++ {
|
||||
lobby.JoinRoom(code, "player", "fp-player")
|
||||
@@ -59,7 +69,7 @@ func TestJoinRoomFull(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSetPlayerClass(t *testing.T) {
|
||||
l := NewLobby()
|
||||
l := NewLobby(testConfig(t))
|
||||
code := l.CreateRoom("Test")
|
||||
l.JoinRoom(code, "Alice", "fp-alice")
|
||||
l.SetPlayerClass(code, "fp-alice", "Warrior")
|
||||
@@ -70,7 +80,7 @@ func TestSetPlayerClass(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAllReady(t *testing.T) {
|
||||
l := NewLobby()
|
||||
l := NewLobby(testConfig(t))
|
||||
code := l.CreateRoom("Test")
|
||||
l.JoinRoom(code, "Alice", "fp-alice")
|
||||
l.JoinRoom(code, "Bob", "fp-bob")
|
||||
@@ -91,7 +101,7 @@ func TestAllReady(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAllReadyEmptyRoom(t *testing.T) {
|
||||
l := NewLobby()
|
||||
l := NewLobby(testConfig(t))
|
||||
code := l.CreateRoom("Test")
|
||||
if l.AllReady(code) {
|
||||
t.Error("empty room should not be all ready")
|
||||
|
||||
41
game/mutation.go
Normal file
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: "스킬 봉인", Description: "직업 스킬 사용 불가",
|
||||
Apply: func(cfg *config.GameConfig) { cfg.SkillUses = 0 }},
|
||||
{ID: "speed_run", Name: "스피드 런", Description: "턴 제한 시간 절반",
|
||||
Apply: func(cfg *config.GameConfig) { cfg.TurnTimeoutSec = max(cfg.TurnTimeoutSec/2, 2) }},
|
||||
{ID: "no_shop", Name: "상점 폐쇄", Description: "상점 이용 불가",
|
||||
Apply: func(cfg *config.GameConfig) {}}, // handled at runtime in EnterRoom
|
||||
{ID: "glass_cannon", Name: "유리 대포", Description: "피해 2배, HP 절반",
|
||||
Apply: func(cfg *config.GameConfig) {}}, // handled at runtime in AddPlayer/spawnMonsters
|
||||
{ID: "elite_flood", Name: "엘리트 범람", Description: "모든 몬스터가 엘리트",
|
||||
Apply: func(cfg *config.GameConfig) {}}, // handled at runtime in spawnMonsters
|
||||
}
|
||||
|
||||
// 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: "이상한 에너지로 빛나는 고대 제단을 발견했습니다.",
|
||||
Choices: []EventChoice{
|
||||
{
|
||||
Label: "제단에서 기도하기",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
if rand.Float64() < 0.6 {
|
||||
heal := 15 + floor*2
|
||||
return EventOutcome{HPChange: heal, Description: "제단이 치유의 빛으로 축복합니다."}
|
||||
}
|
||||
dmg := 10 + floor
|
||||
return EventOutcome{HPChange: -dmg, Description: "제단의 에너지가 당신을 공격합니다!"}
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "골드 바치기",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
cost := 10 + floor
|
||||
return EventOutcome{GoldChange: -cost, ItemDrop: true, Description: "골드를 바치고 신성한 선물을 받았습니다."}
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "그냥 지나가기",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
return EventOutcome{Description: "제단을 건드리지 않고 떠납니다."}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "fountain",
|
||||
Description: "방 중앙에서 빛나는 분수가 솟아오릅니다.",
|
||||
Choices: []EventChoice{
|
||||
{
|
||||
Label: "분수의 물 마시기",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
heal := 20 + floor*2
|
||||
return EventOutcome{HPChange: heal, Description: "물이 당신을 활기차게 합니다!"}
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "동전 던지기",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
if rand.Float64() < 0.5 {
|
||||
gold := 15 + floor*3
|
||||
return EventOutcome{GoldChange: gold, Description: "분수가 당신의 관대함에 보답합니다!"}
|
||||
}
|
||||
return EventOutcome{GoldChange: -5, Description: "동전이 가라앉고 아무 일도 일어나지 않습니다."}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "merchant",
|
||||
Description: "두건을 쓴 상인이 어둠 속에서 나타납니다.",
|
||||
Choices: []EventChoice{
|
||||
{
|
||||
Label: "골드로 치료 거래",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
cost := 15 + floor
|
||||
heal := 25 + floor*2
|
||||
return EventOutcome{HPChange: heal, GoldChange: -cost, Description: "상인이 치유의 물약을 팝니다."}
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "미스터리 아이템 구매",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
cost := 20 + floor*2
|
||||
return EventOutcome{GoldChange: -cost, ItemDrop: true, Description: "상인이 포장된 꾸러미를 건넵니다."}
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "거절하기",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
return EventOutcome{Description: "상인이 어둠 속으로 사라집니다."}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "trap_room",
|
||||
Description: "바닥이 수상한 압력판으로 덮여 있습니다.",
|
||||
Choices: []EventChoice{
|
||||
{
|
||||
Label: "조심히 지나가기",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
if rand.Float64() < 0.5 {
|
||||
return EventOutcome{Description: "능숙하게 모든 함정을 피했습니다!"}
|
||||
}
|
||||
dmg := 8 + floor
|
||||
return EventOutcome{HPChange: -dmg, Description: "함정을 밟아 피해를 입었습니다!"}
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "돌진하기",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
dmg := 5 + floor/2
|
||||
gold := 10 + floor*2
|
||||
return EventOutcome{HPChange: -dmg, GoldChange: gold, Description: "약간의 피해를 입었지만 숨겨진 골드를 발견했습니다!"}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "shrine",
|
||||
Description: "신성한 힘으로 울리는 빛나는 성소가 있습니다.",
|
||||
Choices: []EventChoice{
|
||||
{
|
||||
Label: "무릎 꿇고 기도하기",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
heal := 30 + floor*2
|
||||
return EventOutcome{HPChange: heal, Description: "성소가 새로운 활력으로 가득 채워줍니다!"}
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "제물 가져가기",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
gold := 20 + floor*3
|
||||
dmg := 15 + floor
|
||||
return EventOutcome{HPChange: -dmg, GoldChange: gold, Description: "제물을 훔쳤지만 영혼들이 분노합니다!"}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "chest",
|
||||
Description: "방 구석에 화려한 상자가 놓여 있습니다.",
|
||||
Choices: []EventChoice{
|
||||
{
|
||||
Label: "조심히 열기",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
if rand.Float64() < 0.7 {
|
||||
gold := 15 + floor*2
|
||||
return EventOutcome{GoldChange: gold, Description: "상자 안에 골드 더미가 있습니다!"}
|
||||
}
|
||||
dmg := 12 + floor
|
||||
return EventOutcome{HPChange: -dmg, Description: "상자가 미믹이었습니다! 물어뜯깁니다!"}
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "부수어 열기",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
return EventOutcome{ItemDrop: true, Description: "상자를 부수고 안에서 장비를 발견했습니다!"}
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "그냥 두기",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
return EventOutcome{Description: "안전한 게 최고입니다."}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "ghost",
|
||||
Description: "유령 같은 형체가 눈앞에 나타납니다.",
|
||||
Choices: []EventChoice{
|
||||
{
|
||||
Label: "유령과 대화하기",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
gold := 10 + floor*2
|
||||
return EventOutcome{GoldChange: gold, Description: "유령이 들어줘서 감사하며 보상합니다."}
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "유령 공격하기",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
if rand.Float64() < 0.4 {
|
||||
return EventOutcome{ItemDrop: true, Description: "유령이 사라지며 유령 무기를 떨어뜨립니다!"}
|
||||
}
|
||||
dmg := 15 + floor
|
||||
return EventOutcome{HPChange: -dmg, Description: "유령이 분노하여 반격합니다!"}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "mushroom",
|
||||
Description: "이상하게 빛나는 버섯들이 무리 지어 자라고 있습니다.",
|
||||
Choices: []EventChoice{
|
||||
{
|
||||
Label: "버섯 먹기",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
r := rand.Float64()
|
||||
if r < 0.33 {
|
||||
heal := 20 + floor*2
|
||||
return EventOutcome{HPChange: heal, Description: "버섯이 맛있고 치유 효과가 있습니다!"}
|
||||
} else if r < 0.66 {
|
||||
dmg := 10 + floor
|
||||
return EventOutcome{HPChange: -dmg, Description: "독버섯이었습니다!"}
|
||||
}
|
||||
gold := 10 + floor
|
||||
return EventOutcome{GoldChange: gold, Description: "버섯이 이상한 환각을 보여주고... 위에서 골드가 떨어집니다!"}
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "채집하여 팔기",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
gold := 8 + floor
|
||||
return EventOutcome{GoldChange: gold, Description: "조심히 버섯을 채집하여 판매합니다."}
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "무시하기",
|
||||
Resolve: func(floor int) EventOutcome {
|
||||
return EventOutcome{Description: "의문의 균류를 현명하게 피합니다."}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
231
game/session.go
231
game/session.go
@@ -2,9 +2,12 @@ package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tolelom/catacombs/config"
|
||||
"github.com/tolelom/catacombs/dungeon"
|
||||
"github.com/tolelom/catacombs/entity"
|
||||
)
|
||||
@@ -52,6 +55,8 @@ type GameState struct {
|
||||
TurnResolving bool // true while logs are being replayed
|
||||
BossKilled bool
|
||||
FleeSucceeded bool
|
||||
LastEventName string // name of the most recent random event (for codex)
|
||||
MoveVotes map[string]int // fingerprint -> voted room index (exploration)
|
||||
}
|
||||
|
||||
func (s *GameSession) addLog(msg string) {
|
||||
@@ -70,14 +75,20 @@ func (s *GameSession) clearLog() {
|
||||
}
|
||||
|
||||
type GameSession struct {
|
||||
mu sync.Mutex
|
||||
state GameState
|
||||
started bool
|
||||
actions map[string]PlayerAction // playerName -> action
|
||||
actionCh chan playerActionMsg
|
||||
combatSignal chan struct{}
|
||||
done chan struct{}
|
||||
lastActivity map[string]time.Time // fingerprint -> last activity time
|
||||
mu sync.Mutex
|
||||
cfg *config.Config
|
||||
state GameState
|
||||
started bool
|
||||
actions map[string]PlayerAction // playerName -> action
|
||||
actionCh chan playerActionMsg
|
||||
combatSignal chan struct{}
|
||||
done chan struct{}
|
||||
lastActivity map[string]time.Time // fingerprint -> last activity time
|
||||
moveVotes map[string]int // fingerprint -> voted room index
|
||||
HardMode bool
|
||||
ActiveMutation *Mutation
|
||||
DailyMode bool
|
||||
DailyDate string
|
||||
}
|
||||
|
||||
type playerActionMsg struct {
|
||||
@@ -85,8 +96,14 @@ type playerActionMsg struct {
|
||||
Action PlayerAction
|
||||
}
|
||||
|
||||
func NewGameSession() *GameSession {
|
||||
// hasMutation returns true if the session has the given mutation active.
|
||||
func (s *GameSession) hasMutation(id string) bool {
|
||||
return s.ActiveMutation != nil && s.ActiveMutation.ID == id
|
||||
}
|
||||
|
||||
func NewGameSession(cfg *config.Config) *GameSession {
|
||||
return &GameSession{
|
||||
cfg: cfg,
|
||||
state: GameState{
|
||||
FloorNum: 1,
|
||||
},
|
||||
@@ -98,6 +115,13 @@ func NewGameSession() *GameSession {
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyWeeklyMutation sets the current week's mutation on this session.
|
||||
func (s *GameSession) ApplyWeeklyMutation() {
|
||||
mut := GetWeeklyMutation()
|
||||
s.ActiveMutation = &mut
|
||||
mut.Apply(&s.cfg.Game)
|
||||
}
|
||||
|
||||
func (s *GameSession) Stop() {
|
||||
select {
|
||||
case <-s.done:
|
||||
@@ -140,6 +164,7 @@ func (s *GameSession) combatLoop() {
|
||||
s.mu.Unlock()
|
||||
|
||||
if gameOver {
|
||||
slog.Info("game over", "floor", s.state.FloorNum, "victory", s.state.Victory)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -152,7 +177,8 @@ func (s *GameSession) combatLoop() {
|
||||
if p.Fingerprint != "" && !p.IsOut() {
|
||||
if last, ok := s.lastActivity[p.Fingerprint]; ok {
|
||||
if now.Sub(last) > 60*time.Second {
|
||||
s.addLog(fmt.Sprintf("%s removed (disconnected)", p.Name))
|
||||
slog.Warn("player inactive removed", "fingerprint", p.Fingerprint, "name", p.Name)
|
||||
s.addLog(fmt.Sprintf("%s 제거됨 (접속 끊김)", p.Name))
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
@@ -192,13 +218,29 @@ func (s *GameSession) signalCombat() {
|
||||
func (s *GameSession) AddPlayer(p *entity.Player) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if p.Skills == nil {
|
||||
p.Skills = &entity.PlayerSkills{BranchIndex: -1}
|
||||
}
|
||||
if s.hasMutation("glass_cannon") {
|
||||
p.ATK *= 2
|
||||
p.MaxHP /= 2
|
||||
if p.MaxHP < 1 {
|
||||
p.MaxHP = 1
|
||||
}
|
||||
p.HP = p.MaxHP
|
||||
}
|
||||
s.state.Players = append(s.state.Players, p)
|
||||
}
|
||||
|
||||
func (s *GameSession) StartFloor() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum)
|
||||
if s.DailyMode {
|
||||
seed := DailySeed(s.DailyDate) + int64(s.state.FloorNum)
|
||||
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum, rand.New(rand.NewSource(seed)))
|
||||
} else {
|
||||
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum, rand.New(rand.NewSource(time.Now().UnixNano())))
|
||||
}
|
||||
s.state.Phase = PhaseExploring
|
||||
s.state.TurnNum = 0
|
||||
|
||||
@@ -224,6 +266,10 @@ func (s *GameSession) GetState() GameState {
|
||||
copy(cp.Relics, p.Relics)
|
||||
cp.Effects = make([]entity.ActiveEffect, len(p.Effects))
|
||||
copy(cp.Effects, p.Effects)
|
||||
if p.Skills != nil {
|
||||
skillsCopy := *p.Skills
|
||||
cp.Skills = &skillsCopy
|
||||
}
|
||||
players[i] = &cp
|
||||
}
|
||||
|
||||
@@ -258,6 +304,15 @@ func (s *GameSession) GetState() GameState {
|
||||
submittedCopy[k] = v
|
||||
}
|
||||
|
||||
// Copy move votes
|
||||
var moveVotesCopy map[string]int
|
||||
if s.state.MoveVotes != nil {
|
||||
moveVotesCopy = make(map[string]int, len(s.state.MoveVotes))
|
||||
for k, v := range s.state.MoveVotes {
|
||||
moveVotesCopy[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Copy pending logs
|
||||
pendingCopy := make([]string, len(s.state.PendingLogs))
|
||||
copy(pendingCopy, s.state.PendingLogs)
|
||||
@@ -281,24 +336,41 @@ func (s *GameSession) GetState() GameState {
|
||||
TurnResolving: s.state.TurnResolving,
|
||||
BossKilled: s.state.BossKilled,
|
||||
FleeSucceeded: s.state.FleeSucceeded,
|
||||
LastEventName: s.state.LastEventName,
|
||||
MoveVotes: moveVotesCopy,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GameSession) SubmitAction(playerID string, action PlayerAction) {
|
||||
s.mu.Lock()
|
||||
s.lastActivity[playerID] = time.Now()
|
||||
|
||||
// Block dead/out players from submitting
|
||||
for _, p := range s.state.Players {
|
||||
if p.Fingerprint == playerID && p.IsOut() {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent duplicate submissions in the same turn
|
||||
if _, already := s.state.SubmittedActions[playerID]; already {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
desc := ""
|
||||
switch action.Type {
|
||||
case ActionAttack:
|
||||
desc = "Attacking"
|
||||
desc = "공격"
|
||||
case ActionSkill:
|
||||
desc = "Using Skill"
|
||||
desc = "스킬 사용"
|
||||
case ActionItem:
|
||||
desc = "Using Item"
|
||||
desc = "아이템 사용"
|
||||
case ActionFlee:
|
||||
desc = "Fleeing"
|
||||
desc = "도주"
|
||||
case ActionWait:
|
||||
desc = "Defending"
|
||||
desc = "방어"
|
||||
}
|
||||
if s.state.SubmittedActions == nil {
|
||||
s.state.SubmittedActions = make(map[string]string)
|
||||
@@ -330,32 +402,64 @@ func (s *GameSession) TouchActivity(fingerprint string) {
|
||||
s.lastActivity[fingerprint] = time.Now()
|
||||
}
|
||||
|
||||
// AllocateSkillPoint spends one skill point into the given branch for the player.
|
||||
func (s *GameSession) AllocateSkillPoint(fingerprint string, branchIdx int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, p := range s.state.Players {
|
||||
if p.Fingerprint == fingerprint {
|
||||
if p.Skills == nil || p.Skills.Points <= p.Skills.Allocated {
|
||||
return fmt.Errorf("스킬 포인트가 없습니다")
|
||||
}
|
||||
return p.Skills.Allocate(branchIdx, p.Class)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("플레이어를 찾을 수 없습니다")
|
||||
}
|
||||
|
||||
// BuyResult describes the outcome of a shop purchase attempt.
|
||||
type BuyResult int
|
||||
|
||||
const (
|
||||
BuyOK BuyResult = iota
|
||||
BuyNoGold
|
||||
BuyInventoryFull
|
||||
BuyFailed
|
||||
)
|
||||
|
||||
// BuyItem handles shop purchases
|
||||
func (s *GameSession) BuyItem(playerID string, itemIdx int) bool {
|
||||
func (s *GameSession) BuyItem(playerID string, itemIdx int) BuyResult {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state.Phase != PhaseShop || itemIdx < 0 || itemIdx >= len(s.state.ShopItems) {
|
||||
return false
|
||||
return BuyFailed
|
||||
}
|
||||
item := s.state.ShopItems[itemIdx]
|
||||
for _, p := range s.state.Players {
|
||||
if p.Fingerprint == playerID && p.Gold >= item.Price {
|
||||
if len(p.Inventory) >= 10 {
|
||||
return false
|
||||
if p.Fingerprint == playerID {
|
||||
if p.Gold < item.Price {
|
||||
return BuyNoGold
|
||||
}
|
||||
if len(p.Inventory) >= s.cfg.Game.InventoryLimit {
|
||||
return BuyInventoryFull
|
||||
}
|
||||
p.Gold -= item.Price
|
||||
p.Inventory = append(p.Inventory, item)
|
||||
return true
|
||||
return BuyOK
|
||||
}
|
||||
}
|
||||
return false
|
||||
return BuyFailed
|
||||
}
|
||||
|
||||
// SendChat appends a chat message to the combat log
|
||||
func (s *GameSession) SendChat(playerName, message string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.addLog(fmt.Sprintf("[%s] %s", playerName, message))
|
||||
if emoteText, ok := ParseEmote(message); ok {
|
||||
s.addLog(fmt.Sprintf("✨ %s %s", playerName, emoteText))
|
||||
} else {
|
||||
s.addLog(fmt.Sprintf("[%s] %s", playerName, message))
|
||||
}
|
||||
}
|
||||
|
||||
// LeaveShop exits the shop phase
|
||||
@@ -365,3 +469,82 @@ func (s *GameSession) LeaveShop() {
|
||||
s.state.Phase = PhaseExploring
|
||||
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
|
||||
}
|
||||
|
||||
// SubmitMoveVote records a player's room choice during exploration.
|
||||
// When all alive players have voted, the majority choice wins and the party moves.
|
||||
// Returns true if the vote triggered a move (all votes collected).
|
||||
func (s *GameSession) SubmitMoveVote(fingerprint string, roomIdx int) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.state.Phase != PhaseExploring {
|
||||
return false
|
||||
}
|
||||
|
||||
s.lastActivity[fingerprint] = time.Now()
|
||||
|
||||
if s.moveVotes == nil {
|
||||
s.moveVotes = make(map[string]int)
|
||||
}
|
||||
s.moveVotes[fingerprint] = roomIdx
|
||||
|
||||
// Copy votes to state for UI display
|
||||
s.state.MoveVotes = make(map[string]int, len(s.moveVotes))
|
||||
for k, v := range s.moveVotes {
|
||||
s.state.MoveVotes[k] = v
|
||||
}
|
||||
|
||||
// Check if all alive players have voted
|
||||
aliveCount := 0
|
||||
for _, p := range s.state.Players {
|
||||
if !p.IsOut() {
|
||||
aliveCount++
|
||||
}
|
||||
}
|
||||
voteCount := 0
|
||||
for _, p := range s.state.Players {
|
||||
if !p.IsOut() {
|
||||
if _, ok := s.moveVotes[p.Fingerprint]; ok {
|
||||
voteCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if voteCount < aliveCount {
|
||||
return false
|
||||
}
|
||||
|
||||
// All voted — resolve by majority
|
||||
tally := make(map[int]int)
|
||||
for _, p := range s.state.Players {
|
||||
if !p.IsOut() {
|
||||
if room, ok := s.moveVotes[p.Fingerprint]; ok {
|
||||
tally[room]++
|
||||
}
|
||||
}
|
||||
}
|
||||
bestRoom := -1
|
||||
bestCount := 0
|
||||
for room, count := range tally {
|
||||
if count > bestCount || (count == bestCount && room < bestRoom) {
|
||||
bestRoom = room
|
||||
bestCount = count
|
||||
}
|
||||
}
|
||||
|
||||
// Clear votes
|
||||
s.moveVotes = nil
|
||||
s.state.MoveVotes = nil
|
||||
|
||||
// Execute the move (inline EnterRoom logic since we already hold the lock)
|
||||
s.enterRoomLocked(bestRoom)
|
||||
return true
|
||||
}
|
||||
|
||||
// ClearMoveVotes resets any pending move votes (e.g. when phase changes).
|
||||
func (s *GameSession) ClearMoveVotes() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.moveVotes = nil
|
||||
s.state.MoveVotes = nil
|
||||
}
|
||||
|
||||
@@ -4,11 +4,18 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tolelom/catacombs/config"
|
||||
"github.com/tolelom/catacombs/entity"
|
||||
)
|
||||
|
||||
func testCfg(t *testing.T) *config.Config {
|
||||
t.Helper()
|
||||
cfg, _ := config.Load("")
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestGetStateNoRace(t *testing.T) {
|
||||
s := NewGameSession()
|
||||
s := NewGameSession(testCfg(t))
|
||||
p := entity.NewPlayer("Racer", entity.ClassWarrior)
|
||||
p.Fingerprint = "test-fp"
|
||||
s.AddPlayer(p)
|
||||
@@ -40,7 +47,7 @@ func TestGetStateNoRace(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSessionTurnTimeout(t *testing.T) {
|
||||
s := NewGameSession()
|
||||
s := NewGameSession(testCfg(t))
|
||||
p := entity.NewPlayer("test", entity.ClassWarrior)
|
||||
p.Fingerprint = "test-fp"
|
||||
s.AddPlayer(p)
|
||||
@@ -56,13 +63,13 @@ func TestSessionTurnTimeout(t *testing.T) {
|
||||
select {
|
||||
case <-done:
|
||||
// Turn completed via timeout
|
||||
case <-time.After(7 * time.Second):
|
||||
t.Error("Turn did not timeout within 7 seconds")
|
||||
case <-time.After(12 * time.Second):
|
||||
t.Error("Turn did not timeout within 12 seconds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealNextLog(t *testing.T) {
|
||||
s := NewGameSession()
|
||||
s := NewGameSession(testCfg(t))
|
||||
|
||||
// No logs to reveal
|
||||
if s.RevealNextLog() {
|
||||
@@ -95,7 +102,7 @@ func TestRevealNextLog(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeepCopyIndependence(t *testing.T) {
|
||||
s := NewGameSession()
|
||||
s := NewGameSession(testCfg(t))
|
||||
p := entity.NewPlayer("Test", entity.ClassWarrior)
|
||||
p.Fingerprint = "fp-test"
|
||||
p.Inventory = append(p.Inventory, entity.Item{Name: "Sword", Type: entity.ItemWeapon, Bonus: 5})
|
||||
@@ -118,7 +125,7 @@ func TestDeepCopyIndependence(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuyItemInventoryFull(t *testing.T) {
|
||||
s := NewGameSession()
|
||||
s := NewGameSession(testCfg(t))
|
||||
p := entity.NewPlayer("Buyer", entity.ClassWarrior)
|
||||
p.Fingerprint = "fp-buyer"
|
||||
p.Gold = 1000
|
||||
@@ -135,13 +142,13 @@ func TestBuyItemInventoryFull(t *testing.T) {
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.BuyItem("fp-buyer", 0) {
|
||||
t.Error("should not buy when inventory is full")
|
||||
if result := s.BuyItem("fp-buyer", 0); result != BuyInventoryFull {
|
||||
t.Errorf("expected BuyInventoryFull, got %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendChat(t *testing.T) {
|
||||
s := NewGameSession()
|
||||
s := NewGameSession(testCfg(t))
|
||||
s.SendChat("Alice", "hello")
|
||||
st := s.GetState()
|
||||
if len(st.CombatLog) != 1 || st.CombatLog[0] != "[Alice] hello" {
|
||||
|
||||
239
game/turn.go
239
game/turn.go
@@ -10,8 +10,6 @@ import (
|
||||
"github.com/tolelom/catacombs/entity"
|
||||
)
|
||||
|
||||
const TurnTimeout = 5 * time.Second
|
||||
|
||||
func (s *GameSession) RunTurn() {
|
||||
s.mu.Lock()
|
||||
s.state.TurnNum++
|
||||
@@ -27,10 +25,21 @@ func (s *GameSession) RunTurn() {
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// Drain stale actions from previous turn
|
||||
draining:
|
||||
for {
|
||||
select {
|
||||
case <-s.actionCh:
|
||||
default:
|
||||
break draining
|
||||
}
|
||||
}
|
||||
|
||||
// Collect actions with timeout
|
||||
timer := time.NewTimer(TurnTimeout)
|
||||
turnTimeout := time.Duration(s.cfg.Game.TurnTimeoutSec) * time.Second
|
||||
timer := time.NewTimer(turnTimeout)
|
||||
s.mu.Lock()
|
||||
s.state.TurnDeadline = time.Now().Add(TurnTimeout)
|
||||
s.state.TurnDeadline = time.Now().Add(turnTimeout)
|
||||
s.mu.Unlock()
|
||||
collected := 0
|
||||
|
||||
@@ -73,15 +82,40 @@ collecting:
|
||||
}
|
||||
|
||||
func (s *GameSession) resolvePlayerActions() {
|
||||
// Tick status effects
|
||||
// Record frozen players BEFORE ticking effects (freeze expires on tick)
|
||||
frozenPlayers := make(map[string]bool)
|
||||
for _, p := range s.state.Players {
|
||||
if !p.IsOut() && p.HasEffect(entity.StatusFreeze) {
|
||||
frozenPlayers[p.Fingerprint] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Tick status effects with floor theme damage bonus
|
||||
theme := dungeon.GetTheme(s.state.FloorNum)
|
||||
for _, p := range s.state.Players {
|
||||
if !p.IsOut() {
|
||||
// Snapshot effects before tick to compute theme bonus
|
||||
effectsBefore := make([]entity.ActiveEffect, len(p.Effects))
|
||||
copy(effectsBefore, p.Effects)
|
||||
|
||||
msgs := p.TickEffects()
|
||||
for _, msg := range msgs {
|
||||
s.addLog(msg)
|
||||
}
|
||||
|
||||
// Apply theme damage bonus for matching status effects
|
||||
for _, e := range effectsBefore {
|
||||
if e.Value > 0 && (theme.StatusBoost == entity.StatusEffect(-1) || e.Type == theme.StatusBoost) {
|
||||
bonus := int(float64(e.Value) * (theme.DamageMult - 1.0))
|
||||
if bonus > 0 {
|
||||
p.TakeDamage(bonus)
|
||||
s.addLog(fmt.Sprintf(" (%s 테마: +%d 피해)", theme.Name, bonus))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if p.IsDead() {
|
||||
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
|
||||
s.addLog(fmt.Sprintf("☠ %s 쓰러졌습니다!", p.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +135,11 @@ func (s *GameSession) resolvePlayerActions() {
|
||||
if p.IsOut() {
|
||||
continue
|
||||
}
|
||||
// Frozen players skip their action
|
||||
if frozenPlayers[p.Fingerprint] {
|
||||
s.addLog(fmt.Sprintf("%s 동결되어 행동할 수 없습니다!", p.Name))
|
||||
continue
|
||||
}
|
||||
action, ok := s.actions[p.Fingerprint]
|
||||
if !ok {
|
||||
continue
|
||||
@@ -117,7 +156,7 @@ func (s *GameSession) resolvePlayerActions() {
|
||||
intentOwners = append(intentOwners, p.Name)
|
||||
case ActionSkill:
|
||||
if p.SkillUses <= 0 {
|
||||
s.addLog(fmt.Sprintf("%s has no skill uses left!", p.Name))
|
||||
s.addLog(fmt.Sprintf("%s 스킬 사용 횟수가 없습니다!", p.Name))
|
||||
break
|
||||
}
|
||||
p.SkillUses--
|
||||
@@ -129,12 +168,17 @@ func (s *GameSession) resolvePlayerActions() {
|
||||
m.TauntTurns = 2
|
||||
}
|
||||
}
|
||||
s.addLog(fmt.Sprintf("%s used Taunt! Enemies focus on %s for 2 turns", p.Name, p.Name))
|
||||
s.addLog(fmt.Sprintf("%s Taunt 사용! 적들이 2턴간 %s를 집중 공격합니다", p.Name, p.Name))
|
||||
case entity.ClassMage:
|
||||
skillPower := 0
|
||||
if p.Skills != nil {
|
||||
skillPower = p.Skills.GetSkillPower(p.Class)
|
||||
}
|
||||
multiplier := 0.8 + float64(skillPower)/100.0
|
||||
intents = append(intents, combat.AttackIntent{
|
||||
PlayerATK: p.EffectiveATK(),
|
||||
TargetIdx: -1,
|
||||
Multiplier: 0.8,
|
||||
Multiplier: multiplier,
|
||||
IsAoE: true,
|
||||
})
|
||||
intentOwners = append(intentOwners, p.Name)
|
||||
@@ -154,34 +198,45 @@ func (s *GameSession) resolvePlayerActions() {
|
||||
}
|
||||
}
|
||||
}
|
||||
healAmount := 30
|
||||
if p.Skills != nil {
|
||||
healAmount += p.Skills.GetSkillPower(p.Class) / 2
|
||||
}
|
||||
if s.HardMode {
|
||||
healAmount = int(float64(healAmount) * s.cfg.Difficulty.HardModeHealMult)
|
||||
}
|
||||
before := target.HP
|
||||
target.Heal(30)
|
||||
s.addLog(fmt.Sprintf("%s healed %s for %d HP", p.Name, target.Name, target.HP-before))
|
||||
target.Heal(healAmount)
|
||||
s.addLog(fmt.Sprintf("%s이(가) %s에게 HP %d 회복", p.Name, target.Name, target.HP-before))
|
||||
case entity.ClassRogue:
|
||||
currentRoom := s.state.Floor.Rooms[s.state.Floor.CurrentRoom]
|
||||
for _, neighborIdx := range currentRoom.Neighbors {
|
||||
s.state.Floor.Rooms[neighborIdx].Visited = true
|
||||
}
|
||||
s.addLog(fmt.Sprintf("%s scouted nearby rooms!", p.Name))
|
||||
s.addLog(fmt.Sprintf("%s 주변 방을 정찰했습니다!", p.Name))
|
||||
}
|
||||
case ActionItem:
|
||||
found := false
|
||||
for i, item := range p.Inventory {
|
||||
if item.Type == entity.ItemConsumable {
|
||||
before := p.HP
|
||||
p.Heal(item.Bonus)
|
||||
healAmt := item.Bonus
|
||||
if s.HardMode {
|
||||
healAmt = int(float64(healAmt) * s.cfg.Difficulty.HardModeHealMult)
|
||||
}
|
||||
p.Heal(healAmt)
|
||||
p.Inventory = append(p.Inventory[:i], p.Inventory[i+1:]...)
|
||||
s.addLog(fmt.Sprintf("%s used %s, restored %d HP", p.Name, item.Name, p.HP-before))
|
||||
s.addLog(fmt.Sprintf("%s %s 사용, HP %d 회복", p.Name, item.Name, p.HP-before))
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
s.addLog(fmt.Sprintf("%s has no items to use!", p.Name))
|
||||
s.addLog(fmt.Sprintf("%s 사용할 아이템이 없습니다!", p.Name))
|
||||
}
|
||||
case ActionFlee:
|
||||
if combat.AttemptFlee() {
|
||||
s.addLog(fmt.Sprintf("%s fled from battle!", p.Name))
|
||||
if combat.AttemptFlee(s.cfg.Combat.FleeChance) {
|
||||
s.addLog(fmt.Sprintf("%s 전투에서 도주했습니다!", p.Name))
|
||||
s.state.FleeSucceeded = true
|
||||
if s.state.SoloMode {
|
||||
s.state.Phase = PhaseExploring
|
||||
@@ -189,10 +244,10 @@ func (s *GameSession) resolvePlayerActions() {
|
||||
}
|
||||
p.Fled = true
|
||||
} else {
|
||||
s.addLog(fmt.Sprintf("%s failed to flee!", p.Name))
|
||||
s.addLog(fmt.Sprintf("%s 도주에 실패했습니다!", p.Name))
|
||||
}
|
||||
case ActionWait:
|
||||
s.addLog(fmt.Sprintf("%s is defending", p.Name))
|
||||
s.addLog(fmt.Sprintf("%s 방어 중", p.Name))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,30 +262,96 @@ func (s *GameSession) resolvePlayerActions() {
|
||||
if allFled && !s.state.SoloMode {
|
||||
s.state.Phase = PhaseExploring
|
||||
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
|
||||
s.addLog("All players fled!")
|
||||
s.addLog("모든 플레이어가 도주했습니다!")
|
||||
for _, p := range s.state.Players {
|
||||
p.Fled = false
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Build name→player map for relic effects
|
||||
playerByName := make(map[string]*entity.Player)
|
||||
for _, p := range s.state.Players {
|
||||
playerByName[p.Name] = p
|
||||
}
|
||||
|
||||
if len(intents) > 0 && len(s.state.Monsters) > 0 {
|
||||
results := combat.ResolveAttacks(intents, s.state.Monsters)
|
||||
results := combat.ResolveAttacks(intents, s.state.Monsters, s.cfg.Game.CoopBonus)
|
||||
for i, r := range results {
|
||||
owner := intentOwners[i]
|
||||
if r.IsAoE {
|
||||
coopStr := ""
|
||||
if r.CoopApplied {
|
||||
coopStr = " (co-op!)"
|
||||
coopStr = " (협동!)"
|
||||
}
|
||||
s.addLog(fmt.Sprintf("%s hit all enemies for %d total dmg%s", owner, r.Damage, coopStr))
|
||||
s.addLog(fmt.Sprintf("%s 전체 적에게 총 %d 피해%s", owner, r.Damage, coopStr))
|
||||
} else if r.TargetIdx >= 0 && r.TargetIdx < len(s.state.Monsters) {
|
||||
target := s.state.Monsters[r.TargetIdx]
|
||||
coopStr := ""
|
||||
if r.CoopApplied {
|
||||
coopStr = " (co-op!)"
|
||||
coopStr = " (협동!)"
|
||||
}
|
||||
s.addLog(fmt.Sprintf("%s이(가) %s에게 %d 피해%s", owner, target.Name, r.Damage, coopStr))
|
||||
}
|
||||
// Apply Life Siphon relic: heal percentage of damage dealt
|
||||
if r.Damage > 0 {
|
||||
if p := playerByName[owner]; p != nil && !p.IsOut() {
|
||||
for _, rel := range p.Relics {
|
||||
if rel.Effect == entity.RelicLifeSteal {
|
||||
heal := r.Damage * rel.Value / 100
|
||||
if heal > 0 {
|
||||
p.Heal(heal)
|
||||
s.addLog(fmt.Sprintf(" %s의 Life Siphon으로 HP %d 회복", p.Name, heal))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
s.addLog(fmt.Sprintf("%s hit %s for %d dmg%s", owner, target.Name, r.Damage, coopStr))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,13 +369,13 @@ func (s *GameSession) resolvePlayerActions() {
|
||||
}
|
||||
if r.Effect == entity.RelicHealOnKill {
|
||||
p.Heal(r.Value)
|
||||
s.addLog(fmt.Sprintf("%s's relic heals %d HP", p.Name, r.Value))
|
||||
s.addLog(fmt.Sprintf("%s의 유물로 HP %d 회복", p.Name, r.Value))
|
||||
}
|
||||
}
|
||||
p.Gold += goldReward + bonus
|
||||
}
|
||||
}
|
||||
s.addLog(fmt.Sprintf("%s defeated! +%d gold", m.Name, goldReward))
|
||||
s.addLog(fmt.Sprintf("%s 처치! +%d 골드", m.Name, goldReward))
|
||||
if m.IsBoss {
|
||||
s.state.BossKilled = true
|
||||
s.grantBossRelic()
|
||||
@@ -274,7 +395,7 @@ func (s *GameSession) resolvePlayerActions() {
|
||||
// Check if combat is over
|
||||
if len(s.state.Monsters) == 0 {
|
||||
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
|
||||
s.addLog("Room cleared!")
|
||||
s.addLog("방 클리어!")
|
||||
for _, p := range s.state.Players {
|
||||
p.Fled = false
|
||||
}
|
||||
@@ -287,22 +408,34 @@ func (s *GameSession) resolvePlayerActions() {
|
||||
}
|
||||
|
||||
func (s *GameSession) advanceFloor() {
|
||||
if s.state.FloorNum >= 20 {
|
||||
if s.state.FloorNum >= s.cfg.Game.MaxFloors {
|
||||
s.state.Phase = PhaseResult
|
||||
s.state.Victory = true
|
||||
s.state.GameOver = true
|
||||
s.addLog("You conquered the Catacombs!")
|
||||
s.addLog("카타콤을 정복했습니다!")
|
||||
return
|
||||
}
|
||||
// Grant 1 skill point per floor clear
|
||||
for _, p := range s.state.Players {
|
||||
if p.Skills == nil {
|
||||
p.Skills = &entity.PlayerSkills{BranchIndex: -1}
|
||||
}
|
||||
p.Skills.Points++
|
||||
}
|
||||
s.state.FloorNum++
|
||||
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum)
|
||||
if s.DailyMode {
|
||||
seed := DailySeed(s.DailyDate) + int64(s.state.FloorNum)
|
||||
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum, rand.New(rand.NewSource(seed)))
|
||||
} else {
|
||||
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum, rand.New(rand.NewSource(time.Now().UnixNano())))
|
||||
}
|
||||
s.state.Phase = PhaseExploring
|
||||
s.state.CombatTurn = 0
|
||||
s.addLog(fmt.Sprintf("Descending to B%d...", s.state.FloorNum))
|
||||
s.addLog(fmt.Sprintf("B%d층으로 내려갑니다...", s.state.FloorNum))
|
||||
for _, p := range s.state.Players {
|
||||
if p.IsDead() {
|
||||
p.Revive(0.30)
|
||||
s.addLog(fmt.Sprintf("✦ %s revived at %d HP!", p.Name, p.HP))
|
||||
s.addLog(fmt.Sprintf("✦ %s HP %d로 부활!", p.Name, p.HP))
|
||||
}
|
||||
p.Fled = false
|
||||
}
|
||||
@@ -322,7 +455,7 @@ func (s *GameSession) grantBossRelic() {
|
||||
if !p.IsOut() {
|
||||
r := relics[rand.Intn(len(relics))]
|
||||
p.Relics = append(p.Relics, r)
|
||||
s.addLog(fmt.Sprintf("%s obtained relic: %s", p.Name, r.Name))
|
||||
s.addLog(fmt.Sprintf("%s 유물 획득: %s", p.Name, r.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,27 +474,34 @@ func (s *GameSession) resolveMonsterActions() {
|
||||
if !p.IsOut() {
|
||||
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 0.5)
|
||||
p.TakeDamage(dmg)
|
||||
s.addLog(fmt.Sprintf("%s AoE hits %s for %d dmg", m.Name, p.Name, dmg))
|
||||
s.addLog(fmt.Sprintf("%s 광역 공격으로 %s에게 %d 피해", m.Name, p.Name, dmg))
|
||||
if p.IsDead() {
|
||||
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
|
||||
s.addLog(fmt.Sprintf("☠ %s 쓰러졌습니다!", p.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.IsBoss {
|
||||
// Boss special pattern
|
||||
if m.IsBoss || m.IsMiniBoss {
|
||||
// Boss/mini-boss special pattern
|
||||
switch m.Pattern {
|
||||
case entity.PatternPoison:
|
||||
for _, p := range s.state.Players {
|
||||
if !p.IsOut() {
|
||||
p.AddEffect(entity.ActiveEffect{Type: entity.StatusPoison, Duration: 3, Value: 5})
|
||||
s.addLog(fmt.Sprintf("%s poisons %s!", m.Name, p.Name))
|
||||
s.addLog(fmt.Sprintf("%s이(가) %s에게 독을 걸었습니다!", m.Name, p.Name))
|
||||
}
|
||||
}
|
||||
case entity.PatternBurn:
|
||||
for _, p := range s.state.Players {
|
||||
if !p.IsOut() {
|
||||
p.AddEffect(entity.ActiveEffect{Type: entity.StatusBurn, Duration: 2, Value: 8})
|
||||
s.addLog(fmt.Sprintf("%s burns %s!", m.Name, p.Name))
|
||||
s.addLog(fmt.Sprintf("%s이(가) %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이(가) %s을(를) 동결시켰습니다!", m.Name, p.Name))
|
||||
}
|
||||
}
|
||||
case entity.PatternHeal:
|
||||
@@ -370,7 +510,7 @@ func (s *GameSession) resolveMonsterActions() {
|
||||
if m.HP > m.MaxHP {
|
||||
m.HP = m.MaxHP
|
||||
}
|
||||
s.addLog(fmt.Sprintf("%s regenerates %d HP!", m.Name, healAmt))
|
||||
s.addLog(fmt.Sprintf("%s HP %d 재생!", m.Name, healAmt))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -379,9 +519,20 @@ func (s *GameSession) resolveMonsterActions() {
|
||||
if !p.IsOut() {
|
||||
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0)
|
||||
p.TakeDamage(dmg)
|
||||
s.addLog(fmt.Sprintf("%s attacks %s for %d dmg", m.Name, p.Name, dmg))
|
||||
s.addLog(fmt.Sprintf("%s이(가) %s을(를) 공격하여 %d 피해", 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에게 적용!", 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이(가) %s의 생명력을 흡수! (+%d HP)", m.Name, p.Name, heal))
|
||||
}
|
||||
}
|
||||
if p.IsDead() {
|
||||
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
|
||||
s.addLog(fmt.Sprintf("☠ %s 쓰러졌습니다!", p.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,6 +550,6 @@ func (s *GameSession) resolveMonsterActions() {
|
||||
if allPlayersDead {
|
||||
s.state.Phase = PhaseResult
|
||||
s.state.GameOver = true
|
||||
s.addLog("Party wiped!")
|
||||
s.addLog("파티가 전멸했습니다!")
|
||||
}
|
||||
}
|
||||
|
||||
1
go.mod
1
go.mod
@@ -41,4 +41,5 @@ require (
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
1
go.sum
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/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
114
main.go
114
main.go
@@ -1,9 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/tolelom/catacombs/config"
|
||||
"github.com/tolelom/catacombs/game"
|
||||
"github.com/tolelom/catacombs/server"
|
||||
"github.com/tolelom/catacombs/store"
|
||||
@@ -13,23 +20,114 @@ import (
|
||||
func main() {
|
||||
os.MkdirAll("data", 0755)
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
cfg, err := config.Load("config.yaml")
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
cfg, _ = config.Load("")
|
||||
} else {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
db, err := store.Open("data/catacombs.db")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
lobby := game.NewLobby()
|
||||
lobby := game.NewLobby(cfg)
|
||||
startTime := time.Now()
|
||||
|
||||
// Start web terminal server in background
|
||||
sshAddr := fmt.Sprintf("0.0.0.0:%d", cfg.Server.SSHPort)
|
||||
webAddr := fmt.Sprintf(":%d", cfg.Server.HTTPPort)
|
||||
|
||||
// Start web server (non-blocking, returns *http.Server)
|
||||
webServer := web.Start(webAddr, cfg.Server.SSHPort, lobby, db, startTime)
|
||||
|
||||
// Create SSH server
|
||||
sshServer, err := server.NewServer(sshAddr, lobby, db)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create SSH server: %v", err)
|
||||
}
|
||||
|
||||
// Start backup scheduler
|
||||
backupDone := make(chan struct{})
|
||||
go backupScheduler(db, cfg.Backup, backupDone)
|
||||
|
||||
// Start SSH server in background
|
||||
sshErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
if err := web.Start(":8080", 2222); err != nil {
|
||||
log.Printf("Web server error: %v", err)
|
||||
}
|
||||
slog.Info("starting SSH server", "addr", sshAddr)
|
||||
sshErrCh <- sshServer.ListenAndServe()
|
||||
}()
|
||||
|
||||
log.Println("Catacombs server starting — SSH :2222, Web :8080")
|
||||
if err := server.Start("0.0.0.0", 2222, lobby, db); err != nil {
|
||||
log.Fatal(err)
|
||||
slog.Info("server starting", "ssh_port", cfg.Server.SSHPort, "http_port", cfg.Server.HTTPPort)
|
||||
|
||||
// Wait for shutdown signal or SSH server error
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case sig := <-sigCh:
|
||||
slog.Info("shutdown signal received", "signal", sig)
|
||||
case err := <-sshErrCh:
|
||||
if err != nil {
|
||||
slog.Error("SSH server error", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
slog.Info("starting graceful shutdown")
|
||||
|
||||
// Stop backup scheduler
|
||||
close(backupDone)
|
||||
|
||||
// Shutdown web server (5s timeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := webServer.Shutdown(ctx); err != nil {
|
||||
slog.Error("web server shutdown error", "error", err)
|
||||
}
|
||||
|
||||
// Shutdown SSH server (10s timeout for active sessions)
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel2()
|
||||
if err := sshServer.Shutdown(ctx2); err != nil {
|
||||
slog.Error("SSH server shutdown error", "error", err)
|
||||
}
|
||||
|
||||
// Final backup before exit
|
||||
if path, err := db.Backup(cfg.Backup.Dir); err != nil {
|
||||
slog.Error("final backup failed", "error", err)
|
||||
} else {
|
||||
slog.Info("final backup completed", "path", path)
|
||||
}
|
||||
|
||||
slog.Info("server stopped")
|
||||
}
|
||||
|
||||
func backupScheduler(db *store.DB, cfg config.BackupConfig, done chan struct{}) {
|
||||
if cfg.IntervalMin <= 0 {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(time.Duration(cfg.IntervalMin) * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if path, err := db.Backup(cfg.Dir); err != nil {
|
||||
slog.Error("scheduled backup failed", "error", err)
|
||||
} else {
|
||||
slog.Info("scheduled backup completed", "path", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
|
||||
"github.com/charmbracelet/ssh"
|
||||
"github.com/charmbracelet/wish"
|
||||
@@ -14,15 +14,17 @@ import (
|
||||
"github.com/tolelom/catacombs/ui"
|
||||
)
|
||||
|
||||
func Start(host string, port int, lobby *game.Lobby, db *store.DB) error {
|
||||
// NewServer creates the SSH server but does not start it.
|
||||
// The caller is responsible for calling ListenAndServe() and Shutdown().
|
||||
func NewServer(addr string, lobby *game.Lobby, db *store.DB) (*ssh.Server, error) {
|
||||
s, err := wish.NewServer(
|
||||
wish.WithAddress(fmt.Sprintf("%s:%d", host, port)),
|
||||
wish.WithAddress(addr),
|
||||
wish.WithHostKeyPath(".ssh/catacombs_host_key"),
|
||||
wish.WithPublicKeyAuth(func(_ ssh.Context, _ ssh.PublicKey) bool {
|
||||
return true // accept all keys
|
||||
return true
|
||||
}),
|
||||
wish.WithPasswordAuth(func(_ ssh.Context, _ string) bool {
|
||||
return true // accept any password (game server, not secure shell)
|
||||
return true
|
||||
}),
|
||||
wish.WithMiddleware(
|
||||
bubbletea.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
@@ -31,15 +33,31 @@ func Start(host string, port int, lobby *game.Lobby, db *store.DB) error {
|
||||
if s.PublicKey() != nil {
|
||||
fingerprint = gossh.FingerprintSHA256(s.PublicKey())
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("session panic recovered", "error", r, "fingerprint", fingerprint)
|
||||
}
|
||||
}()
|
||||
|
||||
slog.Info("new SSH session", "fingerprint", fingerprint, "width", pty.Window.Width, "height", pty.Window.Height)
|
||||
m := ui.NewModel(pty.Window.Width, pty.Window.Height, fingerprint, lobby, db)
|
||||
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
||||
}),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create server: %w", err)
|
||||
return nil, fmt.Errorf("could not create server: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
log.Printf("Starting SSH server on %s:%d", host, port)
|
||||
// Start creates and starts the SSH server (blocking).
|
||||
func Start(addr string, lobby *game.Lobby, db *store.DB) error {
|
||||
s, err := NewServer(addr, lobby, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("starting SSH server", "addr", addr)
|
||||
return s.ListenAndServe()
|
||||
}
|
||||
|
||||
@@ -14,16 +14,16 @@ type Achievement struct {
|
||||
}
|
||||
|
||||
var AchievementDefs = []Achievement{
|
||||
{ID: "first_clear", Name: "Dungeon Delver", Description: "Clear floor 5 for the first time"},
|
||||
{ID: "boss_slayer", Name: "Boss Slayer", Description: "Defeat any boss"},
|
||||
{ID: "floor10", Name: "Deep Explorer", Description: "Reach floor 10"},
|
||||
{ID: "floor20", Name: "Conqueror", Description: "Conquer the Catacombs (floor 20)"},
|
||||
{ID: "solo_clear", Name: "Lone Wolf", Description: "Clear floor 5 solo"},
|
||||
{ID: "gold_hoarder", Name: "Gold Hoarder", Description: "Accumulate 200+ gold in one run"},
|
||||
{ID: "no_death", Name: "Untouchable", Description: "Complete a floor without anyone dying"},
|
||||
{ID: "full_party", Name: "Fellowship", Description: "Start a game with 4 players"},
|
||||
{ID: "relic_collector", Name: "Relic Collector", Description: "Collect 3+ relics in one run"},
|
||||
{ID: "flee_master", Name: "Tactical Retreat", Description: "Successfully flee from combat"},
|
||||
{ID: "first_clear", Name: "던전 탐험가", Description: "처음으로 5층 클리어"},
|
||||
{ID: "boss_slayer", Name: "보스 슬레이어", Description: "보스 처치"},
|
||||
{ID: "floor10", Name: "심층 탐험가", Description: "10층 도달"},
|
||||
{ID: "floor20", Name: "정복자", Description: "카타콤 정복 (20층)"},
|
||||
{ID: "solo_clear", Name: "외로운 늑대", Description: "솔로로 5층 클리어"},
|
||||
{ID: "gold_hoarder", Name: "골드 수집가", Description: "한 번의 플레이에서 골드 200 이상 모으기"},
|
||||
{ID: "no_death", Name: "무적", Description: "아무도 죽지 않고 층 클리어"},
|
||||
{ID: "full_party", Name: "동료들", Description: "4명으로 게임 시작"},
|
||||
{ID: "relic_collector", Name: "유물 수집가", Description: "한 번의 플레이에서 유물 3개 이상 수집"},
|
||||
{ID: "flee_master", Name: "전략적 후퇴", Description: "전투에서 도주 성공"},
|
||||
}
|
||||
|
||||
func (d *DB) initAchievements() error {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
28
store/db.go
28
store/db.go
@@ -18,10 +18,11 @@ type DB struct {
|
||||
}
|
||||
|
||||
type RunRecord struct {
|
||||
Player string `json:"player"`
|
||||
Floor int `json:"floor"`
|
||||
Score int `json:"score"`
|
||||
Class string `json:"class,omitempty"`
|
||||
Player string `json:"player"`
|
||||
Floor int `json:"floor"`
|
||||
Score int `json:"score"`
|
||||
Class string `json:"class,omitempty"`
|
||||
Members []string `json:"members,omitempty"` // party member names (empty for solo)
|
||||
}
|
||||
|
||||
func Open(path string) (*DB, error) {
|
||||
@@ -39,6 +40,21 @@ func Open(path string) (*DB, error) {
|
||||
if _, err := tx.CreateBucketIfNotExists(bucketAchievements); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.CreateBucketIfNotExists(bucketDailyRuns); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.CreateBucketIfNotExists(bucketUnlocks); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.CreateBucketIfNotExists(bucketTitles); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.CreateBucketIfNotExists(bucketCodex); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.CreateBucketIfNotExists(bucketPasswords); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return &DB{db: db}, err
|
||||
@@ -67,11 +83,11 @@ func (d *DB) GetProfile(fingerprint string) (string, error) {
|
||||
return name, err
|
||||
}
|
||||
|
||||
func (d *DB) SaveRun(player string, floor, score int, class string) error {
|
||||
func (d *DB) SaveRun(player string, floor, score int, class string, members []string) error {
|
||||
return d.db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketRankings)
|
||||
id, _ := b.NextSequence()
|
||||
record := RunRecord{Player: player, Floor: floor, Score: score, Class: class}
|
||||
record := RunRecord{Player: player, Floor: floor, Score: score, Class: class, Members: members}
|
||||
data, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -38,9 +38,9 @@ func TestRanking(t *testing.T) {
|
||||
os.Remove("test_rank.db")
|
||||
}()
|
||||
|
||||
db.SaveRun("Alice", 20, 1500, "Warrior")
|
||||
db.SaveRun("Bob", 15, 1000, "Mage")
|
||||
db.SaveRun("Charlie", 20, 2000, "Rogue")
|
||||
db.SaveRun("Alice", 20, 1500, "Warrior", nil)
|
||||
db.SaveRun("Bob", 15, 1000, "Mage", nil)
|
||||
db.SaveRun("Charlie", 20, 2000, "Rogue", nil)
|
||||
|
||||
rankings, err := db.TopRuns(10)
|
||||
if err != nil {
|
||||
@@ -63,10 +63,10 @@ func TestGetStats(t *testing.T) {
|
||||
defer db.Close()
|
||||
|
||||
// Save some runs
|
||||
db.SaveRun("Alice", 5, 100, "Warrior")
|
||||
db.SaveRun("Alice", 10, 250, "Warrior")
|
||||
db.SaveRun("Alice", 20, 500, "Warrior") // victory (floor >= 20)
|
||||
db.SaveRun("Bob", 3, 50, "")
|
||||
db.SaveRun("Alice", 5, 100, "Warrior", nil)
|
||||
db.SaveRun("Alice", 10, 250, "Warrior", nil)
|
||||
db.SaveRun("Alice", 20, 500, "Warrior", nil) // victory (floor >= 20)
|
||||
db.SaveRun("Bob", 3, 50, "", nil)
|
||||
|
||||
stats, err := db.GetStats("Alice")
|
||||
if err != nil {
|
||||
|
||||
51
store/passwords.go
Normal file
51
store/passwords.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
bolt "go.etcd.io/bbolt"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var bucketPasswords = []byte("passwords")
|
||||
|
||||
// SavePassword stores a bcrypt-hashed password for the given nickname.
|
||||
func (d *DB) SavePassword(nickname, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return d.db.Update(func(tx *bolt.Tx) error {
|
||||
return tx.Bucket(bucketPasswords).Put([]byte(nickname), hash)
|
||||
})
|
||||
}
|
||||
|
||||
// CheckPassword verifies a password against the stored bcrypt hash.
|
||||
func (d *DB) CheckPassword(nickname, password string) (bool, error) {
|
||||
var hash []byte
|
||||
err := d.db.View(func(tx *bolt.Tx) error {
|
||||
v := tx.Bucket(bucketPasswords).Get([]byte(nickname))
|
||||
if v != nil {
|
||||
hash = make([]byte, len(v))
|
||||
copy(hash, v)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if hash == nil {
|
||||
return false, nil
|
||||
}
|
||||
err = bcrypt.CompareHashAndPassword(hash, []byte(password))
|
||||
return err == nil, nil
|
||||
}
|
||||
|
||||
// HasPassword checks whether an account with a password exists for the nickname.
|
||||
func (d *DB) HasPassword(nickname string) bool {
|
||||
found := false
|
||||
d.db.View(func(tx *bolt.Tx) error {
|
||||
v := tx.Bucket(bucketPasswords).Get([]byte(nickname))
|
||||
found = v != nil
|
||||
return nil
|
||||
})
|
||||
return found
|
||||
}
|
||||
52
store/passwords_test.go
Normal file
52
store/passwords_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPasswordRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := Open(dir + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// New account should not have a password.
|
||||
if db.HasPassword("alice") {
|
||||
t.Fatal("expected no password for alice")
|
||||
}
|
||||
|
||||
// Save and check password.
|
||||
if err := db.SavePassword("alice", "secret123"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.HasPassword("alice") {
|
||||
t.Fatal("expected alice to have a password")
|
||||
}
|
||||
|
||||
ok, err := db.CheckPassword("alice", "secret123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected correct password to pass")
|
||||
}
|
||||
|
||||
ok, err = db.CheckPassword("alice", "wrong")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected wrong password to fail")
|
||||
}
|
||||
|
||||
// Non-existent user returns false, no error.
|
||||
ok, err = db.CheckPassword("bob", "anything")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected non-existent user to fail")
|
||||
}
|
||||
}
|
||||
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,12 +3,37 @@ package ui
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/tolelom/catacombs/store"
|
||||
)
|
||||
|
||||
// AchievementsScreen shows the player's achievements.
|
||||
type AchievementsScreen struct{}
|
||||
|
||||
func NewAchievementsScreen() *AchievementsScreen {
|
||||
return &AchievementsScreen{}
|
||||
}
|
||||
|
||||
func (s *AchievementsScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if isKey(key, "a") || isEnter(key) || isQuit(key) {
|
||||
return NewTitleScreen(), nil
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *AchievementsScreen) View(ctx *Context) string {
|
||||
var achievements []store.Achievement
|
||||
if ctx.Store != nil {
|
||||
achievements, _ = ctx.Store.GetAchievements(ctx.PlayerName)
|
||||
}
|
||||
return renderAchievements(ctx.PlayerName, achievements, ctx.Width, ctx.Height)
|
||||
}
|
||||
|
||||
func renderAchievements(playerName string, achievements []store.Achievement, width, height int) string {
|
||||
title := styleHeader.Render("── Achievements ──")
|
||||
title := styleHeader.Render("── 업적 ──")
|
||||
|
||||
var content string
|
||||
unlocked := 0
|
||||
@@ -24,9 +49,9 @@ func renderAchievements(playerName string, achievements []store.Achievement, wid
|
||||
content += styleSystem.Render(" "+a.Description) + "\n"
|
||||
}
|
||||
|
||||
progress := fmt.Sprintf("\n %s", styleGold.Render(fmt.Sprintf("%d/%d Unlocked", unlocked, len(achievements))))
|
||||
progress := fmt.Sprintf("\n %s", styleGold.Render(fmt.Sprintf("%d/%d 해금", unlocked, len(achievements))))
|
||||
|
||||
footer := styleSystem.Render("\n[A] Back")
|
||||
footer := styleSystem.Render("\n[A] 뒤로")
|
||||
|
||||
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
|
||||
lipgloss.JoinVertical(lipgloss.Center, title, "", content, progress, footer))
|
||||
|
||||
@@ -3,10 +3,63 @@ package ui
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/tolelom/catacombs/entity"
|
||||
"github.com/tolelom/catacombs/game"
|
||||
)
|
||||
|
||||
// ClassSelectScreen lets the player choose a class before entering the game.
|
||||
type ClassSelectScreen struct {
|
||||
cursor int
|
||||
}
|
||||
|
||||
func NewClassSelectScreen() *ClassSelectScreen {
|
||||
return &ClassSelectScreen{}
|
||||
}
|
||||
|
||||
func (s *ClassSelectScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if isUp(key) {
|
||||
if s.cursor > 0 {
|
||||
s.cursor--
|
||||
}
|
||||
} else if isDown(key) {
|
||||
if s.cursor < len(classOptions)-1 {
|
||||
s.cursor++
|
||||
}
|
||||
} else if isEnter(key) {
|
||||
if ctx.Lobby != nil {
|
||||
selectedClass := classOptions[s.cursor].class
|
||||
ctx.Lobby.SetPlayerClass(ctx.RoomCode, ctx.Fingerprint, selectedClass.String())
|
||||
room := ctx.Lobby.GetRoom(ctx.RoomCode)
|
||||
if room != nil {
|
||||
if room.Session == nil {
|
||||
room.Session = game.NewGameSession(ctx.Lobby.Cfg())
|
||||
room.Session.HardMode = ctx.HardMode
|
||||
room.Session.ApplyWeeklyMutation()
|
||||
}
|
||||
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)
|
||||
}
|
||||
ws := NewWaitingScreen()
|
||||
return ws, ws.pollWaiting()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *ClassSelectScreen) View(ctx *Context) string {
|
||||
state := classSelectState{cursor: s.cursor}
|
||||
return renderClassSelect(state, ctx.Width, ctx.Height)
|
||||
}
|
||||
|
||||
type classSelectState struct {
|
||||
cursor int
|
||||
}
|
||||
@@ -16,10 +69,10 @@ var classOptions = []struct {
|
||||
name string
|
||||
desc string
|
||||
}{
|
||||
{entity.ClassWarrior, "Warrior", "HP:120 ATK:12 DEF:8 Skill: Taunt (draw enemy fire)"},
|
||||
{entity.ClassMage, "Mage", "HP:70 ATK:20 DEF:3 Skill: Fireball (AoE damage)"},
|
||||
{entity.ClassHealer, "Healer", "HP:90 ATK:8 DEF:5 Skill: Heal (restore 30 HP)"},
|
||||
{entity.ClassRogue, "Rogue", "HP:85 ATK:15 DEF:4 Skill: Scout (reveal rooms)"},
|
||||
{entity.ClassWarrior, "Warrior", "HP:120 ATK:12 DEF:8 스킬: Taunt (적의 공격을 끌어옴)"},
|
||||
{entity.ClassMage, "Mage", "HP:70 ATK:20 DEF:3 스킬: Fireball (광역 피해)"},
|
||||
{entity.ClassHealer, "Healer", "HP:90 ATK:8 DEF:5 스킬: Heal (HP 30 회복)"},
|
||||
{entity.ClassRogue, "Rogue", "HP:85 ATK:15 DEF:4 스킬: Scout (주변 방 탐색)"},
|
||||
}
|
||||
|
||||
func renderClassSelect(state classSelectState, width, height int) string {
|
||||
@@ -37,7 +90,7 @@ func renderClassSelect(state classSelectState, width, height int) string {
|
||||
descStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240"))
|
||||
|
||||
header := headerStyle.Render("── Choose Your Class ──")
|
||||
header := headerStyle.Render("── 직업을 선택하세요 ──")
|
||||
list := ""
|
||||
for i, opt := range classOptions {
|
||||
marker := " "
|
||||
@@ -50,7 +103,7 @@ func renderClassSelect(state classSelectState, width, height int) string {
|
||||
marker, style.Render(opt.name), descStyle.Render(opt.desc))
|
||||
}
|
||||
|
||||
menu := "[Up/Down] Select [Enter] Confirm"
|
||||
menu := "[Up/Down] 선택 [Enter] 확인"
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
header,
|
||||
|
||||
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("-- 도감 --")
|
||||
|
||||
// Tab headers
|
||||
tabNames := []string{"몬스터", "아이템", "이벤트"}
|
||||
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("발견: %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] 탭 전환 [Esc] 뒤로")
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Center,
|
||||
title,
|
||||
"",
|
||||
tabBar,
|
||||
"",
|
||||
completion,
|
||||
"",
|
||||
entries,
|
||||
"",
|
||||
footer,
|
||||
)
|
||||
|
||||
return lipgloss.Place(ctx.Width, ctx.Height, lipgloss.Center, lipgloss.Center, content)
|
||||
}
|
||||
20
ui/context.go
Normal file
20
ui/context.go
Normal file
@@ -0,0 +1,20 @@
|
||||
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
|
||||
HardMode bool
|
||||
}
|
||||
522
ui/game_view.go
522
ui/game_view.go
@@ -5,15 +5,387 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/tolelom/catacombs/dungeon"
|
||||
"github.com/tolelom/catacombs/entity"
|
||||
"github.com/tolelom/catacombs/game"
|
||||
"github.com/tolelom/catacombs/store"
|
||||
)
|
||||
|
||||
func renderGame(state game.GameState, width, height int, targetCursor int, moveCursor int, chatting bool, chatInput string) string {
|
||||
// GameScreen handles the main gameplay: exploration, combat, and chat.
|
||||
type GameScreen struct {
|
||||
gameState game.GameState
|
||||
targetCursor int
|
||||
allyCursor int // for Healer skill targeting allies
|
||||
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) leaveGame(ctx *Context) (Screen, tea.Cmd) {
|
||||
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, ls.pollLobby()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record codex entries for events
|
||||
if ctx.Store != nil && s.gameState.LastEventName != "" {
|
||||
key := "event:" + s.gameState.LastEventName
|
||||
if !s.codexRecorded[key] {
|
||||
ctx.Store.RecordCodexEntry(ctx.Fingerprint, "event", s.gameState.LastEventName)
|
||||
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 := ""
|
||||
var members []string
|
||||
for _, p := range s.gameState.Players {
|
||||
if p.Fingerprint == ctx.Fingerprint {
|
||||
playerClass = p.Class.String()
|
||||
}
|
||||
members = append(members, p.Name)
|
||||
}
|
||||
ctx.Store.SaveRun(ctx.PlayerName, s.gameState.FloorNum, score, playerClass, members)
|
||||
// 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:
|
||||
if isForceQuit(key) {
|
||||
return s, tea.Quit
|
||||
}
|
||||
for _, p := range s.gameState.Players {
|
||||
if p.Fingerprint == ctx.Fingerprint && p.IsDead() {
|
||||
if isKey(key, "q") {
|
||||
return s.leaveGame(ctx)
|
||||
}
|
||||
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()
|
||||
// Block input if this player already voted in multiplayer
|
||||
alreadyVoted := false
|
||||
if !s.gameState.SoloMode && s.gameState.MoveVotes != nil {
|
||||
_, alreadyVoted = s.gameState.MoveVotes[ctx.Fingerprint]
|
||||
}
|
||||
if isUp(key) {
|
||||
if !alreadyVoted && s.moveCursor > 0 {
|
||||
s.moveCursor--
|
||||
}
|
||||
} else if isDown(key) {
|
||||
if !alreadyVoted && s.moveCursor < len(neighbors)-1 {
|
||||
s.moveCursor++
|
||||
}
|
||||
} else if isEnter(key) {
|
||||
if ctx.Session != nil && len(neighbors) > 0 && !alreadyVoted {
|
||||
roomIdx := neighbors[s.moveCursor]
|
||||
if s.gameState.SoloMode {
|
||||
ctx.Session.EnterRoom(roomIdx)
|
||||
} else {
|
||||
ctx.Session.SubmitMoveVote(ctx.Fingerprint, roomIdx)
|
||||
}
|
||||
s.gameState = ctx.Session.GetState()
|
||||
s.moveCursor = 0
|
||||
if s.gameState.Phase == game.PhaseCombat {
|
||||
return s, s.pollState()
|
||||
}
|
||||
}
|
||||
} else if isForceQuit(key) {
|
||||
return s, tea.Quit
|
||||
} else if isKey(key, "q") {
|
||||
return s.leaveGame(ctx)
|
||||
}
|
||||
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 key.Type == tea.KeyShiftTab {
|
||||
// Shift+Tab: cycle ally target (for Healer)
|
||||
if len(s.gameState.Players) > 0 {
|
||||
s.allyCursor = (s.allyCursor + 1) % len(s.gameState.Players)
|
||||
}
|
||||
} else {
|
||||
// Tab: cycle enemy target
|
||||
if len(s.gameState.Monsters) > 0 {
|
||||
s.targetCursor = (s.targetCursor + 1) % len(s.gameState.Monsters)
|
||||
}
|
||||
}
|
||||
return s, s.pollState()
|
||||
}
|
||||
if ctx.Session != nil {
|
||||
// Determine current player's class for skill targeting
|
||||
myClass := entity.ClassWarrior
|
||||
for _, p := range s.gameState.Players {
|
||||
if p.Fingerprint == ctx.Fingerprint {
|
||||
myClass = p.Class
|
||||
break
|
||||
}
|
||||
}
|
||||
switch key.String() {
|
||||
case "1":
|
||||
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionAttack, TargetIdx: s.targetCursor})
|
||||
case "2":
|
||||
skillTarget := s.targetCursor
|
||||
if myClass == entity.ClassHealer {
|
||||
skillTarget = s.allyCursor
|
||||
}
|
||||
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionSkill, TargetIdx: skillTarget})
|
||||
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.allyCursor, s.moveCursor, s.chatting, s.chatInput, ctx.Fingerprint)
|
||||
}
|
||||
|
||||
func renderGame(state game.GameState, width, height int, targetCursor int, allyCursor int, moveCursor int, chatting bool, chatInput string, fingerprint string) string {
|
||||
mapView := renderMap(state.Floor)
|
||||
hudView := renderHUD(state, targetCursor, moveCursor)
|
||||
hudView := renderHUD(state, targetCursor, allyCursor, moveCursor, fingerprint)
|
||||
logView := renderCombatLog(state.CombatLog)
|
||||
|
||||
if chatting {
|
||||
@@ -45,11 +417,11 @@ func renderMap(floor *dungeon.Floor) string {
|
||||
}
|
||||
total := len(floor.Rooms)
|
||||
|
||||
header := headerStyle.Render(fmt.Sprintf("── Catacombs B%d: %s ── %d/%d Rooms ──", floor.Number, theme.Name, explored, total))
|
||||
header := headerStyle.Render(fmt.Sprintf("── 카타콤 B%d: %s ── %d/%d 방 ──", floor.Number, theme.Name, explored, total))
|
||||
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, allyCursor int, moveCursor int, fingerprint string) string {
|
||||
var sb strings.Builder
|
||||
border := lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
@@ -60,23 +432,31 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int) string {
|
||||
hpBar := renderHPBar(p.HP, p.MaxHP, 20)
|
||||
status := ""
|
||||
if p.IsDead() {
|
||||
status = " [DEAD]"
|
||||
status = " [사망]"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s (%s) %s %d/%d%s Gold: %d",
|
||||
sb.WriteString(fmt.Sprintf("%s (%s) %s %d/%d%s 골드: %d",
|
||||
p.Name, p.Class, hpBar, p.HP, p.MaxHP, status, p.Gold))
|
||||
|
||||
// Show inventory count
|
||||
itemCount := len(p.Inventory)
|
||||
relicCount := len(p.Relics)
|
||||
if itemCount > 0 || relicCount > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" Items:%d Relics:%d", itemCount, relicCount))
|
||||
sb.WriteString(fmt.Sprintf(" 아이템:%d 유물:%d", itemCount, relicCount))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if state.Phase == game.PhaseCombat {
|
||||
// Two-panel layout: PARTY | ENEMIES
|
||||
partyContent := renderPartyPanel(state.Players, state.SubmittedActions)
|
||||
// Determine if current player is Healer for ally targeting display
|
||||
isHealer := false
|
||||
for _, p := range state.Players {
|
||||
if p.Fingerprint == fingerprint && p.Class == entity.ClassHealer {
|
||||
isHealer = true
|
||||
break
|
||||
}
|
||||
}
|
||||
partyContent := renderPartyPanel(state.Players, state.SubmittedActions, isHealer, allyCursor)
|
||||
enemyContent := renderEnemyPanel(state.Monsters, targetCursor)
|
||||
|
||||
partyPanel := lipgloss.NewStyle().
|
||||
@@ -98,7 +478,11 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int) string {
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Action bar
|
||||
sb.WriteString(styleAction.Render("[1]Attack [2]Skill [3]Item [4]Flee [5]Wait [Tab]Target [/]Chat"))
|
||||
if isHealer {
|
||||
sb.WriteString(styleAction.Render("[1]공격 [2]스킬 [3]아이템 [4]도주 [5]대기 [Tab]적 [Shift+Tab]아군 [/]채팅"))
|
||||
} else {
|
||||
sb.WriteString(styleAction.Render("[1]공격 [2]스킬 [3]아이템 [4]도주 [5]대기 [Tab]대상 [/]채팅"))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Timer
|
||||
@@ -107,7 +491,7 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int) string {
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
sb.WriteString(styleTimer.Render(fmt.Sprintf(" Timer: %.1fs", remaining.Seconds())))
|
||||
sb.WriteString(styleTimer.Render(fmt.Sprintf(" 타이머: %.1f초", remaining.Seconds())))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
@@ -117,25 +501,59 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int) string {
|
||||
var skillDesc string
|
||||
switch p.Class {
|
||||
case entity.ClassWarrior:
|
||||
skillDesc = "Skill: Taunt — enemies attack you for 2 turns"
|
||||
skillDesc = "스킬: Taunt — 2턴간 적의 공격을 끌어옴"
|
||||
case entity.ClassMage:
|
||||
skillDesc = "Skill: Fireball — AoE 0.8x dmg to all enemies"
|
||||
skillDesc = "스킬: Fireball — 전체 적에게 0.8배 피해"
|
||||
case entity.ClassHealer:
|
||||
skillDesc = "Skill: Heal — restore 30 HP to an ally"
|
||||
skillDesc = "스킬: Heal — 아군 HP 30 회복"
|
||||
case entity.ClassRogue:
|
||||
skillDesc = "Skill: Scout — reveal neighboring rooms"
|
||||
skillDesc = "스킬: Scout — 주변 방 공개"
|
||||
}
|
||||
skillDesc += fmt.Sprintf(" (%d uses left)", p.SkillUses)
|
||||
skillDesc += fmt.Sprintf(" (남은 횟수: %d)", p.SkillUses)
|
||||
sb.WriteString(styleSystem.Render(skillDesc))
|
||||
sb.WriteString("\n")
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if state.Phase == game.PhaseExploring {
|
||||
// Count votes per room for display
|
||||
votesPerRoom := make(map[int]int)
|
||||
if state.MoveVotes != nil {
|
||||
for _, room := range state.MoveVotes {
|
||||
votesPerRoom[room]++
|
||||
}
|
||||
}
|
||||
myVoted := false
|
||||
if !state.SoloMode && state.MoveVotes != nil {
|
||||
_, myVoted = state.MoveVotes[fingerprint]
|
||||
}
|
||||
|
||||
if state.Floor != nil && state.Floor.CurrentRoom >= 0 && state.Floor.CurrentRoom < len(state.Floor.Rooms) {
|
||||
current := state.Floor.Rooms[state.Floor.CurrentRoom]
|
||||
if len(current.Neighbors) > 0 {
|
||||
sb.WriteString("\n")
|
||||
// Show vote status in multiplayer
|
||||
if !state.SoloMode {
|
||||
aliveCount := 0
|
||||
votedCount := 0
|
||||
for _, p := range state.Players {
|
||||
if !p.IsDead() {
|
||||
aliveCount++
|
||||
if state.MoveVotes != nil {
|
||||
if _, ok := state.MoveVotes[p.Fingerprint]; ok {
|
||||
votedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
voteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
|
||||
if myVoted {
|
||||
sb.WriteString(voteStyle.Render(fmt.Sprintf("투표 완료! 대기 중... (%d/%d)", votedCount, aliveCount)))
|
||||
} else {
|
||||
sb.WriteString(voteStyle.Render(fmt.Sprintf("이동할 방을 선택하세요 (%d/%d 투표)", votedCount, aliveCount)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Bold(true)
|
||||
normalStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
|
||||
for i, n := range current.Neighbors {
|
||||
@@ -143,7 +561,7 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int) string {
|
||||
r := state.Floor.Rooms[n]
|
||||
status := r.Type.String()
|
||||
if r.Cleared {
|
||||
status = "Cleared"
|
||||
status = "클리어"
|
||||
}
|
||||
marker := " "
|
||||
style := normalStyle
|
||||
@@ -151,13 +569,47 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int) string {
|
||||
marker = "> "
|
||||
style = selectedStyle
|
||||
}
|
||||
sb.WriteString(style.Render(fmt.Sprintf("%sRoom %d: %s", marker, n, status)))
|
||||
voteInfo := ""
|
||||
if !state.SoloMode {
|
||||
if count, ok := votesPerRoom[n]; ok {
|
||||
voteInfo = fmt.Sprintf(" [%d표]", count)
|
||||
}
|
||||
}
|
||||
sb.WriteString(style.Render(fmt.Sprintf("%s방 %d: %s%s", marker, n, status, voteInfo)))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.WriteString("[Up/Down] Select [Enter] Move [Q] Quit")
|
||||
// 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(" 스킬 포인트 사용 가능! (미사용: %d)", 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 (잠김)\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
|
||||
}
|
||||
}
|
||||
if !state.SoloMode && myVoted {
|
||||
sb.WriteString("[Q] 종료 — 다른 파티원의 투표를 기다리는 중...")
|
||||
} else {
|
||||
sb.WriteString("[Up/Down] 선택 [Enter] 이동 [Q] 종료")
|
||||
}
|
||||
}
|
||||
|
||||
if state.Phase == game.PhaseCombat {
|
||||
@@ -185,19 +637,19 @@ func renderCombatLog(log []string) string {
|
||||
|
||||
func colorizeLog(msg string) string {
|
||||
switch {
|
||||
case strings.Contains(msg, "fled"):
|
||||
case strings.Contains(msg, "도주"):
|
||||
return styleFlee.Render(msg)
|
||||
case strings.Contains(msg, "co-op"):
|
||||
case strings.Contains(msg, "협동"):
|
||||
return styleCoop.Render(msg)
|
||||
case strings.Contains(msg, "healed") || strings.Contains(msg, "Heal") || strings.Contains(msg, "Blessing"):
|
||||
case strings.Contains(msg, "회복") || strings.Contains(msg, "Heal") || strings.Contains(msg, "치유") || strings.Contains(msg, "부활"):
|
||||
return styleHeal.Render(msg)
|
||||
case strings.Contains(msg, "dmg") || strings.Contains(msg, "hit") || strings.Contains(msg, "attacks") || strings.Contains(msg, "Trap"):
|
||||
case strings.Contains(msg, "피해") || strings.Contains(msg, "공격") || strings.Contains(msg, "Trap") || strings.Contains(msg, "함정"):
|
||||
return styleDamage.Render(msg)
|
||||
case strings.Contains(msg, "Taunt") || strings.Contains(msg, "scouted"):
|
||||
case strings.Contains(msg, "Taunt") || strings.Contains(msg, "정찰"):
|
||||
return styleStatus.Render(msg)
|
||||
case strings.Contains(msg, "gold") || strings.Contains(msg, "Gold") || strings.Contains(msg, "found"):
|
||||
case strings.Contains(msg, "골드") || strings.Contains(msg, "Gold") || strings.Contains(msg, "발견"):
|
||||
return styleGold.Render(msg)
|
||||
case strings.Contains(msg, "defeated") || strings.Contains(msg, "cleared") || strings.Contains(msg, "Descending"):
|
||||
case strings.Contains(msg, "처치") || strings.Contains(msg, "클리어") || strings.Contains(msg, "내려갑니다") || strings.Contains(msg, "정복"):
|
||||
return styleSystem.Render(msg)
|
||||
default:
|
||||
return msg
|
||||
@@ -233,16 +685,20 @@ func renderHPBar(current, max, width int) string {
|
||||
emptyStyle.Render(strings.Repeat("░", empty))
|
||||
}
|
||||
|
||||
func renderPartyPanel(players []*entity.Player, submittedActions map[string]string) string {
|
||||
func renderPartyPanel(players []*entity.Player, submittedActions map[string]string, showAllyCursor bool, allyCursor int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(styleHeader.Render(" PARTY") + "\n\n")
|
||||
sb.WriteString(styleHeader.Render(" 아군") + "\n\n")
|
||||
|
||||
for _, p := range players {
|
||||
nameStr := stylePlayer.Render(fmt.Sprintf(" ♦ %s", p.Name))
|
||||
for i, p := range players {
|
||||
marker := " ♦"
|
||||
if showAllyCursor && i == allyCursor {
|
||||
marker = " >♦"
|
||||
}
|
||||
nameStr := stylePlayer.Render(fmt.Sprintf("%s %s", marker, p.Name))
|
||||
classStr := styleSystem.Render(fmt.Sprintf(" (%s)", p.Class))
|
||||
status := ""
|
||||
if p.IsDead() {
|
||||
status = styleDamage.Render(" [DEAD]")
|
||||
status = styleDamage.Render(" [사망]")
|
||||
}
|
||||
sb.WriteString(nameStr + classStr + status + "\n")
|
||||
|
||||
@@ -272,7 +728,7 @@ func renderPartyPanel(players []*entity.Player, submittedActions map[string]stri
|
||||
sb.WriteString(styleHeal.Render(fmt.Sprintf(" ✓ %s", action)))
|
||||
sb.WriteString("\n")
|
||||
} else if !p.IsOut() {
|
||||
sb.WriteString(styleSystem.Render(" ... Waiting"))
|
||||
sb.WriteString(styleSystem.Render(" ... 대기중"))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
@@ -282,7 +738,7 @@ func renderPartyPanel(players []*entity.Player, submittedActions map[string]stri
|
||||
|
||||
func renderEnemyPanel(monsters []*entity.Monster, targetCursor int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(styleHeader.Render(" ENEMIES") + "\n\n")
|
||||
sb.WriteString(styleHeader.Render(" 적") + "\n\n")
|
||||
|
||||
for i, m := range monsters {
|
||||
if m.IsDead() {
|
||||
@@ -302,7 +758,7 @@ func renderEnemyPanel(monsters []*entity.Monster, targetCursor int) string {
|
||||
hpBar := renderHPBar(m.HP, m.MaxHP, 12)
|
||||
taunt := ""
|
||||
if m.TauntTarget {
|
||||
taunt = styleStatus.Render(fmt.Sprintf(" [TAUNTED %dt]", m.TauntTurns))
|
||||
taunt = styleStatus.Render(fmt.Sprintf(" [도발됨 %d턴]", m.TauntTurns))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %s[%d] %s %s %d/%d%s\n\n",
|
||||
marker, i, styleEnemy.Render(m.Name), hpBar, m.HP, m.MaxHP, taunt))
|
||||
|
||||
@@ -1,31 +1,63 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// HelpScreen shows controls and tips.
|
||||
type HelpScreen struct{}
|
||||
|
||||
func NewHelpScreen() *HelpScreen {
|
||||
return &HelpScreen{}
|
||||
}
|
||||
|
||||
func (s *HelpScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if isKey(key, "h") || isEnter(key) || isQuit(key) {
|
||||
return NewTitleScreen(), nil
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *HelpScreen) View(ctx *Context) string {
|
||||
return renderHelp(ctx.Width, ctx.Height)
|
||||
}
|
||||
|
||||
func renderHelp(width, height int) string {
|
||||
title := styleHeader.Render("── Controls ──")
|
||||
title := styleHeader.Render("── 조작법 ──")
|
||||
|
||||
sections := []struct{ header, body string }{
|
||||
{"Exploration", ` [Up/Down] Select room
|
||||
[Enter] Move to room
|
||||
[/] Chat
|
||||
[Q] Quit`},
|
||||
{"Combat", ` [1] Attack [2] Skill
|
||||
[3] Use Item [4] Flee
|
||||
[5] Defend [Tab] Switch Target
|
||||
[/] Chat`},
|
||||
{"Shop", ` [1-3] Buy item
|
||||
[Q] Leave shop`},
|
||||
{"Classes", ` Warrior 120HP 12ATK 8DEF Taunt (draw fire 2t)
|
||||
Mage 70HP 20ATK 3DEF Fireball (AoE 0.8x)
|
||||
Healer 90HP 8ATK 5DEF Heal (restore 30HP)
|
||||
Rogue 85HP 15ATK 4DEF Scout (reveal rooms)`},
|
||||
{"Tips", ` • Skills have 3 uses per combat
|
||||
• Co-op bonus: 10% extra when 2+ attack same target
|
||||
• Items are limited to 10 per player
|
||||
• Dead players revive next floor at 30% HP`},
|
||||
{"로비", ` [C] 방 만들기 [J] 코드로 참가
|
||||
[Enter] 선택한 방 참가
|
||||
[D] 일일 도전 [H] 하드 모드 전환
|
||||
[Q] 타이틀로 돌아가기`},
|
||||
{"탐험", ` [Up/Down] 방 선택
|
||||
[Enter] 방으로 이동
|
||||
[[] / []] 스킬 포인트 배분 (분기 1/2)
|
||||
[/] 채팅
|
||||
[Q] 종료`},
|
||||
{"전투 (턴당 10초)", ` [1] 공격 [2] 스킬
|
||||
[3] 아이템 사용 [4] 도주
|
||||
[5] 방어 [Tab] 대상 변경
|
||||
[/] 채팅`},
|
||||
{"상점", ` [1-3] 아이템 구매
|
||||
[Q] 상점 나가기`},
|
||||
{"직업", ` Warrior 120HP 12ATK 8DEF Taunt (2턴간 적 공격 유도)
|
||||
Mage 70HP 20ATK 3DEF Fireball (광역 0.8배)
|
||||
Healer 90HP 8ATK 5DEF Heal (HP 30 회복)
|
||||
Rogue 85HP 15ATK 4DEF Scout (주변 방 공개)`},
|
||||
{"멀티플레이", ` • 방당 최대 4명
|
||||
• 협동 보너스: 2명 이상이 같은 적 공격 시 피해 +10%
|
||||
• 직업 콤보로 추가 효과 발동
|
||||
• 모든 플레이어 준비 완료 시 게임 시작`},
|
||||
{"팁", ` • 스킬은 전투당 3회 사용 가능
|
||||
• 아이템은 플레이어당 10개 제한
|
||||
• 사망한 플레이어는 다음 층에서 HP 30%로 부활
|
||||
• 보스는 5, 10, 15, 20층에 등장
|
||||
• 스킬 포인트: 층 클리어당 1포인트 (최대 3)
|
||||
• 주간 변이가 게임플레이를 변경`},
|
||||
}
|
||||
|
||||
var content string
|
||||
@@ -37,7 +69,7 @@ func renderHelp(width, height int) string {
|
||||
content += bodyStyle.Render(s.body) + "\n\n"
|
||||
}
|
||||
|
||||
footer := styleSystem.Render("[H] Back")
|
||||
footer := styleSystem.Render("[H] 뒤로")
|
||||
|
||||
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
|
||||
lipgloss.JoinVertical(lipgloss.Center, title, "", content, footer))
|
||||
|
||||
@@ -2,50 +2,120 @@ package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/tolelom/catacombs/store"
|
||||
)
|
||||
|
||||
func renderLeaderboard(byFloor, byGold []store.RunRecord, width, height int) string {
|
||||
title := styleHeader.Render("── Leaderboard ──")
|
||||
// LeaderboardScreen shows the top runs.
|
||||
type LeaderboardScreen struct {
|
||||
tab int // 0=all-time, 1=gold, 2=daily
|
||||
}
|
||||
|
||||
// By Floor
|
||||
var floorSection string
|
||||
floorSection += styleCoop.Render(" Top by Floor") + "\n"
|
||||
for i, r := range byFloor {
|
||||
if i >= 5 {
|
||||
break
|
||||
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
|
||||
}
|
||||
medal := fmt.Sprintf(" %d.", i+1)
|
||||
cls := ""
|
||||
if r.Class != "" {
|
||||
cls = fmt.Sprintf(" [%s]", r.Class)
|
||||
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("── 리더보드 ──")
|
||||
|
||||
// Tab header
|
||||
tabs := []string{"층수", "골드", "일일"}
|
||||
var tabLine string
|
||||
for i, t := range tabs {
|
||||
if i == tab {
|
||||
tabLine += styleHeader.Render(fmt.Sprintf(" [%s] ", t))
|
||||
} else {
|
||||
tabLine += styleSystem.Render(fmt.Sprintf(" %s ", t))
|
||||
}
|
||||
floorSection += fmt.Sprintf(" %s %s%s B%d %s\n",
|
||||
medal, stylePlayer.Render(r.Player), styleSystem.Render(cls),
|
||||
r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score)))
|
||||
}
|
||||
|
||||
// By Gold
|
||||
var goldSection string
|
||||
goldSection += styleCoop.Render("\n Top by Gold") + "\n"
|
||||
for i, r := range byGold {
|
||||
if i >= 5 {
|
||||
break
|
||||
var content string
|
||||
|
||||
switch tab {
|
||||
case 0: // By Floor
|
||||
content += styleCoop.Render(" 층수 순위") + "\n"
|
||||
for i, r := range byFloor {
|
||||
if i >= 10 {
|
||||
break
|
||||
}
|
||||
medal := fmt.Sprintf(" %d.", i+1)
|
||||
cls := ""
|
||||
if r.Class != "" {
|
||||
cls = fmt.Sprintf(" [%s]", r.Class)
|
||||
}
|
||||
party := ""
|
||||
if len(r.Members) > 1 {
|
||||
party = styleSystem.Render(fmt.Sprintf(" (%s)", strings.Join(r.Members, ", ")))
|
||||
}
|
||||
content += fmt.Sprintf(" %s %s%s B%d %s%s\n",
|
||||
medal, stylePlayer.Render(r.Player), styleSystem.Render(cls),
|
||||
r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score)), party)
|
||||
}
|
||||
medal := fmt.Sprintf(" %d.", i+1)
|
||||
cls := ""
|
||||
if r.Class != "" {
|
||||
cls = fmt.Sprintf(" [%s]", r.Class)
|
||||
case 1: // By Gold
|
||||
content += styleCoop.Render(" 골드 순위") + "\n"
|
||||
for i, r := range byGold {
|
||||
if i >= 10 {
|
||||
break
|
||||
}
|
||||
medal := fmt.Sprintf(" %d.", i+1)
|
||||
cls := ""
|
||||
if r.Class != "" {
|
||||
cls = fmt.Sprintf(" [%s]", r.Class)
|
||||
}
|
||||
party := ""
|
||||
if len(r.Members) > 1 {
|
||||
party = styleSystem.Render(fmt.Sprintf(" (%s)", strings.Join(r.Members, ", ")))
|
||||
}
|
||||
content += fmt.Sprintf(" %s %s%s B%d %s%s\n",
|
||||
medal, stylePlayer.Render(r.Player), styleSystem.Render(cls),
|
||||
r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score)), party)
|
||||
}
|
||||
case 2: // Daily
|
||||
content += styleCoop.Render(fmt.Sprintf(" 일일 도전 — %s", time.Now().Format("2006-01-02"))) + "\n"
|
||||
if len(daily) == 0 {
|
||||
content += " 오늘 일일 도전 기록이 없습니다.\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] 탭 전환 [L] 뒤로")
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
197
ui/lobby_view.go
197
ui/lobby_view.go
@@ -3,21 +3,13 @@ package ui
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/tolelom/catacombs/game"
|
||||
)
|
||||
|
||||
type lobbyState struct {
|
||||
rooms []roomInfo
|
||||
input string
|
||||
cursor int
|
||||
creating bool
|
||||
roomName string
|
||||
joining bool
|
||||
codeInput string
|
||||
online int
|
||||
}
|
||||
|
||||
type roomInfo struct {
|
||||
Code string
|
||||
Name string
|
||||
@@ -31,6 +23,174 @@ type playerInfo struct {
|
||||
Ready bool
|
||||
}
|
||||
|
||||
// LobbyScreen shows available rooms and lets players create/join.
|
||||
type LobbyScreen struct {
|
||||
rooms []roomInfo
|
||||
input string
|
||||
cursor int
|
||||
creating bool
|
||||
roomName string
|
||||
joining bool
|
||||
codeInput string
|
||||
online int
|
||||
hardMode bool
|
||||
hardUnlocked bool
|
||||
}
|
||||
|
||||
func NewLobbyScreen() *LobbyScreen {
|
||||
return &LobbyScreen{}
|
||||
}
|
||||
|
||||
func (s *LobbyScreen) pollLobby() tea.Cmd {
|
||||
return tea.Tick(time.Second*2, func(t time.Time) tea.Msg {
|
||||
return tickMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
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 := "대기중"
|
||||
if r.Status == game.RoomPlaying {
|
||||
status = "진행중"
|
||||
}
|
||||
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) {
|
||||
switch msg.(type) {
|
||||
case tickMsg:
|
||||
s.refreshLobby(ctx)
|
||||
return s, s.pollLobby()
|
||||
}
|
||||
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 + "의 방")
|
||||
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 + "의 일일 도전")
|
||||
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")
|
||||
room.Session.ApplyWeeklyMutation()
|
||||
ctx.Session = room.Session
|
||||
}
|
||||
return NewClassSelectScreen(), nil
|
||||
}
|
||||
}
|
||||
} else if isKey(key, "h") && s.hardUnlocked {
|
||||
s.hardMode = !s.hardMode
|
||||
ctx.HardMode = s.hardMode
|
||||
} else if isKey(key, "q") {
|
||||
if ctx.Lobby != nil {
|
||||
ctx.Lobby.PlayerOffline(ctx.Fingerprint)
|
||||
}
|
||||
return NewTitleScreen(), nil
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *LobbyScreen) View(ctx *Context) string {
|
||||
state := lobbyState{
|
||||
rooms: s.rooms,
|
||||
input: s.input,
|
||||
cursor: s.cursor,
|
||||
creating: s.creating,
|
||||
roomName: s.roomName,
|
||||
joining: s.joining,
|
||||
codeInput: s.codeInput,
|
||||
online: s.online,
|
||||
hardMode: s.hardMode,
|
||||
hardUnlocked: s.hardUnlocked,
|
||||
}
|
||||
return renderLobby(state, ctx.Width, ctx.Height)
|
||||
}
|
||||
|
||||
type lobbyState struct {
|
||||
rooms []roomInfo
|
||||
input string
|
||||
cursor int
|
||||
creating bool
|
||||
roomName string
|
||||
joining bool
|
||||
codeInput string
|
||||
online int
|
||||
hardMode bool
|
||||
hardUnlocked bool
|
||||
}
|
||||
|
||||
func renderLobby(state lobbyState, width, height int) string {
|
||||
headerStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("205")).
|
||||
@@ -40,8 +200,15 @@ func renderLobby(state lobbyState, width, height int) string {
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
Padding(0, 1)
|
||||
|
||||
header := headerStyle.Render(fmt.Sprintf("── Lobby ── %d online ──", state.online))
|
||||
menu := "[C] Create Room [J] Join by Code [Up/Down] Select [Enter] Join [Q] Back"
|
||||
header := headerStyle.Render(fmt.Sprintf("── 로비 ── %d명 접속중 ──", state.online))
|
||||
menu := "[C] 방 만들기 [J] 코드로 참가 [D] 일일 도전 [Up/Down] 선택 [Enter] 참가 [Q] 뒤로"
|
||||
if state.hardUnlocked {
|
||||
hardStatus := "OFF"
|
||||
if state.hardMode {
|
||||
hardStatus = "ON"
|
||||
}
|
||||
menu += fmt.Sprintf(" [H] 하드 모드: %s", hardStatus)
|
||||
}
|
||||
|
||||
roomList := ""
|
||||
for i, r := range state.rooms {
|
||||
@@ -67,11 +234,11 @@ func renderLobby(state lobbyState, width, height int) string {
|
||||
}
|
||||
}
|
||||
if roomList == "" {
|
||||
roomList = " No rooms available. Create one!"
|
||||
roomList = " 방이 없습니다. 새로 만드세요!"
|
||||
}
|
||||
if state.joining {
|
||||
inputStr := state.codeInput + strings.Repeat("_", 4-len(state.codeInput))
|
||||
roomList += fmt.Sprintf("\n Enter room code: [%s] (Esc to cancel)\n", inputStr)
|
||||
roomList += fmt.Sprintf("\n 방 코드 입력: [%s] (Esc로 취소)\n", inputStr)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
|
||||
727
ui/model.go
727
ui/model.go
@@ -2,61 +2,17 @@ package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/tolelom/catacombs/entity"
|
||||
"github.com/tolelom/catacombs/game"
|
||||
"github.com/tolelom/catacombs/store"
|
||||
)
|
||||
|
||||
type screen int
|
||||
|
||||
const (
|
||||
screenTitle screen = iota
|
||||
screenLobby
|
||||
screenClassSelect
|
||||
screenGame
|
||||
screenShop
|
||||
screenResult
|
||||
screenHelp
|
||||
screenStats
|
||||
screenAchievements
|
||||
screenLeaderboard
|
||||
screenNickname
|
||||
)
|
||||
|
||||
// StateUpdateMsg is sent by GameSession to update the view
|
||||
type StateUpdateMsg struct {
|
||||
State game.GameState
|
||||
}
|
||||
type tickMsg struct{}
|
||||
|
||||
type Model struct {
|
||||
width int
|
||||
height int
|
||||
fingerprint string
|
||||
playerName string
|
||||
screen screen
|
||||
|
||||
// Shared references (set by server)
|
||||
lobby *game.Lobby
|
||||
store *store.DB
|
||||
|
||||
// Per-session state
|
||||
session *game.GameSession
|
||||
roomCode string
|
||||
gameState game.GameState
|
||||
lobbyState lobbyState
|
||||
classState classSelectState
|
||||
inputBuffer string
|
||||
targetCursor int
|
||||
moveCursor int // selected neighbor index during exploration
|
||||
chatting bool
|
||||
chatInput string
|
||||
rankingSaved bool
|
||||
shopMsg string
|
||||
nicknameInput string
|
||||
currentScreen Screen
|
||||
ctx *Context
|
||||
}
|
||||
|
||||
func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *store.DB) Model {
|
||||
@@ -66,13 +22,26 @@ func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *stor
|
||||
if height == 0 {
|
||||
height = 24
|
||||
}
|
||||
ctx := &Context{
|
||||
Width: width,
|
||||
Height: height,
|
||||
Fingerprint: fingerprint,
|
||||
Lobby: lobby,
|
||||
Store: db,
|
||||
}
|
||||
|
||||
// Determine initial screen
|
||||
var initialScreen Screen
|
||||
if fingerprint != "" && db != nil {
|
||||
if name, err := db.GetProfile(fingerprint); err == nil {
|
||||
ctx.PlayerName = name
|
||||
}
|
||||
}
|
||||
initialScreen = NewTitleScreen()
|
||||
|
||||
return Model{
|
||||
width: width,
|
||||
height: height,
|
||||
fingerprint: fingerprint,
|
||||
screen: screenTitle,
|
||||
lobby: lobby,
|
||||
store: db,
|
||||
currentScreen: initialScreen,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,95 +52,30 @@ func (m Model) Init() tea.Cmd {
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
if m.width == 0 {
|
||||
m.width = 80
|
||||
m.ctx.Width = msg.Width
|
||||
m.ctx.Height = msg.Height
|
||||
if m.ctx.Width == 0 {
|
||||
m.ctx.Width = 80
|
||||
}
|
||||
if m.height == 0 {
|
||||
m.height = 24
|
||||
if m.ctx.Height == 0 {
|
||||
m.ctx.Height = 24
|
||||
}
|
||||
return m, nil
|
||||
case StateUpdateMsg:
|
||||
m.gameState = msg.State
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch m.screen {
|
||||
case screenTitle:
|
||||
return m.updateTitle(msg)
|
||||
case screenLobby:
|
||||
return m.updateLobby(msg)
|
||||
case screenClassSelect:
|
||||
return m.updateClassSelect(msg)
|
||||
case screenGame:
|
||||
return m.updateGame(msg)
|
||||
case screenShop:
|
||||
return m.updateShop(msg)
|
||||
case screenResult:
|
||||
return m.updateResult(msg)
|
||||
case screenHelp:
|
||||
return m.updateHelp(msg)
|
||||
case screenStats:
|
||||
return m.updateStats(msg)
|
||||
case screenAchievements:
|
||||
return m.updateAchievements(msg)
|
||||
case screenLeaderboard:
|
||||
return m.updateLeaderboard(msg)
|
||||
case screenNickname:
|
||||
return m.updateNickname(msg)
|
||||
}
|
||||
return m, nil
|
||||
next, cmd := m.currentScreen.Update(msg, m.ctx)
|
||||
m.currentScreen = next
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.width < 80 || m.height < 24 {
|
||||
return fmt.Sprintf("Terminal too small (%dx%d). Minimum: 80x24.", m.width, m.height)
|
||||
if m.ctx.Width < 80 || m.ctx.Height < 24 {
|
||||
return fmt.Sprintf("Terminal too small (%dx%d). Minimum: 80x24.", m.ctx.Width, m.ctx.Height)
|
||||
}
|
||||
switch m.screen {
|
||||
case screenTitle:
|
||||
return renderTitle(m.width, m.height)
|
||||
case screenLobby:
|
||||
return renderLobby(m.lobbyState, m.width, m.height)
|
||||
case screenClassSelect:
|
||||
return renderClassSelect(m.classState, m.width, m.height)
|
||||
case screenGame:
|
||||
return renderGame(m.gameState, m.width, m.height, m.targetCursor, m.moveCursor, m.chatting, m.chatInput)
|
||||
case screenShop:
|
||||
return renderShop(m.gameState, m.width, m.height, m.shopMsg)
|
||||
case screenResult:
|
||||
var rankings []store.RunRecord
|
||||
if m.store != nil {
|
||||
rankings, _ = m.store.TopRuns(10)
|
||||
}
|
||||
return renderResult(m.gameState, rankings)
|
||||
case screenHelp:
|
||||
return renderHelp(m.width, m.height)
|
||||
case screenStats:
|
||||
var stats store.PlayerStats
|
||||
if m.store != nil {
|
||||
stats, _ = m.store.GetStats(m.playerName)
|
||||
}
|
||||
return renderStats(m.playerName, stats, m.width, m.height)
|
||||
case screenAchievements:
|
||||
var achievements []store.Achievement
|
||||
if m.store != nil {
|
||||
achievements, _ = m.store.GetAchievements(m.playerName)
|
||||
}
|
||||
return renderAchievements(m.playerName, achievements, m.width, m.height)
|
||||
case screenLeaderboard:
|
||||
var byFloor, byGold []store.RunRecord
|
||||
if m.store != nil {
|
||||
byFloor, _ = m.store.TopRuns(10)
|
||||
byGold, _ = m.store.TopRunsByGold(10)
|
||||
}
|
||||
return renderLeaderboard(byFloor, byGold, m.width, m.height)
|
||||
case screenNickname:
|
||||
return renderNickname(m.nicknameInput, m.width, m.height)
|
||||
}
|
||||
return ""
|
||||
return m.currentScreen.View(m.ctx)
|
||||
}
|
||||
|
||||
// Key helper functions used by all screens.
|
||||
func isKey(key tea.KeyMsg, names ...string) bool {
|
||||
s := key.String()
|
||||
for _, n := range names {
|
||||
@@ -190,6 +94,10 @@ func isQuit(key tea.KeyMsg) bool {
|
||||
return isKey(key, "q", "ctrl+c") || key.Type == tea.KeyCtrlC
|
||||
}
|
||||
|
||||
func isForceQuit(key tea.KeyMsg) bool {
|
||||
return isKey(key, "ctrl+c") || key.Type == tea.KeyCtrlC
|
||||
}
|
||||
|
||||
func isUp(key tea.KeyMsg) bool {
|
||||
return isKey(key, "up") || key.Type == tea.KeyUp
|
||||
}
|
||||
@@ -198,515 +106,66 @@ func isDown(key tea.KeyMsg) bool {
|
||||
return isKey(key, "down") || key.Type == tea.KeyDown
|
||||
}
|
||||
|
||||
func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if isEnter(key) {
|
||||
if m.fingerprint == "" {
|
||||
m.fingerprint = fmt.Sprintf("anon-%d", time.Now().UnixNano())
|
||||
}
|
||||
if m.store != nil {
|
||||
name, err := m.store.GetProfile(m.fingerprint)
|
||||
if err != nil {
|
||||
// First time player — show nickname input
|
||||
m.screen = screenNickname
|
||||
m.nicknameInput = ""
|
||||
return m, nil
|
||||
}
|
||||
m.playerName = name
|
||||
} else {
|
||||
m.playerName = "Adventurer"
|
||||
}
|
||||
if m.lobby != nil {
|
||||
m.lobby.PlayerOnline(m.fingerprint, m.playerName)
|
||||
}
|
||||
// Check for active session to reconnect
|
||||
if m.lobby != nil {
|
||||
code, session := m.lobby.GetActiveSession(m.fingerprint)
|
||||
if session != nil {
|
||||
m.roomCode = code
|
||||
m.session = session
|
||||
m.gameState = m.session.GetState()
|
||||
m.screen = screenGame
|
||||
m.session.TouchActivity(m.fingerprint)
|
||||
m.session.SendChat("System", m.playerName+" reconnected!")
|
||||
return m, m.pollState()
|
||||
}
|
||||
}
|
||||
m.screen = screenLobby
|
||||
m = m.withRefreshedLobby()
|
||||
} else if isKey(key, "h") {
|
||||
m.screen = screenHelp
|
||||
} else if isKey(key, "s") {
|
||||
m.screen = screenStats
|
||||
} else if isKey(key, "a") {
|
||||
m.screen = screenAchievements
|
||||
} else if isKey(key, "l") {
|
||||
m.screen = screenLeaderboard
|
||||
} else if isQuit(key) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
// Keep these for backward compatibility with tests
|
||||
// screen enum kept temporarily for test compatibility
|
||||
type screen int
|
||||
|
||||
const (
|
||||
screenTitle screen = iota
|
||||
screenLobby
|
||||
screenClassSelect
|
||||
screenWaiting
|
||||
screenGame
|
||||
screenShop
|
||||
screenResult
|
||||
screenHelp
|
||||
screenStats
|
||||
screenAchievements
|
||||
screenLeaderboard
|
||||
screenNickname
|
||||
)
|
||||
|
||||
// screenType returns the screen enum for the current screen (for test compatibility).
|
||||
func (m Model) screenType() screen {
|
||||
switch m.currentScreen.(type) {
|
||||
case *TitleScreen:
|
||||
return screenTitle
|
||||
case *LobbyScreen:
|
||||
return screenLobby
|
||||
case *ClassSelectScreen:
|
||||
return screenClassSelect
|
||||
case *WaitingScreen:
|
||||
return screenWaiting
|
||||
case *GameScreen:
|
||||
return screenGame
|
||||
case *ShopScreen:
|
||||
return screenShop
|
||||
case *ResultScreen:
|
||||
return screenResult
|
||||
case *HelpScreen:
|
||||
return screenHelp
|
||||
case *StatsScreen:
|
||||
return screenStats
|
||||
case *AchievementsScreen:
|
||||
return screenAchievements
|
||||
case *LeaderboardScreen:
|
||||
return screenLeaderboard
|
||||
case *NicknameScreen:
|
||||
return screenNickname
|
||||
}
|
||||
return m, nil
|
||||
return screenTitle
|
||||
}
|
||||
|
||||
func (m Model) updateNickname(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if isEnter(key) && len(m.nicknameInput) > 0 {
|
||||
m.playerName = m.nicknameInput
|
||||
if m.store != nil && m.fingerprint != "" {
|
||||
m.store.SaveProfile(m.fingerprint, m.playerName)
|
||||
}
|
||||
m.nicknameInput = ""
|
||||
if m.lobby != nil {
|
||||
m.lobby.PlayerOnline(m.fingerprint, m.playerName)
|
||||
}
|
||||
// Check for active session to reconnect
|
||||
if m.lobby != nil {
|
||||
code, session := m.lobby.GetActiveSession(m.fingerprint)
|
||||
if session != nil {
|
||||
m.roomCode = code
|
||||
m.session = session
|
||||
m.gameState = m.session.GetState()
|
||||
m.screen = screenGame
|
||||
m.session.TouchActivity(m.fingerprint)
|
||||
m.session.SendChat("System", m.playerName+" reconnected!")
|
||||
return m, m.pollState()
|
||||
}
|
||||
}
|
||||
m.screen = screenLobby
|
||||
m = m.withRefreshedLobby()
|
||||
} else if isKey(key, "esc") || key.Type == tea.KeyEsc {
|
||||
m.nicknameInput = ""
|
||||
m.screen = screenTitle
|
||||
} else if key.Type == tea.KeyBackspace && len(m.nicknameInput) > 0 {
|
||||
m.nicknameInput = m.nicknameInput[:len(m.nicknameInput)-1]
|
||||
} else if len(key.Runes) == 1 && len(m.nicknameInput) < 12 {
|
||||
ch := string(key.Runes)
|
||||
// Only allow alphanumeric and some special chars
|
||||
if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 {
|
||||
m.nicknameInput += ch
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
// Convenience accessors for test compatibility
|
||||
func (m Model) playerName() string {
|
||||
return m.ctx.PlayerName
|
||||
}
|
||||
|
||||
func (m Model) updateStats(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if isKey(key, "s") || isEnter(key) || isQuit(key) {
|
||||
m.screen = screenTitle
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
func (m Model) roomCode() string {
|
||||
return m.ctx.RoomCode
|
||||
}
|
||||
|
||||
func (m Model) updateAchievements(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if isKey(key, "a") || isEnter(key) || isQuit(key) {
|
||||
m.screen = screenTitle
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
func (m Model) session() *game.GameSession {
|
||||
return m.ctx.Session
|
||||
}
|
||||
|
||||
func (m Model) updateLeaderboard(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if isKey(key, "l") || isEnter(key) || isQuit(key) {
|
||||
m.screen = screenTitle
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateHelp(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if isKey(key, "h") || isEnter(key) || isQuit(key) {
|
||||
m.screen = screenTitle
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateLobby(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
// Join-by-code input mode
|
||||
if m.lobbyState.joining {
|
||||
if isEnter(key) && len(m.lobbyState.codeInput) == 4 {
|
||||
if m.lobby != nil {
|
||||
if err := m.lobby.JoinRoom(m.lobbyState.codeInput, m.playerName, m.fingerprint); err == nil {
|
||||
m.roomCode = m.lobbyState.codeInput
|
||||
m.screen = screenClassSelect
|
||||
}
|
||||
}
|
||||
m.lobbyState.joining = false
|
||||
m.lobbyState.codeInput = ""
|
||||
} else if isKey(key, "esc") || key.Type == tea.KeyEsc {
|
||||
m.lobbyState.joining = false
|
||||
m.lobbyState.codeInput = ""
|
||||
} else if key.Type == tea.KeyBackspace && len(m.lobbyState.codeInput) > 0 {
|
||||
m.lobbyState.codeInput = m.lobbyState.codeInput[:len(m.lobbyState.codeInput)-1]
|
||||
} else if len(key.Runes) == 1 && len(m.lobbyState.codeInput) < 4 {
|
||||
ch := strings.ToUpper(string(key.Runes))
|
||||
m.lobbyState.codeInput += ch
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
// Normal lobby key handling
|
||||
if isKey(key, "c") {
|
||||
if m.lobby != nil {
|
||||
code := m.lobby.CreateRoom(m.playerName + "'s Room")
|
||||
m.lobby.JoinRoom(code, m.playerName, m.fingerprint)
|
||||
m.roomCode = code
|
||||
m.screen = screenClassSelect
|
||||
}
|
||||
} else if isKey(key, "j") {
|
||||
m.lobbyState.joining = true
|
||||
m.lobbyState.codeInput = ""
|
||||
} else if isUp(key) {
|
||||
if m.lobbyState.cursor > 0 {
|
||||
m.lobbyState.cursor--
|
||||
}
|
||||
} else if isDown(key) {
|
||||
if m.lobbyState.cursor < len(m.lobbyState.rooms)-1 {
|
||||
m.lobbyState.cursor++
|
||||
}
|
||||
} else if isEnter(key) {
|
||||
if m.lobby != nil && len(m.lobbyState.rooms) > 0 {
|
||||
r := m.lobbyState.rooms[m.lobbyState.cursor]
|
||||
if err := m.lobby.JoinRoom(r.Code, m.playerName, m.fingerprint); err == nil {
|
||||
m.roomCode = r.Code
|
||||
m.screen = screenClassSelect
|
||||
}
|
||||
}
|
||||
} else if isKey(key, "q") {
|
||||
if m.lobby != nil {
|
||||
m.lobby.PlayerOffline(m.fingerprint)
|
||||
}
|
||||
m.screen = screenTitle
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateClassSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if isUp(key) {
|
||||
if m.classState.cursor > 0 {
|
||||
m.classState.cursor--
|
||||
}
|
||||
} else if isDown(key) {
|
||||
if m.classState.cursor < len(classOptions)-1 {
|
||||
m.classState.cursor++
|
||||
}
|
||||
} else if isEnter(key) {
|
||||
if m.lobby != nil {
|
||||
selectedClass := classOptions[m.classState.cursor].class
|
||||
m.lobby.SetPlayerClass(m.roomCode, m.fingerprint, selectedClass.String())
|
||||
room := m.lobby.GetRoom(m.roomCode)
|
||||
if room != nil {
|
||||
if room.Session == nil {
|
||||
room.Session = game.NewGameSession()
|
||||
}
|
||||
m.session = room.Session
|
||||
player := entity.NewPlayer(m.playerName, selectedClass)
|
||||
player.Fingerprint = m.fingerprint
|
||||
m.session.AddPlayer(player)
|
||||
if m.lobby != nil {
|
||||
m.lobby.RegisterSession(m.fingerprint, m.roomCode)
|
||||
}
|
||||
m.session.StartGame()
|
||||
m.lobby.StartRoom(m.roomCode)
|
||||
m.gameState = m.session.GetState()
|
||||
m.screen = screenGame
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// pollState returns a Cmd that waits briefly then refreshes game state
|
||||
func (m Model) pollState() tea.Cmd {
|
||||
return tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
|
||||
return tickMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
type tickMsg struct{}
|
||||
|
||||
func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.session != nil && m.fingerprint != "" {
|
||||
m.session.TouchActivity(m.fingerprint)
|
||||
}
|
||||
// Refresh state on every update
|
||||
if m.session != nil {
|
||||
m.gameState = m.session.GetState()
|
||||
// Clamp target cursor to valid range after monsters die
|
||||
if len(m.gameState.Monsters) > 0 {
|
||||
if m.targetCursor >= len(m.gameState.Monsters) {
|
||||
m.targetCursor = len(m.gameState.Monsters) - 1
|
||||
}
|
||||
} else {
|
||||
m.targetCursor = 0
|
||||
}
|
||||
}
|
||||
|
||||
if m.gameState.GameOver {
|
||||
if m.store != nil && !m.rankingSaved {
|
||||
score := 0
|
||||
for _, p := range m.gameState.Players {
|
||||
score += p.Gold
|
||||
}
|
||||
// Find the current player's class
|
||||
playerClass := ""
|
||||
for _, p := range m.gameState.Players {
|
||||
if p.Fingerprint == m.fingerprint {
|
||||
playerClass = p.Class.String()
|
||||
break
|
||||
}
|
||||
}
|
||||
m.store.SaveRun(m.playerName, m.gameState.FloorNum, score, playerClass)
|
||||
// Check achievements
|
||||
if m.gameState.FloorNum >= 5 {
|
||||
m.store.UnlockAchievement(m.playerName, "first_clear")
|
||||
}
|
||||
if m.gameState.FloorNum >= 10 {
|
||||
m.store.UnlockAchievement(m.playerName, "floor10")
|
||||
}
|
||||
if m.gameState.Victory {
|
||||
m.store.UnlockAchievement(m.playerName, "floor20")
|
||||
}
|
||||
if m.gameState.SoloMode && m.gameState.FloorNum >= 5 {
|
||||
m.store.UnlockAchievement(m.playerName, "solo_clear")
|
||||
}
|
||||
if m.gameState.BossKilled {
|
||||
m.store.UnlockAchievement(m.playerName, "boss_slayer")
|
||||
}
|
||||
if m.gameState.FleeSucceeded {
|
||||
m.store.UnlockAchievement(m.playerName, "flee_master")
|
||||
}
|
||||
for _, p := range m.gameState.Players {
|
||||
if p.Gold >= 200 {
|
||||
m.store.UnlockAchievement(p.Name, "gold_hoarder")
|
||||
}
|
||||
if len(p.Relics) >= 3 {
|
||||
m.store.UnlockAchievement(p.Name, "relic_collector")
|
||||
}
|
||||
}
|
||||
if len(m.gameState.Players) >= 4 {
|
||||
m.store.UnlockAchievement(m.playerName, "full_party")
|
||||
}
|
||||
m.rankingSaved = true
|
||||
}
|
||||
m.screen = screenResult
|
||||
return m, nil
|
||||
}
|
||||
if m.gameState.Phase == game.PhaseShop {
|
||||
m.screen = screenShop
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg.(type) {
|
||||
case tickMsg:
|
||||
if m.session != nil {
|
||||
m.session.RevealNextLog()
|
||||
}
|
||||
// Keep polling during combat or while there are pending logs to reveal
|
||||
if m.gameState.Phase == game.PhaseCombat {
|
||||
return m, m.pollState()
|
||||
}
|
||||
if len(m.gameState.PendingLogs) > 0 {
|
||||
return m, m.pollState()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
// Chat mode
|
||||
if m.chatting {
|
||||
if isEnter(key) && len(m.chatInput) > 0 {
|
||||
if m.session != nil {
|
||||
m.session.SendChat(m.playerName, m.chatInput)
|
||||
m.gameState = m.session.GetState()
|
||||
}
|
||||
m.chatting = false
|
||||
m.chatInput = ""
|
||||
} else if isKey(key, "esc") || key.Type == tea.KeyEsc {
|
||||
m.chatting = false
|
||||
m.chatInput = ""
|
||||
} else if key.Type == tea.KeyBackspace && len(m.chatInput) > 0 {
|
||||
m.chatInput = m.chatInput[:len(m.chatInput)-1]
|
||||
} else if len(key.Runes) == 1 && len(m.chatInput) < 40 {
|
||||
m.chatInput += string(key.Runes)
|
||||
}
|
||||
if m.gameState.Phase == game.PhaseCombat {
|
||||
return m, m.pollState()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if isKey(key, "/") {
|
||||
m.chatting = true
|
||||
m.chatInput = ""
|
||||
if m.gameState.Phase == game.PhaseCombat {
|
||||
return m, m.pollState()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch m.gameState.Phase {
|
||||
case game.PhaseExploring:
|
||||
// Dead players can only observe, not move
|
||||
for _, p := range m.gameState.Players {
|
||||
if p.Fingerprint == m.fingerprint && p.IsDead() {
|
||||
if isQuit(key) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
neighbors := m.getNeighbors()
|
||||
if isUp(key) {
|
||||
if m.moveCursor > 0 {
|
||||
m.moveCursor--
|
||||
}
|
||||
} else if isDown(key) {
|
||||
if m.moveCursor < len(neighbors)-1 {
|
||||
m.moveCursor++
|
||||
}
|
||||
} else if isEnter(key) {
|
||||
if m.session != nil && len(neighbors) > 0 {
|
||||
roomIdx := neighbors[m.moveCursor]
|
||||
m.session.EnterRoom(roomIdx)
|
||||
m.gameState = m.session.GetState()
|
||||
m.moveCursor = 0
|
||||
if m.gameState.Phase == game.PhaseCombat {
|
||||
return m, m.pollState()
|
||||
}
|
||||
}
|
||||
} else if isQuit(key) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
case game.PhaseCombat:
|
||||
isPlayerDead := false
|
||||
for _, p := range m.gameState.Players {
|
||||
if p.Fingerprint == m.fingerprint && p.IsDead() {
|
||||
isPlayerDead = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isPlayerDead {
|
||||
return m, m.pollState()
|
||||
}
|
||||
if isKey(key, "tab") || key.Type == tea.KeyTab {
|
||||
if len(m.gameState.Monsters) > 0 {
|
||||
m.targetCursor = (m.targetCursor + 1) % len(m.gameState.Monsters)
|
||||
}
|
||||
return m, m.pollState()
|
||||
}
|
||||
if m.session != nil {
|
||||
switch key.String() {
|
||||
case "1":
|
||||
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionAttack, TargetIdx: m.targetCursor})
|
||||
case "2":
|
||||
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionSkill, TargetIdx: m.targetCursor})
|
||||
case "3":
|
||||
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionItem})
|
||||
case "4":
|
||||
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionFlee})
|
||||
case "5":
|
||||
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionWait})
|
||||
}
|
||||
// After submitting, poll for turn resolution
|
||||
return m, m.pollState()
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) getNeighbors() []int {
|
||||
if m.gameState.Floor == nil {
|
||||
return nil
|
||||
}
|
||||
cur := m.gameState.Floor.CurrentRoom
|
||||
if cur < 0 || cur >= len(m.gameState.Floor.Rooms) {
|
||||
return nil
|
||||
}
|
||||
return m.gameState.Floor.Rooms[cur].Neighbors
|
||||
}
|
||||
|
||||
func (m Model) updateShop(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "1", "2", "3":
|
||||
if m.session != nil {
|
||||
idx := int(key.String()[0] - '1')
|
||||
if m.session.BuyItem(m.fingerprint, idx) {
|
||||
m.shopMsg = "Purchased!"
|
||||
} else {
|
||||
m.shopMsg = "Not enough gold!"
|
||||
}
|
||||
m.gameState = m.session.GetState()
|
||||
}
|
||||
case "q":
|
||||
if m.session != nil {
|
||||
m.session.LeaveShop()
|
||||
m.gameState = m.session.GetState()
|
||||
m.screen = screenGame
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateResult(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if isEnter(key) {
|
||||
if m.lobby != nil && m.fingerprint != "" {
|
||||
m.lobby.UnregisterSession(m.fingerprint)
|
||||
}
|
||||
if m.session != nil {
|
||||
m.session.Stop()
|
||||
m.session = nil
|
||||
}
|
||||
if m.lobby != nil && m.roomCode != "" {
|
||||
m.lobby.RemoveRoom(m.roomCode)
|
||||
}
|
||||
m.roomCode = ""
|
||||
m.rankingSaved = false
|
||||
m.screen = screenLobby
|
||||
m = m.withRefreshedLobby()
|
||||
} else if isQuit(key) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) withRefreshedLobby() Model {
|
||||
if m.lobby == nil {
|
||||
return m
|
||||
}
|
||||
rooms := m.lobby.ListRooms()
|
||||
m.lobbyState.rooms = make([]roomInfo, len(rooms))
|
||||
for i, r := range rooms {
|
||||
status := "Waiting"
|
||||
if r.Status == game.RoomPlaying {
|
||||
status = "Playing"
|
||||
}
|
||||
players := make([]playerInfo, len(r.Players))
|
||||
for j, p := range r.Players {
|
||||
players[j] = playerInfo{Name: p.Name, Class: p.Class, Ready: p.Ready}
|
||||
}
|
||||
m.lobbyState.rooms[i] = roomInfo{
|
||||
Code: r.Code,
|
||||
Name: r.Name,
|
||||
Players: players,
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
m.lobbyState.online = len(m.lobby.ListOnline())
|
||||
m.lobbyState.cursor = 0
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/tolelom/catacombs/config"
|
||||
"github.com/tolelom/catacombs/game"
|
||||
"github.com/tolelom/catacombs/store"
|
||||
"os"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T) *store.DB {
|
||||
@@ -18,22 +19,22 @@ func testDB(t *testing.T) *store.DB {
|
||||
}
|
||||
|
||||
func TestTitleToLobby(t *testing.T) {
|
||||
lobby := game.NewLobby()
|
||||
lobby := game.NewLobby(func() *config.Config { c, _ := config.Load(""); return c }())
|
||||
db := testDB(t)
|
||||
defer func() { db.Close(); os.Remove("test_ui.db") }()
|
||||
|
||||
m := NewModel(80, 24, "testfp", lobby, db)
|
||||
|
||||
if m.screen != screenTitle {
|
||||
t.Fatalf("initial screen: got %d, want screenTitle(0)", m.screen)
|
||||
if m.screenType() != screenTitle {
|
||||
t.Fatalf("initial screen: got %d, want screenTitle(0)", m.screenType())
|
||||
}
|
||||
|
||||
// First-time player: Enter goes to nickname screen
|
||||
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m2 := result.(Model)
|
||||
|
||||
if m2.screen != screenNickname {
|
||||
t.Errorf("after Enter (first time): screen=%d, want screenNickname(%d)", m2.screen, screenNickname)
|
||||
if m2.screenType() != screenNickname {
|
||||
t.Errorf("after Enter (first time): screen=%d, want screenNickname(%d)", m2.screenType(), screenNickname)
|
||||
}
|
||||
|
||||
// Type a name
|
||||
@@ -46,16 +47,16 @@ func TestTitleToLobby(t *testing.T) {
|
||||
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m3 := result.(Model)
|
||||
|
||||
if m3.screen != screenLobby {
|
||||
t.Errorf("after nickname Enter: screen=%d, want screenLobby(1)", m3.screen)
|
||||
if m3.screenType() != screenLobby {
|
||||
t.Errorf("after nickname Enter: screen=%d, want screenLobby(1)", m3.screenType())
|
||||
}
|
||||
if m3.playerName == "" {
|
||||
if m3.playerName() == "" {
|
||||
t.Error("playerName should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLobbyCreateRoom(t *testing.T) {
|
||||
lobby := game.NewLobby()
|
||||
lobby := game.NewLobby(func() *config.Config { c, _ := config.Load(""); return c }())
|
||||
db := testDB(t)
|
||||
defer func() { db.Close(); os.Remove("test_ui.db") }()
|
||||
|
||||
@@ -77,16 +78,16 @@ func TestLobbyCreateRoom(t *testing.T) {
|
||||
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
|
||||
m3 := result.(Model)
|
||||
|
||||
if m3.screen != screenClassSelect {
|
||||
t.Errorf("after 'c': screen=%d, want screenClassSelect(2)", m3.screen)
|
||||
if m3.screenType() != screenClassSelect {
|
||||
t.Errorf("after 'c': screen=%d, want screenClassSelect(2)", m3.screenType())
|
||||
}
|
||||
if m3.roomCode == "" {
|
||||
if m3.roomCode() == "" {
|
||||
t.Error("roomCode should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassSelectToGame(t *testing.T) {
|
||||
lobby := game.NewLobby()
|
||||
lobby := game.NewLobby(func() *config.Config { c, _ := config.Load(""); return c }())
|
||||
db := testDB(t)
|
||||
defer func() { db.Close(); os.Remove("test_ui.db") }()
|
||||
|
||||
@@ -106,18 +107,26 @@ func TestClassSelectToGame(t *testing.T) {
|
||||
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
|
||||
m3 := result.(Model)
|
||||
|
||||
if m3.screen != screenClassSelect {
|
||||
t.Fatalf("should be at class select, got %d", m3.screen)
|
||||
if m3.screenType() != screenClassSelect {
|
||||
t.Fatalf("should be at class select, got %d", m3.screenType())
|
||||
}
|
||||
|
||||
// Press Enter to select Warrior (default cursor=0)
|
||||
// Press Enter to select Warrior (default cursor=0) → WaitingScreen
|
||||
result, _ = m3.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m4 := result.(Model)
|
||||
|
||||
if m4.screen != screenGame {
|
||||
t.Errorf("after class select Enter: screen=%d, want screenGame(3)", m4.screen)
|
||||
if m4.screenType() != screenWaiting {
|
||||
t.Fatalf("after class select Enter: screen=%d, want screenWaiting(%d)", m4.screenType(), screenWaiting)
|
||||
}
|
||||
if m4.session == nil {
|
||||
|
||||
// Press Enter to ready up (solo room → immediately starts game)
|
||||
result, _ = m4.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m5 := result.(Model)
|
||||
|
||||
if m5.screenType() != screenGame {
|
||||
t.Errorf("after ready Enter: screen=%d, want screenGame(%d)", m5.screenType(), screenGame)
|
||||
}
|
||||
if m5.session() == nil {
|
||||
t.Error("session should be set")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,241 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func renderNickname(input string, width, height int) string {
|
||||
title := styleHeader.Render("── Enter Your Name ──")
|
||||
// nicknamePhase tracks the current step of the nickname/login screen.
|
||||
type nicknamePhase int
|
||||
|
||||
const (
|
||||
phaseNickname nicknamePhase = iota // entering nickname
|
||||
phasePasswordLogin // existing account — enter password
|
||||
phasePasswordCreate // new account — enter password
|
||||
phasePasswordConfirm // new account — confirm password
|
||||
)
|
||||
|
||||
// NicknameScreen handles player name input and optional web login.
|
||||
type NicknameScreen struct {
|
||||
input string
|
||||
password string
|
||||
confirm string
|
||||
phase nicknamePhase
|
||||
error string
|
||||
}
|
||||
|
||||
func NewNicknameScreen() *NicknameScreen {
|
||||
return &NicknameScreen{}
|
||||
}
|
||||
|
||||
// isWebUser returns true when the player connected via the web bridge
|
||||
// (no real SSH fingerprint).
|
||||
func isWebUser(ctx *Context) bool {
|
||||
return ctx.Fingerprint == "" || strings.HasPrefix(ctx.Fingerprint, "anon-")
|
||||
}
|
||||
|
||||
func (s *NicknameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
|
||||
key, ok := msg.(tea.KeyMsg)
|
||||
if !ok {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Esc always goes back one step or cancels.
|
||||
if isKey(key, "esc") || key.Type == tea.KeyEsc {
|
||||
switch s.phase {
|
||||
case phaseNickname:
|
||||
s.input = ""
|
||||
return NewTitleScreen(), nil
|
||||
case phasePasswordLogin, phasePasswordCreate:
|
||||
s.phase = phaseNickname
|
||||
s.password = ""
|
||||
s.error = ""
|
||||
return s, nil
|
||||
case phasePasswordConfirm:
|
||||
s.phase = phasePasswordCreate
|
||||
s.confirm = ""
|
||||
s.error = ""
|
||||
return s, nil
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
switch s.phase {
|
||||
case phaseNickname:
|
||||
return s.updateNickname(key, ctx)
|
||||
case phasePasswordLogin:
|
||||
return s.updatePasswordLogin(key, ctx)
|
||||
case phasePasswordCreate:
|
||||
return s.updatePasswordCreate(key, ctx)
|
||||
case phasePasswordConfirm:
|
||||
return s.updatePasswordConfirm(key, ctx)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *NicknameScreen) updateNickname(key tea.KeyMsg, ctx *Context) (Screen, tea.Cmd) {
|
||||
if isEnter(key) && len(s.input) > 0 {
|
||||
// SSH users with a real fingerprint skip password entirely.
|
||||
if !isWebUser(ctx) {
|
||||
return s.finishLogin(ctx)
|
||||
}
|
||||
// Web user — need password flow.
|
||||
if ctx.Store != nil && ctx.Store.HasPassword(s.input) {
|
||||
s.phase = phasePasswordLogin
|
||||
s.error = ""
|
||||
} else {
|
||||
s.phase = phasePasswordCreate
|
||||
s.error = ""
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
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) updatePasswordLogin(key tea.KeyMsg, ctx *Context) (Screen, tea.Cmd) {
|
||||
if isEnter(key) {
|
||||
if ctx.Store == nil {
|
||||
return s.finishLogin(ctx)
|
||||
}
|
||||
ok, err := ctx.Store.CheckPassword(s.input, s.password)
|
||||
if err != nil {
|
||||
s.error = "오류가 발생했습니다"
|
||||
slog.Error("password check failed", "error", err)
|
||||
return s, nil
|
||||
}
|
||||
if !ok {
|
||||
s.error = "비밀번호가 틀렸습니다"
|
||||
s.password = ""
|
||||
return s, nil
|
||||
}
|
||||
// Set deterministic fingerprint for web user.
|
||||
ctx.Fingerprint = webFingerprint(s.input)
|
||||
return s.finishLogin(ctx)
|
||||
}
|
||||
s.password = handlePasswordInput(key, s.password)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *NicknameScreen) updatePasswordCreate(key tea.KeyMsg, ctx *Context) (Screen, tea.Cmd) {
|
||||
if isEnter(key) {
|
||||
if len(s.password) < 4 {
|
||||
s.error = "비밀번호는 4자 이상이어야 합니다"
|
||||
return s, nil
|
||||
}
|
||||
s.phase = phasePasswordConfirm
|
||||
s.error = ""
|
||||
return s, nil
|
||||
}
|
||||
s.password = handlePasswordInput(key, s.password)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *NicknameScreen) updatePasswordConfirm(key tea.KeyMsg, ctx *Context) (Screen, tea.Cmd) {
|
||||
if isEnter(key) {
|
||||
if s.confirm != s.password {
|
||||
s.error = "비밀번호가 일치하지 않습니다"
|
||||
s.confirm = ""
|
||||
return s, nil
|
||||
}
|
||||
if ctx.Store != nil {
|
||||
if err := ctx.Store.SavePassword(s.input, s.password); err != nil {
|
||||
s.error = "저장 오류가 발생했습니다"
|
||||
slog.Error("failed to save password", "error", err)
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
ctx.Fingerprint = webFingerprint(s.input)
|
||||
return s.finishLogin(ctx)
|
||||
}
|
||||
s.confirm = handlePasswordInput(key, s.confirm)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// finishLogin sets the player name, saves the profile, and transitions to lobby.
|
||||
func (s *NicknameScreen) finishLogin(ctx *Context) (Screen, tea.Cmd) {
|
||||
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+" 재접속!")
|
||||
return gs, gs.pollState()
|
||||
}
|
||||
}
|
||||
ls := NewLobbyScreen()
|
||||
ls.refreshLobby(ctx)
|
||||
return ls, ls.pollLobby()
|
||||
}
|
||||
|
||||
// webFingerprint produces a deterministic fingerprint for a web user.
|
||||
func webFingerprint(nickname string) string {
|
||||
h := sha256.Sum256([]byte("web:" + nickname))
|
||||
return fmt.Sprintf("SHA256:%x", h)
|
||||
}
|
||||
|
||||
func handlePasswordInput(key tea.KeyMsg, current string) string {
|
||||
if key.Type == tea.KeyBackspace && len(current) > 0 {
|
||||
return current[:len(current)-1]
|
||||
}
|
||||
if len(key.Runes) == 1 && len(current) < 32 {
|
||||
ch := string(key.Runes)
|
||||
if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 {
|
||||
return current + ch
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func (s *NicknameScreen) View(ctx *Context) string {
|
||||
return renderNicknameLogin(s, ctx.Width, ctx.Height)
|
||||
}
|
||||
|
||||
func renderNicknameLogin(s *NicknameScreen, width, height int) string {
|
||||
var sections []string
|
||||
|
||||
switch s.phase {
|
||||
case phaseNickname:
|
||||
sections = renderNicknamePhase(s.input)
|
||||
case phasePasswordLogin:
|
||||
sections = renderPasswordPhase(s.input, s.password, "비밀번호를 입력하세요", s.error)
|
||||
case phasePasswordCreate:
|
||||
sections = renderPasswordPhase(s.input, s.password, "비밀번호를 설정하세요 (4자 이상)", s.error)
|
||||
case phasePasswordConfirm:
|
||||
sections = renderPasswordPhase(s.input, s.confirm, "비밀번호를 다시 입력하세요", s.error)
|
||||
}
|
||||
|
||||
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
|
||||
lipgloss.JoinVertical(lipgloss.Center, sections...))
|
||||
}
|
||||
|
||||
func renderNicknamePhase(input string) []string {
|
||||
title := styleHeader.Render("── 이름을 입력하세요 ──")
|
||||
|
||||
display := input
|
||||
if display == "" {
|
||||
@@ -23,9 +250,39 @@ func renderNickname(input string, width, height int) string {
|
||||
Padding(0, 2).
|
||||
Render(stylePlayer.Render(display))
|
||||
|
||||
hint := styleSystem.Render(fmt.Sprintf("(%d/12 characters)", len(input)))
|
||||
footer := styleAction.Render("[Enter] Confirm [Esc] Cancel")
|
||||
hint := styleSystem.Render(fmt.Sprintf("(%d/12 글자)", len(input)))
|
||||
footer := styleAction.Render("[Enter] 확인 [Esc] 취소")
|
||||
|
||||
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
|
||||
lipgloss.JoinVertical(lipgloss.Center, title, "", inputBox, hint, "", footer))
|
||||
return []string{title, "", inputBox, hint, "", footer}
|
||||
}
|
||||
|
||||
func renderPasswordPhase(nickname, password, prompt, errMsg string) []string {
|
||||
title := styleHeader.Render("── " + prompt + " ──")
|
||||
|
||||
nameDisplay := stylePlayer.Render("이름: " + nickname)
|
||||
|
||||
masked := strings.Repeat("*", len(password))
|
||||
if masked == "" {
|
||||
masked = strings.Repeat("_", 8)
|
||||
} else {
|
||||
masked += "_"
|
||||
}
|
||||
|
||||
inputBox := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorCyan).
|
||||
Padding(0, 2).
|
||||
Render(stylePlayer.Render(masked))
|
||||
|
||||
sections := []string{title, "", nameDisplay, "", inputBox}
|
||||
|
||||
if errMsg != "" {
|
||||
sections = append(sections, "",
|
||||
lipgloss.NewStyle().Foreground(colorRed).Bold(true).Render(errMsg))
|
||||
}
|
||||
|
||||
footer := styleAction.Render("[Enter] 확인 [Esc] 뒤로")
|
||||
sections = append(sections, "", footer)
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
@@ -4,39 +4,82 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/tolelom/catacombs/game"
|
||||
"github.com/tolelom/catacombs/store"
|
||||
)
|
||||
|
||||
// ResultScreen shows the end-of-run summary and rankings.
|
||||
type ResultScreen struct {
|
||||
gameState game.GameState
|
||||
rankingSaved bool
|
||||
}
|
||||
|
||||
func NewResultScreen(state game.GameState, rankingSaved bool) *ResultScreen {
|
||||
return &ResultScreen{gameState: state, rankingSaved: rankingSaved}
|
||||
}
|
||||
|
||||
func (s *ResultScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if isEnter(key) {
|
||||
if ctx.Lobby != nil && ctx.Fingerprint != "" {
|
||||
ctx.Lobby.UnregisterSession(ctx.Fingerprint)
|
||||
}
|
||||
if ctx.Session != nil {
|
||||
ctx.Session.Stop()
|
||||
ctx.Session = nil
|
||||
}
|
||||
if ctx.Lobby != nil && ctx.RoomCode != "" {
|
||||
ctx.Lobby.RemoveRoom(ctx.RoomCode)
|
||||
}
|
||||
ctx.RoomCode = ""
|
||||
ls := NewLobbyScreen()
|
||||
ls.refreshLobby(ctx)
|
||||
return ls, ls.pollLobby()
|
||||
} else if isForceQuit(key) {
|
||||
return s, tea.Quit
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *ResultScreen) View(ctx *Context) string {
|
||||
var rankings []store.RunRecord
|
||||
if ctx.Store != nil {
|
||||
rankings, _ = ctx.Store.TopRuns(10)
|
||||
}
|
||||
return renderResult(s.gameState, rankings)
|
||||
}
|
||||
|
||||
func renderResult(state game.GameState, rankings []store.RunRecord) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Title
|
||||
if state.Victory {
|
||||
sb.WriteString(styleHeal.Render(" ✦ VICTORY ✦ ") + "\n\n")
|
||||
sb.WriteString(styleSystem.Render(" You conquered the Catacombs!") + "\n\n")
|
||||
sb.WriteString(styleHeal.Render(" ✦ 승리 ✦ ") + "\n\n")
|
||||
sb.WriteString(styleSystem.Render(" 카타콤을 정복했습니다!") + "\n\n")
|
||||
} else {
|
||||
sb.WriteString(styleDamage.Render(" ✦ DEFEAT ✦ ") + "\n\n")
|
||||
sb.WriteString(styleSystem.Render(fmt.Sprintf(" Fallen on floor B%d", state.FloorNum)) + "\n\n")
|
||||
sb.WriteString(styleDamage.Render(" ✦ 패배 ✦ ") + "\n\n")
|
||||
sb.WriteString(styleSystem.Render(fmt.Sprintf(" B%d층에서 쓰러졌습니다", state.FloorNum)) + "\n\n")
|
||||
}
|
||||
|
||||
// Player summary
|
||||
sb.WriteString(styleHeader.Render("── Party Summary ──") + "\n\n")
|
||||
sb.WriteString(styleHeader.Render("── 파티 요약 ──") + "\n\n")
|
||||
totalGold := 0
|
||||
for _, p := range state.Players {
|
||||
status := styleHeal.Render("Alive")
|
||||
status := styleHeal.Render("생존")
|
||||
if p.IsDead() {
|
||||
status = styleDamage.Render("Dead")
|
||||
status = styleDamage.Render("사망")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %s (%s) %s Gold: %d Items: %d Relics: %d\n",
|
||||
sb.WriteString(fmt.Sprintf(" %s (%s) %s 골드: %d 아이템: %d 유물: %d\n",
|
||||
stylePlayer.Render(p.Name), p.Class, status, p.Gold, len(p.Inventory), len(p.Relics)))
|
||||
totalGold += p.Gold
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n Total Gold: %s\n", styleGold.Render(fmt.Sprintf("%d", totalGold))))
|
||||
sb.WriteString(fmt.Sprintf("\n 총 골드: %s\n", styleGold.Render(fmt.Sprintf("%d", totalGold))))
|
||||
|
||||
// Rankings
|
||||
if len(rankings) > 0 {
|
||||
sb.WriteString("\n" + styleHeader.Render("── Top Runs ──") + "\n\n")
|
||||
sb.WriteString("\n" + styleHeader.Render("── 최고 기록 ──") + "\n\n")
|
||||
for i, r := range rankings {
|
||||
medal := " "
|
||||
switch i {
|
||||
@@ -47,11 +90,15 @@ func renderResult(state game.GameState, rankings []store.RunRecord) string {
|
||||
case 2:
|
||||
medal = styleGold.Render("🥉")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %s %s Floor B%d Score: %d\n", medal, r.Player, r.Floor, r.Score))
|
||||
party := ""
|
||||
if len(r.Members) > 1 {
|
||||
party = fmt.Sprintf(" (%s)", strings.Join(r.Members, ", "))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %s %s B%d층 점수: %d%s\n", medal, r.Player, r.Floor, r.Score, party))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\n" + styleAction.Render(" [Enter] Return to Lobby") + "\n")
|
||||
sb.WriteString("\n" + styleAction.Render(" [Enter] 로비로 돌아가기") + "\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
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,56 @@ package ui
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/tolelom/catacombs/entity"
|
||||
"github.com/tolelom/catacombs/game"
|
||||
)
|
||||
|
||||
// ShopScreen handles the shop between floors.
|
||||
type ShopScreen struct {
|
||||
gameState game.GameState
|
||||
shopMsg string
|
||||
}
|
||||
|
||||
func NewShopScreen(state game.GameState) *ShopScreen {
|
||||
return &ShopScreen{gameState: state}
|
||||
}
|
||||
|
||||
func (s *ShopScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "1", "2", "3":
|
||||
if ctx.Session != nil {
|
||||
idx := int(key.String()[0] - '1')
|
||||
switch ctx.Session.BuyItem(ctx.Fingerprint, idx) {
|
||||
case game.BuyOK:
|
||||
s.shopMsg = "구매 완료!"
|
||||
case game.BuyNoGold:
|
||||
s.shopMsg = "골드가 부족합니다!"
|
||||
case game.BuyInventoryFull:
|
||||
s.shopMsg = "인벤토리가 가득 찼습니다!"
|
||||
default:
|
||||
s.shopMsg = "구매할 수 없습니다!"
|
||||
}
|
||||
s.gameState = ctx.Session.GetState()
|
||||
}
|
||||
case "q":
|
||||
if ctx.Session != nil {
|
||||
ctx.Session.LeaveShop()
|
||||
gs := NewGameScreen()
|
||||
gs.gameState = ctx.Session.GetState()
|
||||
return gs, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *ShopScreen) View(ctx *Context) string {
|
||||
return renderShop(s.gameState, ctx.Width, ctx.Height, s.shopMsg)
|
||||
}
|
||||
|
||||
func itemTypeLabel(item entity.Item) string {
|
||||
switch item.Type {
|
||||
case entity.ItemWeapon:
|
||||
@@ -31,23 +76,23 @@ func renderShop(state game.GameState, width, height int, shopMsg string) string
|
||||
Foreground(lipgloss.Color("196")).
|
||||
Bold(true)
|
||||
|
||||
header := headerStyle.Render("── Shop ──")
|
||||
header := headerStyle.Render("── 상점 ──")
|
||||
|
||||
// Show current player's gold
|
||||
goldLine := ""
|
||||
for _, p := range state.Players {
|
||||
inventoryCount := len(p.Inventory)
|
||||
goldLine += goldStyle.Render(fmt.Sprintf(" %s — Gold: %d Items: %d/10", p.Name, p.Gold, inventoryCount))
|
||||
goldLine += goldStyle.Render(fmt.Sprintf(" %s — 골드: %d 아이템: %d/10", p.Name, p.Gold, inventoryCount))
|
||||
goldLine += "\n"
|
||||
}
|
||||
|
||||
items := ""
|
||||
for i, item := range state.ShopItems {
|
||||
label := itemTypeLabel(item)
|
||||
items += fmt.Sprintf(" [%d] %s %s — %d gold\n", i+1, item.Name, label, item.Price)
|
||||
items += fmt.Sprintf(" [%d] %s %s — %d 골드\n", i+1, item.Name, label, item.Price)
|
||||
}
|
||||
|
||||
menu := "[1-3] Buy [Q] Leave Shop"
|
||||
menu := "[1-3] 구매 [Q] 상점 나가기"
|
||||
|
||||
parts := []string{header, "", goldLine, items, "", menu}
|
||||
if shopMsg != "" {
|
||||
|
||||
@@ -3,27 +3,52 @@ package ui
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/tolelom/catacombs/store"
|
||||
)
|
||||
|
||||
// StatsScreen shows player statistics.
|
||||
type StatsScreen struct{}
|
||||
|
||||
func NewStatsScreen() *StatsScreen {
|
||||
return &StatsScreen{}
|
||||
}
|
||||
|
||||
func (s *StatsScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if isKey(key, "s") || isEnter(key) || isQuit(key) {
|
||||
return NewTitleScreen(), nil
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *StatsScreen) View(ctx *Context) string {
|
||||
var stats store.PlayerStats
|
||||
if ctx.Store != nil {
|
||||
stats, _ = ctx.Store.GetStats(ctx.PlayerName)
|
||||
}
|
||||
return renderStats(ctx.PlayerName, stats, ctx.Width, ctx.Height)
|
||||
}
|
||||
|
||||
func renderStats(playerName string, stats store.PlayerStats, width, height int) string {
|
||||
title := styleHeader.Render("── Player Statistics ──")
|
||||
title := styleHeader.Render("── 플레이어 통계 ──")
|
||||
|
||||
var content string
|
||||
content += stylePlayer.Render(fmt.Sprintf(" %s", playerName)) + "\n\n"
|
||||
content += fmt.Sprintf(" Total Runs: %s\n", styleGold.Render(fmt.Sprintf("%d", stats.TotalRuns)))
|
||||
content += fmt.Sprintf(" Best Floor: %s\n", styleGold.Render(fmt.Sprintf("B%d", stats.BestFloor)))
|
||||
content += fmt.Sprintf(" Total Gold: %s\n", styleGold.Render(fmt.Sprintf("%d", stats.TotalGold)))
|
||||
content += fmt.Sprintf(" Victories: %s\n", styleHeal.Render(fmt.Sprintf("%d", stats.Victories)))
|
||||
content += fmt.Sprintf(" 총 플레이: %s\n", styleGold.Render(fmt.Sprintf("%d", stats.TotalRuns)))
|
||||
content += fmt.Sprintf(" 최고 층: %s\n", styleGold.Render(fmt.Sprintf("B%d", stats.BestFloor)))
|
||||
content += fmt.Sprintf(" 총 골드: %s\n", styleGold.Render(fmt.Sprintf("%d", stats.TotalGold)))
|
||||
content += fmt.Sprintf(" 승리 횟수: %s\n", styleHeal.Render(fmt.Sprintf("%d", stats.Victories)))
|
||||
|
||||
winRate := 0.0
|
||||
if stats.TotalRuns > 0 {
|
||||
winRate = float64(stats.Victories) / float64(stats.TotalRuns) * 100
|
||||
}
|
||||
content += fmt.Sprintf(" Win Rate: %s\n", styleSystem.Render(fmt.Sprintf("%.1f%%", winRate)))
|
||||
content += fmt.Sprintf(" 승률: %s\n", styleSystem.Render(fmt.Sprintf("%.1f%%", winRate)))
|
||||
|
||||
footer := styleSystem.Render("[S] Back")
|
||||
footer := styleSystem.Render("[S] 뒤로")
|
||||
|
||||
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
|
||||
lipgloss.JoinVertical(lipgloss.Center, title, "", content, "", footer))
|
||||
|
||||
70
ui/title.go
70
ui/title.go
@@ -1,11 +1,77 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// TitleScreen is the main menu screen.
|
||||
type TitleScreen struct{}
|
||||
|
||||
func NewTitleScreen() *TitleScreen {
|
||||
return &TitleScreen{}
|
||||
}
|
||||
|
||||
func (s *TitleScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if isEnter(key) {
|
||||
if ctx.Fingerprint == "" {
|
||||
ctx.Fingerprint = fmt.Sprintf("anon-%d", time.Now().UnixNano())
|
||||
}
|
||||
if ctx.Store != nil {
|
||||
name, err := ctx.Store.GetProfile(ctx.Fingerprint)
|
||||
if err != nil {
|
||||
// First time player — show nickname input
|
||||
return NewNicknameScreen(), nil
|
||||
}
|
||||
ctx.PlayerName = name
|
||||
} else {
|
||||
ctx.PlayerName = "Adventurer"
|
||||
}
|
||||
if ctx.Lobby != nil {
|
||||
ctx.Lobby.PlayerOnline(ctx.Fingerprint, ctx.PlayerName)
|
||||
}
|
||||
// Check for active session to reconnect
|
||||
if ctx.Lobby != nil {
|
||||
code, session := ctx.Lobby.GetActiveSession(ctx.Fingerprint)
|
||||
if session != nil {
|
||||
ctx.RoomCode = code
|
||||
ctx.Session = session
|
||||
gs := NewGameScreen()
|
||||
gs.gameState = ctx.Session.GetState()
|
||||
ctx.Session.TouchActivity(ctx.Fingerprint)
|
||||
ctx.Session.SendChat("System", ctx.PlayerName+" 재접속!")
|
||||
return gs, gs.pollState()
|
||||
}
|
||||
}
|
||||
ls := NewLobbyScreen()
|
||||
ls.refreshLobby(ctx)
|
||||
return ls, ls.pollLobby()
|
||||
} 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{
|
||||
` ██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗`,
|
||||
`██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝`,
|
||||
@@ -35,7 +101,7 @@ func renderTitle(width, height int) string {
|
||||
|
||||
subtitle := lipgloss.NewStyle().
|
||||
Foreground(colorGray).
|
||||
Render("⚔ A Cooperative Dungeon Crawler ⚔")
|
||||
Render("⚔ 협동 던전 크롤러 ⚔")
|
||||
|
||||
server := lipgloss.NewStyle().
|
||||
Foreground(colorCyan).
|
||||
@@ -44,7 +110,7 @@ func renderTitle(width, height int) string {
|
||||
menu := lipgloss.NewStyle().
|
||||
Foreground(colorWhite).
|
||||
Bold(true).
|
||||
Render("[Enter] Start [H] Help [S] Stats [A] Achievements [L] Leaderboard [Q] Quit")
|
||||
Render("[Enter] 시작 [H] 도움말 [S] 통계 [A] 업적 [L] 리더보드 [C] 도감 [Q] 종료")
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Center,
|
||||
logo,
|
||||
|
||||
119
ui/waiting_view.go
Normal file
119
ui/waiting_view.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// WaitingScreen shows room members and lets players ready up before starting.
|
||||
type WaitingScreen struct {
|
||||
ready bool
|
||||
}
|
||||
|
||||
func NewWaitingScreen() *WaitingScreen {
|
||||
return &WaitingScreen{}
|
||||
}
|
||||
|
||||
func (s *WaitingScreen) pollWaiting() tea.Cmd {
|
||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||
return tickMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *WaitingScreen) startGame(ctx *Context) (Screen, tea.Cmd) {
|
||||
room := ctx.Lobby.GetRoom(ctx.RoomCode)
|
||||
if room != nil && room.Session != nil {
|
||||
ctx.Session = room.Session
|
||||
ctx.Session.StartGame()
|
||||
ctx.Lobby.StartRoom(ctx.RoomCode)
|
||||
gs := NewGameScreen()
|
||||
gs.gameState = ctx.Session.GetState()
|
||||
return gs, gs.pollState()
|
||||
}
|
||||
return s, s.pollWaiting()
|
||||
}
|
||||
|
||||
func (s *WaitingScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
|
||||
switch msg.(type) {
|
||||
case tickMsg:
|
||||
// Check if all players are ready → start game
|
||||
if ctx.Lobby != nil && ctx.Lobby.AllReady(ctx.RoomCode) {
|
||||
return s.startGame(ctx)
|
||||
}
|
||||
return s, s.pollWaiting()
|
||||
}
|
||||
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if isEnter(key) && !s.ready {
|
||||
s.ready = true
|
||||
if ctx.Lobby != nil {
|
||||
ctx.Lobby.SetPlayerReady(ctx.RoomCode, ctx.Fingerprint, true)
|
||||
// Solo: if only 1 player in room, start immediately
|
||||
room := ctx.Lobby.GetRoom(ctx.RoomCode)
|
||||
if room != nil && len(room.Players) == 1 {
|
||||
return s.startGame(ctx)
|
||||
}
|
||||
}
|
||||
} else if isKey(key, "esc") || key.Type == tea.KeyEsc {
|
||||
// Leave room — unready and go back to lobby
|
||||
if ctx.Lobby != nil {
|
||||
ctx.Lobby.SetPlayerReady(ctx.RoomCode, ctx.Fingerprint, false)
|
||||
ctx.Lobby.LeaveRoom(ctx.RoomCode, ctx.Fingerprint)
|
||||
}
|
||||
ctx.RoomCode = ""
|
||||
ls := NewLobbyScreen()
|
||||
ls.refreshLobby(ctx)
|
||||
return ls, nil
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *WaitingScreen) View(ctx *Context) string {
|
||||
headerStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("205")).
|
||||
Bold(true)
|
||||
|
||||
readyStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("46"))
|
||||
|
||||
notReadyStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240"))
|
||||
|
||||
header := headerStyle.Render(fmt.Sprintf("── 대기실 [%s] ──", ctx.RoomCode))
|
||||
|
||||
playerList := ""
|
||||
if ctx.Lobby != nil {
|
||||
room := ctx.Lobby.GetRoom(ctx.RoomCode)
|
||||
if room != nil {
|
||||
for _, p := range room.Players {
|
||||
status := notReadyStyle.Render("...")
|
||||
if p.Ready {
|
||||
status = readyStyle.Render("준비 완료")
|
||||
}
|
||||
cls := p.Class
|
||||
if cls == "" {
|
||||
cls = "?"
|
||||
}
|
||||
playerList += fmt.Sprintf(" %s (%s) %s\n", p.Name, cls, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
menu := "[Enter] 준비"
|
||||
if s.ready {
|
||||
menu = "다른 플레이어를 기다리는 중..."
|
||||
}
|
||||
menu += " [Esc] 나가기"
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
header,
|
||||
"",
|
||||
playerList,
|
||||
"",
|
||||
menu,
|
||||
)
|
||||
}
|
||||
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"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"github.com/tolelom/catacombs/game"
|
||||
"github.com/tolelom/catacombs/store"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
@@ -26,8 +29,8 @@ type resizeMsg struct {
|
||||
Rows int `json:"rows"`
|
||||
}
|
||||
|
||||
// Start launches the HTTP server for the web terminal.
|
||||
func Start(addr string, sshPort int) error {
|
||||
// Start launches the HTTP server for the web terminal and returns the server handle.
|
||||
func Start(addr string, sshPort int, lobby *game.Lobby, db *store.DB, startTime time.Time) *http.Server {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Serve static files from embedded FS
|
||||
@@ -38,14 +41,25 @@ func Start(addr string, sshPort int) error {
|
||||
handleWS(w, r, sshPort)
|
||||
})
|
||||
|
||||
log.Printf("Starting web terminal on %s", addr)
|
||||
return http.ListenAndServe(addr, mux)
|
||||
// Admin endpoint
|
||||
mux.Handle("/admin", AdminHandler(lobby, db, startTime))
|
||||
|
||||
srv := &http.Server{Addr: addr, Handler: mux}
|
||||
|
||||
slog.Info("starting web terminal", "addr", addr)
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("web server error", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
func handleWS(w http.ResponseWriter, r *http.Request, sshPort int) {
|
||||
ws, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket upgrade error: %v", err)
|
||||
slog.Error("WebSocket upgrade error", "error", err)
|
||||
return
|
||||
}
|
||||
defer ws.Close()
|
||||
@@ -62,7 +76,7 @@ func handleWS(w http.ResponseWriter, r *http.Request, sshPort int) {
|
||||
sshAddr := fmt.Sprintf("localhost:%d", sshPort)
|
||||
client, err := ssh.Dial("tcp", sshAddr, sshConfig)
|
||||
if err != nil {
|
||||
log.Printf("SSH dial error: %v", err)
|
||||
slog.Error("SSH dial error", "error", err)
|
||||
ws.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("Failed to connect to game server: %v\r\n", err)))
|
||||
return
|
||||
}
|
||||
@@ -70,7 +84,7 @@ func handleWS(w http.ResponseWriter, r *http.Request, sshPort int) {
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
log.Printf("SSH session error: %v", err)
|
||||
slog.Error("SSH session error", "error", err)
|
||||
return
|
||||
}
|
||||
defer session.Close()
|
||||
@@ -81,24 +95,24 @@ func handleWS(w http.ResponseWriter, r *http.Request, sshPort int) {
|
||||
ssh.TTY_OP_ISPEED: 14400,
|
||||
ssh.TTY_OP_OSPEED: 14400,
|
||||
}); err != nil {
|
||||
log.Printf("PTY request error: %v", err)
|
||||
slog.Error("PTY request error", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
log.Printf("stdin pipe error: %v", err)
|
||||
slog.Error("stdin pipe error", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
log.Printf("stdout pipe error: %v", err)
|
||||
slog.Error("stdout pipe error", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := session.Shell(); err != nil {
|
||||
log.Printf("shell error: %v", err)
|
||||
slog.Error("shell error", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -117,7 +131,7 @@ func handleWS(w http.ResponseWriter, r *http.Request, sshPort int) {
|
||||
for {
|
||||
n, err := stdout.Read(buf)
|
||||
if n > 0 {
|
||||
if writeErr := ws.WriteMessage(websocket.TextMessage, buf[:n]); writeErr != nil {
|
||||
if writeErr := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); writeErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,8 +72,9 @@
|
||||
sendResize();
|
||||
};
|
||||
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onmessage = (e) => {
|
||||
term.write(e.data);
|
||||
term.write(new Uint8Array(e.data));
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
|
||||
Reference in New Issue
Block a user