Compare commits

...

50 Commits

Author SHA1 Message Date
523f1bc90c fix: sync multiplayer exploration via vote system and fix combat bugs
- Add room vote system for multiplayer exploration (prevents players
  from independently moving the party to different rooms)
- Fix Healer skill targeting: use ally cursor (Shift+Tab) instead of
  monster cursor, preventing wrong-target or out-of-bounds access
- Prevent duplicate action submissions in the same combat turn
- Drain stale actions from channel between turns
- Block dead players from submitting actions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:45:08 +09:00
d44bba5364 feat: add simple login for web users to persist game data
Web users had no persistent fingerprint, losing codex/achievements/
rankings on reconnect. Now web users enter nickname + password:
- New accounts: set password (min 4 chars, bcrypt hashed)
- Existing accounts: verify password to log in
- On success: deterministic fingerprint SHA256(web:nickname) assigned
- SSH users with real key fingerprints skip password entirely

New files: store/passwords.go, store/passwords_test.go

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:16:20 +09:00
087ce31164 feat: show party members in rankings and result screen
- Add Members field to RunRecord for party member names
- Save all party member names when recording a run
- Display party members in leaderboard (floor/gold tabs)
- Display party members in result screen rankings
- Solo runs show no party info, party runs show "(Alice, Bob, ...)"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 00:03:08 +09:00
f28160d4da feat: localize all UI text to Korean
Translate all user-facing strings to Korean across 25 files:
- UI screens: title, nickname, lobby, class select, waiting, game,
  shop, result, help, leaderboard, achievements, codex, stats
- Game logic: combat logs, events, achievements, mutations, emotes,
  lobby errors, session messages
- Keep English for: class names, monster names, item names, relic names

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:47:27 +09:00
206ac522c5 fix: Q returns to lobby instead of killing SSH session
Pressing Q in game now cleans up the session and returns to lobby,
instead of calling tea.Quit which terminates the SSH connection and
shows "Connection lost" on web. Only Ctrl+C force-quits now.

Also cleans up room on exit so abandoned rooms don't persist in lobby.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:18:36 +09:00
24d9982b15 feat: increase turn timeout to 10s and expand help screen
- Turn timeout 5s → 10s for more comfortable gameplay
- Help screen now covers: lobby, exploration, combat, shop, classes,
  multiplayer tips, and general tips including skill tree controls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:15:12 +09:00
f6419b7984 fix: sort rooms by physical position so map numbers match layout
Rooms were shuffled randomly before assignment, causing room 1 and 2
to be physically far apart on the map despite being logically connected.
Now rooms are sorted top-to-bottom, left-to-right after BSP placement,
so adjacent room numbers are also physically adjacent on the map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:13:20 +09:00
3068fc5550 docs: add deployment instructions for tolelom.xyz server
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:36:22 +09:00
1efb78149c fix: corridor visibility flood-fill so connected rooms appear linked
Long L-shaped corridors had invisible middle sections because
corridorVisible only checked 8 adjacent tiles for room proximity.
Replace with flood-fill from room edges along corridor tiles so the
entire path between connected rooms is visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:35:06 +09:00
08d97b3f89 fix: use binary WebSocket frames for SSH PTY output
SSH PTY output contains non-UTF-8 bytes (terminal escape sequences).
Sending as TextMessage caused WebSocket decode errors. Switch to
BinaryMessage and handle arraybuffer on the client side.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:30:24 +09:00
1563091de1 fix: 13 bugs found via systematic code review and testing
Multiplayer:
- Add WaitingScreen between class select and game start; previously
  selecting a class immediately started the game and locked the room,
  preventing other players from joining
- Add periodic lobby room list refresh (2s interval)
- Add LeaveRoom method for backing out of waiting room

Combat & mechanics:
- Mark invalid attack targets with TargetIdx=-1 to suppress misleading
  "0 dmg" combat log entries
- Make Freeze effect actually skip frozen player's action (was purely
  cosmetic before - expired during tick before action processing)
- Implement Life Siphon relic heal-on-damage effect (was defined but
  never applied in combat)
- Fix combo matching to track used actions and prevent reuse

Game modes:
- Wire up weekly mutations to GameSession via ApplyWeeklyMutation()
- Implement 3 mutation runtime effects: no_shop, glass_cannon, elite_flood
- Pass HardMode toggle from lobby UI through Context to GameSession
- Apply HardMode difficulty multipliers (1.5x monsters, 2x shop, 0.5x heal)

Polish:
- Set starting room (index 0) to always be Empty (safe start)
- Distinguish shop purchase errors: "Not enough gold" vs "Inventory full"
- Record random events in codex for discovery tracking

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:13:10 +09:00
00581880f2 feat: add hard mode and weekly mutation system
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:59:35 +09:00
cf37eef1b1 feat: add codex UI screen with completion tracking
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:39:08 +09:00
caefaff200 feat: add codex system for monster/item/event tracking
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:20:36 +09:00
6c3188e747 feat: add player title system with 7 titles
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:07:02 +09:00
8f899a5afd feat: add unlock system with 3 unlockable contents
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:06:40 +09:00
253d1f80d3 feat: add daily challenge record storage and leaderboard
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:01:58 +09:00
cd0d6c1c4c feat: add daily challenge seed generation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:01:17 +09:00
5ff82120ff docs: add Phase 3 retention systems implementation plan
9 tasks: daily challenge seed/store, unlock system, player titles,
codex system+UI, hard mode/mutations, integration, verification.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:53:56 +09:00
b8697e414a feat: integrate skill tree UI and combat bonuses
Grant skill points on floor clear, add allocation UI with [ ] keys
during exploration, apply SkillPower bonus to Mage Fireball and Healer
Heal, initialize skills for new players, and deep copy skills in
GetState.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

109
CLAUDE.md Normal file
View 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

View File

@@ -31,7 +31,7 @@ type AttackResult struct {
IsAoE bool
}
func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster) []AttackResult {
func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster, coopBonus float64) []AttackResult {
targetCount := make(map[int]int)
targetOrder := make(map[int]int)
for i, intent := range intents {
@@ -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 {

View File

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

93
combat/combo.go Normal file
View 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
View File

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

57
config.yaml Normal file
View File

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

90
config/config.go Normal file
View File

@@ -0,0 +1,90 @@
package config
import (
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `yaml:"server"`
Game GameConfig `yaml:"game"`
Combat CombatConfig `yaml:"combat"`
Dungeon DungeonConfig `yaml:"dungeon"`
Backup BackupConfig `yaml:"backup"`
Difficulty DifficultyConfig `yaml:"difficulty"`
Admin AdminConfig `yaml:"admin"`
}
type AdminConfig struct {
Username string `yaml:"username"`
Password string `yaml:"password"`
}
type DifficultyConfig struct {
HardModeMonsterMult float64 `yaml:"hard_mode_monster_mult"`
HardModeShopMult float64 `yaml:"hard_mode_shop_mult"`
HardModeHealMult float64 `yaml:"hard_mode_heal_mult"`
}
type ServerConfig struct {
SSHPort int `yaml:"ssh_port"`
HTTPPort int `yaml:"http_port"`
}
type GameConfig struct {
TurnTimeoutSec int `yaml:"turn_timeout_sec"`
MaxPlayers int `yaml:"max_players"`
MaxFloors int `yaml:"max_floors"`
CoopBonus float64 `yaml:"coop_bonus"`
InventoryLimit int `yaml:"inventory_limit"`
SkillUses int `yaml:"skill_uses"`
}
type CombatConfig struct {
FleeChance float64 `yaml:"flee_chance"`
MonsterScaling float64 `yaml:"monster_scaling"`
SoloHPReduction float64 `yaml:"solo_hp_reduction"`
}
type DungeonConfig struct {
MapWidth int `yaml:"map_width"`
MapHeight int `yaml:"map_height"`
MinRooms int `yaml:"min_rooms"`
MaxRooms int `yaml:"max_rooms"`
}
type BackupConfig struct {
IntervalMin int `yaml:"interval_min"`
Dir string `yaml:"dir"`
}
func defaults() Config {
return Config{
Server: ServerConfig{SSHPort: 2222, HTTPPort: 8080},
Game: GameConfig{
TurnTimeoutSec: 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
View File

@@ -0,0 +1,94 @@
package config
import (
"os"
"testing"
)
func TestLoadDefaults(t *testing.T) {
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
if cfg.Server.SSHPort != 2222 {
t.Errorf("expected SSH port 2222, got %d", cfg.Server.SSHPort)
}
if cfg.Server.HTTPPort != 8080 {
t.Errorf("expected HTTP port 8080, got %d", cfg.Server.HTTPPort)
}
if cfg.Game.TurnTimeoutSec != 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)
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,786 @@
# Phase 3: Retention Systems Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add retention mechanics (daily challenges, meta progression, difficulty modes) that give players reasons to keep coming back.
**Architecture:** Extend the BoltDB store with 4 new buckets (daily_runs, unlocks, titles, codex). Daily challenges use the Phase 2 seed-based generator with a date-derived seed. Unlock/title/codex systems follow the existing achievements pattern (key-value pairs per player). Hard mode and weekly mutations modify game config at session creation. New UI screens (codex_view) follow the Phase 1 Screen interface pattern.
**Tech Stack:** Go 1.25.1, BoltDB, `math/rand`, `crypto/sha256` for date→seed hashing
**Module path:** `github.com/tolelom/catacombs`
---
## File Structure
### New Files
| File | Responsibility |
|------|---------------|
| `store/daily.go` | DailyRecord struct; SaveDaily, GetDailyLeaderboard, GetStreak methods |
| `store/daily_test.go` | Daily record persistence tests |
| `store/unlocks.go` | Unlock definitions; CheckUnlock, GetUnlocks methods |
| `store/unlocks_test.go` | Unlock condition tests |
| `store/titles.go` | Title definitions; EarnTitle, SetActiveTitle, GetTitles methods |
| `store/titles_test.go` | Title persistence tests |
| `store/codex.go` | Codex struct; RecordMonster/Item/Event, GetCodex methods |
| `store/codex_test.go` | Codex tracking tests |
| `game/daily.go` | DailySeed generation; CreateDailySession helper |
| `game/daily_test.go` | Seed determinism tests |
| `game/mutation.go` | Mutation definitions; GetWeeklyMutation, ApplyMutation |
| `game/mutation_test.go` | Mutation application tests |
| `ui/codex_view.go` | CodexScreen implementing Screen interface |
### Modified Files
| File | Changes |
|------|---------|
| `store/db.go` | Create 4 new buckets in Open(); use cfg.Game.MaxFloors in GetStats victory check |
| `config/config.go` | Add DifficultyConfig (HardMode multipliers) |
| `config.yaml` | Add difficulty section |
| `game/session.go` | Add DailyMode/HardMode/Mutation fields to GameSession; seed-based generation for daily; codex recording hooks |
| `game/event.go` | Record codex entries on monster spawn, treasure, events |
| `game/turn.go` | Record codex on monster kills; apply hard mode multipliers |
| `ui/lobby_view.go` | Add daily challenge button; show hard mode option if unlocked |
| `ui/game_view.go` | Show daily/hard mode indicators in HUD; save daily record on game over |
| `ui/title.go` | Show active title next to player name on title screen |
| `ui/leaderboard_view.go` | Add daily leaderboard tab |
---
## Task 1: Daily Challenge Seed System
**Files:**
- Create: `game/daily.go`
- Create: `game/daily_test.go`
- [ ] **Step 1: Write determinism test**
```go
// game/daily_test.go
package game
import (
"math/rand"
"testing"
"github.com/tolelom/catacombs/dungeon"
)
func TestDailySeed(t *testing.T) {
seed1 := DailySeed("2026-03-25")
seed2 := DailySeed("2026-03-25")
if seed1 != seed2 {
t.Errorf("same date should produce same seed: %d vs %d", seed1, seed2)
}
seed3 := DailySeed("2026-03-26")
if seed1 == seed3 {
t.Error("different dates should produce different seeds")
}
}
func TestDailyFloorDeterminism(t *testing.T) {
seed := DailySeed("2026-03-25")
rng1 := rand.New(rand.NewSource(seed))
rng2 := rand.New(rand.NewSource(seed))
f1 := dungeon.GenerateFloor(1, rng1)
f2 := dungeon.GenerateFloor(1, rng2)
if len(f1.Rooms) != len(f2.Rooms) {
t.Fatal("daily floors should be identical")
}
for i := range f1.Rooms {
if f1.Rooms[i].Type != f2.Rooms[i].Type {
t.Errorf("room %d type differs", i)
}
}
}
```
- [ ] **Step 2: Implement DailySeed**
```go
// game/daily.go
package game
import (
"crypto/sha256"
"encoding/binary"
)
// DailySeed returns a deterministic seed for the given date string (YYYY-MM-DD).
func DailySeed(date string) int64 {
h := sha256.Sum256([]byte("catacombs:" + date))
return int64(binary.BigEndian.Uint64(h[:8]))
}
```
- [ ] **Step 3: Run tests**
Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./game/ -run TestDaily -v`
Expected: PASS
- [ ] **Step 4: Commit**
```bash
git add game/daily.go game/daily_test.go
git commit -m "feat: add daily challenge seed generation"
```
---
## Task 2: Daily Record Store
**Files:**
- Create: `store/daily.go`
- Create: `store/daily_test.go`
- Modify: `store/db.go` (add bucket creation)
- [ ] **Step 1: Write tests**
```go
// store/daily_test.go
package store
import (
"os"
"testing"
)
func TestSaveAndGetDaily(t *testing.T) {
f, _ := os.CreateTemp("", "daily-*.db")
f.Close()
defer os.Remove(f.Name())
db, err := Open(f.Name())
if err != nil {
t.Fatal(err)
}
defer db.Close()
err = db.SaveDaily(DailyRecord{
Date: "2026-03-25", Player: "Alice", PlayerName: "Alice",
FloorReached: 10, GoldEarned: 200,
})
if err != nil {
t.Fatal(err)
}
records, err := db.GetDailyLeaderboard("2026-03-25", 10)
if err != nil {
t.Fatal(err)
}
if len(records) != 1 {
t.Fatalf("expected 1 record, got %d", len(records))
}
if records[0].FloorReached != 10 {
t.Errorf("expected floor 10, got %d", records[0].FloorReached)
}
}
func TestDailyStreak(t *testing.T) {
f, _ := os.CreateTemp("", "streak-*.db")
f.Close()
defer os.Remove(f.Name())
db, _ := Open(f.Name())
defer db.Close()
db.SaveDaily(DailyRecord{Date: "2026-03-23", Player: "fp1", PlayerName: "A", FloorReached: 5, GoldEarned: 50})
db.SaveDaily(DailyRecord{Date: "2026-03-24", Player: "fp1", PlayerName: "A", FloorReached: 8, GoldEarned: 100})
db.SaveDaily(DailyRecord{Date: "2026-03-25", Player: "fp1", PlayerName: "A", FloorReached: 12, GoldEarned: 150})
streak := db.GetStreak("fp1", "2026-03-25")
if streak != 3 {
t.Errorf("expected streak 3, got %d", streak)
}
}
```
- [ ] **Step 2: Implement daily store**
```go
// store/daily.go
package store
import (
"encoding/json"
"fmt"
"sort"
"time"
bolt "go.etcd.io/bbolt"
)
var bucketDailyRuns = []byte("daily_runs")
type DailyRecord struct {
Date string `json:"date"`
Player string `json:"player"` // fingerprint
PlayerName string `json:"player_name"`
FloorReached int `json:"floor_reached"`
GoldEarned int `json:"gold_earned"`
}
func (d *DB) SaveDaily(record DailyRecord) error {
return d.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketDailyRuns)
key := []byte(record.Date + ":" + record.Player)
data, err := json.Marshal(record)
if err != nil {
return err
}
return b.Put(key, data)
})
}
func (d *DB) GetDailyLeaderboard(date string, limit int) ([]DailyRecord, error) {
var records []DailyRecord
prefix := []byte(date + ":")
err := d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketDailyRuns)
c := b.Cursor()
for k, v := c.Seek(prefix); k != nil && len(k) >= len(prefix) && string(k[:len(prefix)]) == string(prefix); k, v = c.Next() {
var r DailyRecord
if json.Unmarshal(v, &r) == nil {
records = append(records, r)
}
}
return nil
})
if err != nil {
return nil, err
}
sort.Slice(records, func(i, j int) bool {
if records[i].FloorReached != records[j].FloorReached {
return records[i].FloorReached > records[j].FloorReached
}
return records[i].GoldEarned > records[j].GoldEarned
})
if len(records) > limit {
records = records[:limit]
}
return records, nil
}
func (d *DB) GetStreak(fingerprint, currentDate string) int {
streak := 0
date, err := time.Parse("2006-01-02", currentDate)
if err != nil {
return 0
}
for i := 0; i < 365; i++ {
checkDate := date.AddDate(0, 0, -i).Format("2006-01-02")
key := []byte(checkDate + ":" + fingerprint)
found := false
d.db.View(func(tx *bolt.Tx) error {
if tx.Bucket(bucketDailyRuns).Get(key) != nil {
found = true
}
return nil
})
if found {
streak++
} else {
break
}
}
return streak
}
```
In `store/db.go` `Open()`, add bucket creation:
```go
if _, err := tx.CreateBucketIfNotExists(bucketDailyRuns); err != nil {
return err
}
```
- [ ] **Step 3: Run tests**
Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./store/ -v`
Expected: ALL PASS
- [ ] **Step 4: Commit**
```bash
git add store/daily.go store/daily_test.go store/db.go
git commit -m "feat: add daily challenge record storage and leaderboard"
```
---
## Task 3: Unlock System
**Files:**
- Create: `store/unlocks.go`
- Create: `store/unlocks_test.go`
- Modify: `store/db.go` (add bucket)
- [ ] **Step 1: Implement unlock system**
Follow the `achievements.go` pattern. Define 3 unlocks:
- `"fifth_class"`: "Clear floor 10+" → unlocks 5th class (future)
- `"hard_mode"`: "Clear with 3+ players" → unlocks hard mode
- `"mutations"`: "Clear floor 20" → unlocks weekly mutations
```go
// store/unlocks.go
package store
import bolt "go.etcd.io/bbolt"
var bucketUnlocks = []byte("unlocks")
type UnlockDef struct {
ID string
Name string
Description string
}
var UnlockDefs = []UnlockDef{
{ID: "fifth_class", Name: "New Recruit", Description: "Clear floor 10 to unlock a new class"},
{ID: "hard_mode", Name: "Hardened", Description: "Complete a run with 3+ players to unlock Hard Mode"},
{ID: "mutations", Name: "Chaos Unleashed", Description: "Conquer floor 20 to unlock Weekly Mutations"},
}
func (d *DB) UnlockContent(fingerprint, unlockID string) (bool, error) {
key := []byte(fingerprint + ":" + unlockID)
alreadyUnlocked := false
err := d.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketUnlocks)
if b.Get(key) != nil {
alreadyUnlocked = true
return nil
}
return b.Put(key, []byte("1"))
})
return !alreadyUnlocked, err
}
func (d *DB) IsUnlocked(fingerprint, unlockID string) bool {
key := []byte(fingerprint + ":" + unlockID)
found := false
d.db.View(func(tx *bolt.Tx) error {
if tx.Bucket(bucketUnlocks).Get(key) != nil {
found = true
}
return nil
})
return found
}
func (d *DB) GetUnlocks(fingerprint string) []UnlockDef {
result := make([]UnlockDef, len(UnlockDefs))
copy(result, UnlockDefs)
// Mark unlocked ones (reuse the struct, no Unlocked field — caller checks IsUnlocked)
return result
}
```
Add `bucketUnlocks` creation in `store/db.go` `Open()`.
- [ ] **Step 2: Write tests and verify**
Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./store/ -v`
- [ ] **Step 3: Commit**
```bash
git add store/unlocks.go store/unlocks_test.go store/db.go
git commit -m "feat: add unlock system with 3 unlockable contents"
```
---
## Task 4: Player Titles
**Files:**
- Create: `store/titles.go`
- Create: `store/titles_test.go`
- Modify: `store/db.go` (add bucket)
- [ ] **Step 1: Implement titles**
```go
// store/titles.go
package store
import (
"encoding/json"
bolt "go.etcd.io/bbolt"
)
var bucketTitles = []byte("titles")
type TitleDef struct {
ID string
Name string
}
var TitleDefs = []TitleDef{
{ID: "novice", Name: "Novice"},
{ID: "explorer", Name: "Explorer"}, // reach floor 5
{ID: "veteran", Name: "Veteran"}, // reach floor 10
{ID: "champion", Name: "Champion"}, // reach floor 20
{ID: "gold_king", Name: "Gold King"}, // earn 500+ gold in one run
{ID: "team_player", Name: "Team Player"}, // complete 5 multiplayer runs
{ID: "survivor", Name: "Survivor"}, // complete a run without dying
}
type PlayerTitleData struct {
ActiveTitle string `json:"active_title"`
Earned []string `json:"earned"`
}
func (d *DB) EarnTitle(fingerprint, titleID string) (bool, error) {
data := d.loadTitleData(fingerprint)
for _, t := range data.Earned {
if t == titleID {
return false, nil // already earned
}
}
data.Earned = append(data.Earned, titleID)
if data.ActiveTitle == "" {
data.ActiveTitle = titleID
}
return true, d.saveTitleData(fingerprint, data)
}
func (d *DB) SetActiveTitle(fingerprint, titleID string) error {
data := d.loadTitleData(fingerprint)
data.ActiveTitle = titleID
return d.saveTitleData(fingerprint, data)
}
func (d *DB) GetTitleData(fingerprint string) PlayerTitleData {
return d.loadTitleData(fingerprint)
}
func (d *DB) loadTitleData(fingerprint string) PlayerTitleData {
var data PlayerTitleData
d.db.View(func(tx *bolt.Tx) error {
v := tx.Bucket(bucketTitles).Get([]byte(fingerprint))
if v != nil {
json.Unmarshal(v, &data)
}
return nil
})
return data
}
func (d *DB) saveTitleData(fingerprint string, data PlayerTitleData) error {
return d.db.Update(func(tx *bolt.Tx) error {
raw, _ := json.Marshal(data)
return tx.Bucket(bucketTitles).Put([]byte(fingerprint), raw)
})
}
```
Add `bucketTitles` to `store/db.go` `Open()`.
- [ ] **Step 2: Write tests and verify**
- [ ] **Step 3: Commit**
```bash
git add store/titles.go store/titles_test.go store/db.go
git commit -m "feat: add player title system with 7 titles"
```
---
## Task 5: Codex System
**Files:**
- Create: `store/codex.go`
- Create: `store/codex_test.go`
- Modify: `store/db.go` (add bucket)
- Modify: `game/event.go` (record monster/item/event encounters)
- [ ] **Step 1: Implement codex store**
```go
// store/codex.go
package store
import (
"encoding/json"
bolt "go.etcd.io/bbolt"
)
var bucketCodex = []byte("codex")
type Codex struct {
Monsters map[string]bool `json:"monsters"`
Items map[string]bool `json:"items"`
Events map[string]bool `json:"events"`
}
func (d *DB) RecordCodexEntry(fingerprint, category, id string) error {
return d.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketCodex)
var codex Codex
if v := b.Get([]byte(fingerprint)); v != nil {
json.Unmarshal(v, &codex)
}
if codex.Monsters == nil {
codex.Monsters = make(map[string]bool)
}
if codex.Items == nil {
codex.Items = make(map[string]bool)
}
if codex.Events == nil {
codex.Events = make(map[string]bool)
}
switch category {
case "monster":
codex.Monsters[id] = true
case "item":
codex.Items[id] = true
case "event":
codex.Events[id] = true
}
raw, _ := json.Marshal(codex)
return b.Put([]byte(fingerprint), raw)
})
}
func (d *DB) GetCodex(fingerprint string) Codex {
var codex Codex
d.db.View(func(tx *bolt.Tx) error {
if v := tx.Bucket(bucketCodex).Get([]byte(fingerprint)); v != nil {
json.Unmarshal(v, &codex)
}
return nil
})
if codex.Monsters == nil {
codex.Monsters = make(map[string]bool)
}
if codex.Items == nil {
codex.Items = make(map[string]bool)
}
if codex.Events == nil {
codex.Events = make(map[string]bool)
}
return codex
}
```
Add `bucketCodex` to `store/db.go`. The codex recording hooks in `game/event.go` will be added — but since GameSession doesn't have a DB reference, recording will happen from the UI layer (GameScreen) which has access to `ctx.Store`. Add codex recording in `ui/game_view.go` when the gameState changes (new monsters, items, events).
- [ ] **Step 2: Write tests and verify**
- [ ] **Step 3: Commit**
```bash
git add store/codex.go store/codex_test.go store/db.go
git commit -m "feat: add codex system for monster/item/event tracking"
```
---
## Task 6: Codex UI Screen
**Files:**
- Create: `ui/codex_view.go`
- Modify: `ui/lobby_view.go` or `ui/title.go` (add navigation to codex)
- [ ] **Step 1: Create CodexScreen**
```go
// ui/codex_view.go
package ui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/tolelom/catacombs/store"
)
type CodexScreen struct {
codex store.Codex
tab int // 0=monsters, 1=items, 2=events
}
func NewCodexScreen(ctx *Context) *CodexScreen {
codex := ctx.Store.GetCodex(ctx.Fingerprint)
return &CodexScreen{codex: codex}
}
```
Implement `Update` (Tab to switch tabs, Esc to go back) and `View` (show discovered entries with completion percentage per category).
Add a keybinding in the title screen or lobby to open the codex (e.g., `c` key).
- [ ] **Step 2: Run tests**
- [ ] **Step 3: Commit**
```bash
git add ui/codex_view.go ui/title.go
git commit -m "feat: add codex UI screen with completion tracking"
```
---
## Task 7: Hard Mode and Weekly Mutations
**Files:**
- Create: `game/mutation.go`
- Create: `game/mutation_test.go`
- Modify: `config/config.go` (add DifficultyConfig)
- Modify: `game/session.go` (add HardMode/Mutation fields, apply at session start)
- Modify: `ui/lobby_view.go` (show hard mode toggle if unlocked)
- [ ] **Step 1: Add DifficultyConfig**
```go
// Add to config/config.go
type DifficultyConfig struct {
HardModeMonsterMult float64 `yaml:"hard_mode_monster_mult"` // default: 1.5
HardModeShopMult float64 `yaml:"hard_mode_shop_mult"` // default: 2.0
HardModeHealMult float64 `yaml:"hard_mode_heal_mult"` // default: 0.5
}
```
Add to Config struct and defaults.
- [ ] **Step 2: Implement mutations**
```go
// game/mutation.go
package game
import (
"crypto/sha256"
"encoding/binary"
"time"
"github.com/tolelom/catacombs/config"
)
type Mutation struct {
ID string
Name string
Description string
Apply func(cfg *config.GameConfig)
}
var mutations = []Mutation{
{ID: "no_skills", Name: "Skill Lockout", Description: "Class skills are disabled",
Apply: func(cfg *config.GameConfig) { cfg.SkillUses = 0 }},
{ID: "elite_flood", Name: "Elite Flood", Description: "All monsters are elite",
Apply: func(cfg *config.GameConfig) {}}, // handled in event.go spawn logic
{ID: "no_shop", Name: "Shop Closed", Description: "Shops are unavailable",
Apply: func(cfg *config.GameConfig) {}}, // handled in event.go room generation
{ID: "glass_cannon", Name: "Glass Cannon", Description: "Double ATK, half HP",
Apply: func(cfg *config.GameConfig) {}}, // handled at player creation
{ID: "speed_run", Name: "Speed Run", Description: "Turn timeout halved",
Apply: func(cfg *config.GameConfig) { cfg.TurnTimeoutSec = cfg.TurnTimeoutSec / 2 }},
}
func GetWeeklyMutation() Mutation {
_, week := time.Now().ISOWeek()
year, _ := time.Now().ISOWeek()
h := sha256.Sum256([]byte(fmt.Sprintf("mutation:%d:%d", year, week)))
idx := int(binary.BigEndian.Uint64(h[:8])) % len(mutations)
return mutations[idx]
}
```
- [ ] **Step 3: Wire into session**
Add `HardMode bool` and `ActiveMutation *Mutation` fields to `GameSession`. When creating a session for hard mode, apply difficulty multipliers to a cloned config. When creating a mutation session, call `mutation.Apply(&cfg.Game)`.
- [ ] **Step 4: Add hard mode toggle in lobby UI**
In `LobbyScreen`, if `ctx.Store.IsUnlocked(ctx.Fingerprint, "hard_mode")`, show a `[H] Hard Mode` toggle. Store the selection and pass to session creation.
- [ ] **Step 5: Run all tests**
Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./... -v`
- [ ] **Step 6: Commit**
```bash
git add game/mutation.go game/mutation_test.go config/ game/session.go ui/lobby_view.go config.yaml
git commit -m "feat: add hard mode and weekly mutation system"
```
---
## Task 8: Integration — Daily Challenge Flow + Codex Recording
**Files:**
- Modify: `ui/lobby_view.go` (add daily challenge button)
- Modify: `ui/game_view.go` (save daily record on game over; record codex entries)
- Modify: `ui/leaderboard_view.go` (add daily tab)
- Modify: `game/session.go` (add DailyMode field, use daily seed)
- [ ] **Step 1: Add DailyMode to GameSession**
Add `DailyMode bool` and `DailyDate string` fields. When DailyMode, use `DailySeed(date)` to create the RNG for `GenerateFloor`.
- [ ] **Step 2: Add daily challenge in lobby**
In `LobbyScreen`, add `[D] Daily Challenge` button. Creates a solo session with DailyMode=true using today's date.
- [ ] **Step 3: Save daily record on game over**
In `GameScreen`, when game ends and `ctx.Session.DailyMode`, call `ctx.Store.SaveDaily(...)`.
- [ ] **Step 4: Record codex entries**
In `GameScreen.Update()`, when gameState shows new monsters (entering combat), record them to codex. When getting items (treasure/shop), record items. When events trigger, record event IDs.
- [ ] **Step 5: Add daily leaderboard tab**
In `LeaderboardScreen`, add a tab for daily leaderboard using `ctx.Store.GetDailyLeaderboard(today, 20)`.
- [ ] **Step 6: Trigger unlock checks**
In `GameScreen`, after game over:
- Floor >= 10 → unlock "fifth_class"
- Players >= 3 && floor >= 5 → unlock "hard_mode"
- Victory (floor >= maxFloors) → unlock "mutations"
Trigger title earning based on conditions.
- [ ] **Step 7: Run all tests**
Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./... -v`
- [ ] **Step 8: Commit**
```bash
git add ui/ game/session.go
git commit -m "feat: integrate daily challenges, codex recording, and unlock triggers"
```
---
## Task 9: Final Verification
- [ ] **Step 1: Run full test suite**
Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./... -v`
Expected: ALL PASS
- [ ] **Step 2: Run go vet**
Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go vet ./...`
Expected: No issues
- [ ] **Step 3: Build**
Run: `cd C:/Users/SSAFY/Desktop/projects/Catacombs && go build -o catacombs.exe .`
Expected: Success
- [ ] **Step 4: Verify new BoltDB buckets**
Confirm `store/db.go` `Open()` creates all 7 buckets: profiles, rankings, achievements, daily_runs, unlocks, titles, codex.
- [ ] **Step 5: Commit if cleanup needed**
```bash
git add -A
git commit -m "chore: phase 3 complete — retention systems verified"
```

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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
}

View File

@@ -11,10 +11,12 @@ const (
RoomEvent
RoomEmpty
RoomBoss
RoomSecret
RoomMiniBoss
)
func (r RoomType) String() string {
return [...]string{"Combat", "Treasure", "Shop", "Event", "Empty", "Boss"}[r]
return [...]string{"전투", "보물", "상점", "이벤트", "빈 방", "보스", "비밀", "미니보스"}[r]
}
type Tile int
@@ -44,16 +46,18 @@ type Floor struct {
Height int
}
func RandomRoomType() RoomType {
r := rand.Float64() * 100
func RandomRoomType(rng *rand.Rand) RoomType {
r := rng.Float64() * 100
switch {
case r < 45:
case r < 5:
return RoomSecret
case r < 50:
return RoomCombat
case r < 60:
case r < 65:
return RoomTreasure
case r < 70:
case r < 75:
return RoomShop
case r < 85:
case r < 90:
return RoomEvent
default:
return RoomEmpty

32
dungeon/theme.go Normal file
View File

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

32
dungeon/theme_test.go Normal file
View File

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

47
entity/elite.go Normal file
View File

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

94
entity/elite_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
package entity
import "testing"
import (
"strings"
"testing"
)
func TestNewPlayer(t *testing.T) {
p := NewPlayer("testuser", ClassWarrior)
@@ -190,3 +193,49 @@ func TestEffectOverwrite(t *testing.T) {
t.Error("should have overwritten with new values")
}
}
func TestBleedEffect(t *testing.T) {
p := NewPlayer("Test", ClassWarrior)
startHP := p.HP
p.AddEffect(ActiveEffect{Type: StatusBleed, Duration: 3, Value: 2})
msgs := p.TickEffects()
if len(msgs) == 0 || !strings.Contains(msgs[0], "출혈") {
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
View File

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

148
entity/skill_tree_test.go Normal file
View File

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

11
game/daily.go Normal file
View File

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

42
game/daily_test.go Normal file
View File

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

17
game/emote.go Normal file
View File

@@ -0,0 +1,17 @@
package game
var emotes = map[string]string{
"/hi": "👋 인사합니다!",
"/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
View File

@@ -0,0 +1,31 @@
package game
import "testing"
func TestParseEmote(t *testing.T) {
tests := []struct {
input string
isEmote bool
expected string
}{
{"/hi", true, "👋 인사합니다!"},
{"/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)
}
})
}
}

View File

@@ -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
}
}

View File

@@ -2,8 +2,11 @@ package game
import (
"fmt"
"log/slog"
"math/rand"
"sync"
"github.com/tolelom/catacombs/config"
)
type RoomStatus int
@@ -36,19 +39,25 @@ type OnlinePlayer struct {
type Lobby struct {
mu sync.RWMutex
cfg *config.Config
rooms map[string]*LobbyRoom
online map[string]*OnlinePlayer // fingerprint -> player
activeSessions map[string]string // fingerprint -> room code (for reconnect)
}
func NewLobby() *Lobby {
func NewLobby(cfg *config.Config) *Lobby {
return &Lobby{
cfg: cfg,
rooms: make(map[string]*LobbyRoom),
online: make(map[string]*OnlinePlayer),
activeSessions: make(map[string]string),
}
}
func (l *Lobby) Cfg() *config.Config {
return l.cfg
}
func (l *Lobby) RegisterSession(fingerprint, roomCode string) {
l.mu.Lock()
defer l.mu.Unlock()
@@ -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))
}
}

View File

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

41
game/mutation.go Normal file
View File

@@ -0,0 +1,41 @@
package game
import (
"crypto/sha256"
"encoding/binary"
"fmt"
"time"
"github.com/tolelom/catacombs/config"
)
// Mutation represents a weekly gameplay modifier.
type Mutation struct {
ID string
Name string
Description string
Apply func(cfg *config.GameConfig)
}
// Mutations is the list of all available mutations.
var Mutations = []Mutation{
{ID: "no_skills", Name: "스킬 봉인", 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
View File

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

247
game/random_event.go Normal file
View File

@@ -0,0 +1,247 @@
package game
import "math/rand"
// EventOutcome describes the result of choosing an event option.
type EventOutcome struct {
HPChange int
GoldChange int
ItemDrop bool
Description string
}
// EventChoice represents a single choice the player can make during an event.
type EventChoice struct {
Label string
Resolve func(floor int) EventOutcome
}
// RandomEvent represents a random event with multiple choices.
type RandomEvent struct {
Name string
Description string
Choices []EventChoice
}
// GetRandomEvents returns all 8 defined random events.
func GetRandomEvents() []RandomEvent {
return []RandomEvent{
{
Name: "altar",
Description: "이상한 에너지로 빛나는 고대 제단을 발견했습니다.",
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
View File

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

View File

@@ -2,9 +2,12 @@ package game
import (
"fmt"
"log/slog"
"math/rand"
"sync"
"time"
"github.com/tolelom/catacombs/config"
"github.com/tolelom/catacombs/dungeon"
"github.com/tolelom/catacombs/entity"
)
@@ -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
}

View File

@@ -4,11 +4,18 @@ import (
"testing"
"time"
"github.com/tolelom/catacombs/config"
"github.com/tolelom/catacombs/entity"
)
func testCfg(t *testing.T) *config.Config {
t.Helper()
cfg, _ := config.Load("")
return cfg
}
func TestGetStateNoRace(t *testing.T) {
s := NewGameSession()
s := NewGameSession(testCfg(t))
p := entity.NewPlayer("Racer", entity.ClassWarrior)
p.Fingerprint = "test-fp"
s.AddPlayer(p)
@@ -40,7 +47,7 @@ func TestGetStateNoRace(t *testing.T) {
}
func TestSessionTurnTimeout(t *testing.T) {
s := NewGameSession()
s := NewGameSession(testCfg(t))
p := entity.NewPlayer("test", entity.ClassWarrior)
p.Fingerprint = "test-fp"
s.AddPlayer(p)
@@ -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" {

View File

@@ -10,8 +10,6 @@ import (
"github.com/tolelom/catacombs/entity"
)
const TurnTimeout = 5 * time.Second
func (s *GameSession) RunTurn() {
s.mu.Lock()
s.state.TurnNum++
@@ -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
View File

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

1
go.sum
View File

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

114
main.go
View File

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

View File

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

View File

@@ -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
View File

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

68
store/backup_test.go Normal file
View File

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

88
store/codex.go Normal file
View File

@@ -0,0 +1,88 @@
package store
import (
"encoding/json"
bolt "go.etcd.io/bbolt"
)
var bucketCodex = []byte("codex")
type Codex struct {
Monsters map[string]bool `json:"monsters"`
Items map[string]bool `json:"items"`
Events map[string]bool `json:"events"`
}
func newCodex() Codex {
return Codex{
Monsters: make(map[string]bool),
Items: make(map[string]bool),
Events: make(map[string]bool),
}
}
func (d *DB) RecordCodexEntry(fingerprint, category, id string) error {
return d.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketCodex)
key := []byte(fingerprint)
codex := newCodex()
v := b.Get(key)
if v != nil {
if err := json.Unmarshal(v, &codex); err != nil {
return err
}
// Ensure maps are initialized after unmarshal
if codex.Monsters == nil {
codex.Monsters = make(map[string]bool)
}
if codex.Items == nil {
codex.Items = make(map[string]bool)
}
if codex.Events == nil {
codex.Events = make(map[string]bool)
}
}
switch category {
case "monster":
codex.Monsters[id] = true
case "item":
codex.Items[id] = true
case "event":
codex.Events[id] = true
}
data, err := json.Marshal(codex)
if err != nil {
return err
}
return b.Put(key, data)
})
}
func (d *DB) GetCodex(fingerprint string) (Codex, error) {
codex := newCodex()
err := d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketCodex)
v := b.Get([]byte(fingerprint))
if v == nil {
return nil
}
if err := json.Unmarshal(v, &codex); err != nil {
return err
}
if codex.Monsters == nil {
codex.Monsters = make(map[string]bool)
}
if codex.Items == nil {
codex.Items = make(map[string]bool)
}
if codex.Events == nil {
codex.Events = make(map[string]bool)
}
return nil
})
return codex, err
}

71
store/codex_test.go Normal file
View File

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

81
store/daily.go Normal file
View File

@@ -0,0 +1,81 @@
package store
import (
"encoding/json"
"sort"
"time"
bolt "go.etcd.io/bbolt"
)
var bucketDailyRuns = []byte("daily_runs")
type DailyRecord struct {
Date string `json:"date"`
Player string `json:"player"`
PlayerName string `json:"player_name"`
FloorReached int `json:"floor_reached"`
GoldEarned int `json:"gold_earned"`
}
func (d *DB) SaveDaily(record DailyRecord) error {
return d.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketDailyRuns)
key := []byte(record.Date + ":" + record.Player)
data, err := json.Marshal(record)
if err != nil {
return err
}
return b.Put(key, data)
})
}
func (d *DB) GetDailyLeaderboard(date string, limit int) ([]DailyRecord, error) {
var records []DailyRecord
prefix := []byte(date + ":")
err := d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketDailyRuns)
c := b.Cursor()
for k, v := c.Seek(prefix); k != nil && len(k) >= len(prefix) && string(k[:len(prefix)]) == string(prefix); k, v = c.Next() {
var r DailyRecord
if json.Unmarshal(v, &r) == nil {
records = append(records, r)
}
}
return nil
})
if err != nil {
return nil, err
}
sort.Slice(records, func(i, j int) bool {
if records[i].FloorReached != records[j].FloorReached {
return records[i].FloorReached > records[j].FloorReached
}
return records[i].GoldEarned > records[j].GoldEarned
})
if len(records) > limit {
records = records[:limit]
}
return records, nil
}
func (d *DB) GetStreak(fingerprint, currentDate string) (int, error) {
streak := 0
date, err := time.Parse("2006-01-02", currentDate)
if err != nil {
return 0, err
}
err = d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketDailyRuns)
for {
key := []byte(date.Format("2006-01-02") + ":" + fingerprint)
if b.Get(key) == nil {
break
}
streak++
date = date.AddDate(0, 0, -1)
}
return nil
})
return streak, err
}

66
store/daily_test.go Normal file
View File

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

View File

@@ -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

View File

@@ -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
View 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
View 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
View File

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

69
store/stats_test.go Normal file
View File

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

115
store/titles.go Normal file
View File

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

115
store/titles_test.go Normal file
View File

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

47
store/unlocks.go Normal file
View File

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

47
store/unlocks_test.go Normal file
View File

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

View File

@@ -3,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))

View File

@@ -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
View File

@@ -0,0 +1,169 @@
package ui
import (
"fmt"
"sort"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/store"
)
// Total known entries for completion calculation
const (
totalMonsters = 16 // 8 regular + 4 bosses + 4 mini-bosses
totalItems = 15 // weapons + armor + potions + relics
totalEvents = 8 // random events from game/random_event.go
)
// All known entry names for display
var allMonsters = []string{
"Goblin", "Skeleton", "Bat", "Slime", "Zombie", "Spider", "Rat", "Ghost",
"Dragon", "Lich", "Demon Lord", "Hydra",
"Troll", "Wraith", "Golem", "Minotaur",
}
var allItems = []string{
"Iron Sword", "Steel Axe", "Magic Staff", "Shadow Dagger", "Holy Mace",
"Leather Armor", "Chain Mail", "Plate Armor",
"Health Potion", "Mana Potion", "Strength Potion",
"Shield Relic", "Amulet of Life", "Ring of Power", "Boots of Speed",
}
var allEvents = []string{
"altar", "fountain", "merchant", "trap_room",
"shrine", "chest", "ghost", "mushroom",
}
// CodexScreen displays the player's codex with discovered entries.
type CodexScreen struct {
codex store.Codex
tab int // 0=monsters, 1=items, 2=events
}
func NewCodexScreen(ctx *Context) *CodexScreen {
var codex store.Codex
if ctx.Store != nil {
codex, _ = ctx.Store.GetCodex(ctx.Fingerprint)
} else {
codex = store.Codex{
Monsters: make(map[string]bool),
Items: make(map[string]bool),
Events: make(map[string]bool),
}
}
return &CodexScreen{codex: codex}
}
func (s *CodexScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isKey(key, "esc", "c", "q") || key.Type == tea.KeyEsc {
return NewTitleScreen(), nil
}
if key.Type == tea.KeyTab || isKey(key, "right", "l") || key.Type == tea.KeyRight {
s.tab = (s.tab + 1) % 3
}
if isKey(key, "left", "h") || key.Type == tea.KeyLeft {
s.tab = (s.tab + 2) % 3
}
}
return s, nil
}
func (s *CodexScreen) View(ctx *Context) string {
title := styleHeader.Render("-- 도감 --")
// 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
View 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
}

View File

@@ -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))

View File

@@ -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))

View File

@@ -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))
}

View File

@@ -3,21 +3,13 @@ package ui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/game"
)
type lobbyState struct {
rooms []roomInfo
input string
cursor int
creating bool
roomName string
joining bool
codeInput string
online int
}
type roomInfo struct {
Code string
Name string
@@ -31,6 +23,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,

View File

@@ -2,61 +2,17 @@ package ui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/tolelom/catacombs/entity"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
)
type screen int
const (
screenTitle screen = iota
screenLobby
screenClassSelect
screenGame
screenShop
screenResult
screenHelp
screenStats
screenAchievements
screenLeaderboard
screenNickname
)
// StateUpdateMsg is sent by GameSession to update the view
type StateUpdateMsg struct {
State game.GameState
}
type tickMsg struct{}
type Model struct {
width int
height int
fingerprint string
playerName string
screen screen
// Shared references (set by server)
lobby *game.Lobby
store *store.DB
// Per-session state
session *game.GameSession
roomCode string
gameState game.GameState
lobbyState lobbyState
classState classSelectState
inputBuffer string
targetCursor int
moveCursor int // selected neighbor index during exploration
chatting bool
chatInput string
rankingSaved bool
shopMsg string
nicknameInput string
currentScreen Screen
ctx *Context
}
func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *store.DB) Model {
@@ -66,13 +22,26 @@ func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *stor
if height == 0 {
height = 24
}
ctx := &Context{
Width: width,
Height: height,
Fingerprint: fingerprint,
Lobby: lobby,
Store: db,
}
// Determine initial screen
var initialScreen Screen
if fingerprint != "" && db != nil {
if name, err := db.GetProfile(fingerprint); err == nil {
ctx.PlayerName = name
}
}
initialScreen = NewTitleScreen()
return Model{
width: width,
height: height,
fingerprint: fingerprint,
screen: screenTitle,
lobby: lobby,
store: db,
currentScreen: initialScreen,
ctx: ctx,
}
}
@@ -83,95 +52,30 @@ func (m Model) Init() tea.Cmd {
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
if m.width == 0 {
m.width = 80
m.ctx.Width = msg.Width
m.ctx.Height = msg.Height
if m.ctx.Width == 0 {
m.ctx.Width = 80
}
if m.height == 0 {
m.height = 24
if m.ctx.Height == 0 {
m.ctx.Height = 24
}
return m, nil
case StateUpdateMsg:
m.gameState = msg.State
return m, nil
}
switch m.screen {
case screenTitle:
return m.updateTitle(msg)
case screenLobby:
return m.updateLobby(msg)
case screenClassSelect:
return m.updateClassSelect(msg)
case screenGame:
return m.updateGame(msg)
case screenShop:
return m.updateShop(msg)
case screenResult:
return m.updateResult(msg)
case screenHelp:
return m.updateHelp(msg)
case screenStats:
return m.updateStats(msg)
case screenAchievements:
return m.updateAchievements(msg)
case screenLeaderboard:
return m.updateLeaderboard(msg)
case screenNickname:
return m.updateNickname(msg)
}
return m, nil
next, cmd := m.currentScreen.Update(msg, m.ctx)
m.currentScreen = next
return m, cmd
}
func (m Model) View() string {
if m.width < 80 || m.height < 24 {
return fmt.Sprintf("Terminal too small (%dx%d). Minimum: 80x24.", m.width, m.height)
if m.ctx.Width < 80 || m.ctx.Height < 24 {
return fmt.Sprintf("Terminal too small (%dx%d). Minimum: 80x24.", m.ctx.Width, m.ctx.Height)
}
switch m.screen {
case screenTitle:
return renderTitle(m.width, m.height)
case screenLobby:
return renderLobby(m.lobbyState, m.width, m.height)
case screenClassSelect:
return renderClassSelect(m.classState, m.width, m.height)
case screenGame:
return renderGame(m.gameState, m.width, m.height, m.targetCursor, m.moveCursor, m.chatting, m.chatInput)
case screenShop:
return renderShop(m.gameState, m.width, m.height, m.shopMsg)
case screenResult:
var rankings []store.RunRecord
if m.store != nil {
rankings, _ = m.store.TopRuns(10)
}
return renderResult(m.gameState, rankings)
case screenHelp:
return renderHelp(m.width, m.height)
case screenStats:
var stats store.PlayerStats
if m.store != nil {
stats, _ = m.store.GetStats(m.playerName)
}
return renderStats(m.playerName, stats, m.width, m.height)
case screenAchievements:
var achievements []store.Achievement
if m.store != nil {
achievements, _ = m.store.GetAchievements(m.playerName)
}
return renderAchievements(m.playerName, achievements, m.width, m.height)
case screenLeaderboard:
var byFloor, byGold []store.RunRecord
if m.store != nil {
byFloor, _ = m.store.TopRuns(10)
byGold, _ = m.store.TopRunsByGold(10)
}
return renderLeaderboard(byFloor, byGold, m.width, m.height)
case screenNickname:
return renderNickname(m.nicknameInput, m.width, m.height)
}
return ""
return m.currentScreen.View(m.ctx)
}
// Key helper functions used by all screens.
func isKey(key tea.KeyMsg, names ...string) bool {
s := key.String()
for _, n := range names {
@@ -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
}

View File

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

View File

@@ -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
}

View File

@@ -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
View File

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

View File

@@ -3,11 +3,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 != "" {

View File

@@ -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))

View File

@@ -1,11 +1,77 @@
package ui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// TitleScreen is the main menu screen.
type TitleScreen struct{}
func NewTitleScreen() *TitleScreen {
return &TitleScreen{}
}
func (s *TitleScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isEnter(key) {
if ctx.Fingerprint == "" {
ctx.Fingerprint = fmt.Sprintf("anon-%d", time.Now().UnixNano())
}
if ctx.Store != nil {
name, err := ctx.Store.GetProfile(ctx.Fingerprint)
if err != nil {
// First time player — show nickname input
return NewNicknameScreen(), nil
}
ctx.PlayerName = name
} else {
ctx.PlayerName = "Adventurer"
}
if ctx.Lobby != nil {
ctx.Lobby.PlayerOnline(ctx.Fingerprint, ctx.PlayerName)
}
// Check for active session to reconnect
if ctx.Lobby != nil {
code, session := ctx.Lobby.GetActiveSession(ctx.Fingerprint)
if session != nil {
ctx.RoomCode = code
ctx.Session = session
gs := NewGameScreen()
gs.gameState = ctx.Session.GetState()
ctx.Session.TouchActivity(ctx.Fingerprint)
ctx.Session.SendChat("System", ctx.PlayerName+" 재접속!")
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
View 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
View File

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

64
web/admin_test.go Normal file
View File

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

View File

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

View File

@@ -72,8 +72,9 @@
sendResize();
};
ws.binaryType = 'arraybuffer';
ws.onmessage = (e) => {
term.write(e.data);
term.write(new Uint8Array(e.data));
};
ws.onclose = () => {