Compare commits

...

88 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
604ca00e8b feat: session reconnect via SSH fingerprint on disconnect
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 15:43:08 +09:00
43a9a0d9ad feat: nickname input screen for first-time players
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 15:42:06 +09:00
ef9a713696 feat: online player tracking and count display in lobby
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 15:33:19 +09:00
5c5070502a feat: enhanced leaderboard with floor/gold rankings and class info
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 15:33:16 +09:00
fb0e64a109 feat: achievement system with 10 unlockable achievements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 15:33:12 +09:00
57e56ae7a4 test: lobby, session, store tests — deep copy, logs, inventory, stats
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 15:07:51 +09:00
f396066428 test: comprehensive tests for player effects, monster, and combat
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 15:06:01 +09:00
afdda5d72b feat: player statistics screen with run history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 14:51:44 +09:00
0dce30f23f feat: floor themes with color-coded walls (Stone/Moss/Lava/Shadow)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 14:50:40 +09:00
d3d7e2a76a feat: status effects (poison/burn), boss patterns, new relics
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 14:49:55 +09:00
533e460968 feat: lobby shows player names and classes in room listing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 14:47:53 +09:00
29387ebaa0 feat: help screen, detailed result screen, death/revive messages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 14:44:03 +09:00
80c1988719 feat: party action status display and sequential turn result replay
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 14:23:44 +09:00
9221cfa7c6 refactor: replace goto with labeled break in RunTurn
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:46:33 +09:00
15199bd26f feat: skill cooldown (3/combat), inventory limit (10), scaled event damage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:46:17 +09:00
9ed71eeccd feat: room counter, taunt turns, item labels, shop gold display
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:45:19 +09:00
1104c6e4e9 fix: lock SoloMode at start, shop feedback, dead player exploration block
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:44:43 +09:00
c555ff6e92 feat: add xterm.js web terminal frontend
WebSocket-to-SSH proxy on :8080. Browser connects via xterm.js,
server bridges to localhost:2222 SSH. Single HTML file, CDN deps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:33:08 +09:00
09af632ed9 docs: web terminal frontend design spec
WebSocket-to-SSH proxy with xterm.js client.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:31:20 +09:00
a3bffbecb4 feat: gradient title screen with centered layout 2026-03-24 12:38:54 +09:00
a951f94f3e feat: two-panel combat layout with colored log and 3-color HP bars
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:38:02 +09:00
7fc13a6a32 feat: add shared styles and monster ASCII art 2026-03-24 12:36:10 +09:00
84431c888a docs: terminal visuals implementation plan
5 tasks: styles, ASCII art, colored log, combat layout, title screen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:35:20 +09:00
1ea6db406e docs: terminal visuals enhancement design spec
Monster ASCII art, combat layout redesign, color effects, title screen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:34:54 +09:00
01edb488f7 fix: game balance — gold scaling, solo DEF, floor-scaled items, healer targeting, AI fallback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:17:23 +09:00
ee9aec0b32 feat: add in-game chat with / key
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:06:29 +09:00
ce2f03baf5 feat: remove inactive players after 60s disconnect timeout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:59:33 +09:00
46afd82060 fix: stop combatLoop goroutine and remove lobby room on session exit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:56:23 +09:00
e8887cd69a fix: use fingerprint as player ID to prevent name collision
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:50:21 +09:00
cd2013a917 feat: multiplayer flee marks player as out for current combat
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:47:38 +09:00
6f35bc1172 feat: show log messages for trap, blessing, and treasure events 2026-03-24 10:45:01 +09:00
15614b966a fix: clamp target cursor when monsters die 2026-03-24 10:44:25 +09:00
b6c28ddd80 fix: set room status to Playing when game starts 2026-03-24 10:40:04 +09:00
4db3ba1fc5 fix: clear monster taunt when warrior is dead 2026-03-24 10:27:38 +09:00
e13e1e7a7d fix: prevent duplicate SaveRun calls on game over 2026-03-24 10:27:12 +09:00
b0766c488c fix: deep-copy GameState in GetState to prevent data race
Replace shallow struct copy with full deep copy of Players, Monsters,
Floor/Rooms, Inventory, Relics, ShopItems, and CombatLog slices so
concurrent readers via GetState never alias the combatLoop's live data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:23:21 +09:00
ae3375a023 fix: scale monster DEF with floor level like HP/ATK 2026-03-24 10:17:48 +09:00
e3e6c5105c docs: add bugfix and spec alignment implementation plan
Covers 13 tasks: DEF scaling, race condition fix, SaveRun dedup,
taunt fix, multiplayer flee, room status, cursor clamp, event logs,
fingerprint IDs, session cleanup, room cleanup, disconnect handling, chat.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:10:57 +09:00
93 changed files with 14139 additions and 775 deletions

View File

@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(go build:*)",
"Bash(find . -name \"*.go\" -type f -exec wc -l {} +)",
"Bash(sort -k2)",
"Bash(where go:*)",
"Read(//c/Users/SSAFY/sdk/**)",
"Read(//c/Users/98kim/**)",
"Bash(go test:*)",
"Bash(git add:*)",
"Bash(git commit:*)"
]
},
"disabledMcpjsonServers": [
"unity-mcp"
]
}

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

Binary file not shown.

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%3 == 0 {
return -1, true
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 {
@@ -91,17 +92,23 @@ func MonsterAI(m *entity.Monster, players []*entity.Player, turnNumber int) (tar
return i, false
}
}
// No living warrior found — clear taunt
m.TauntTarget = false
m.TauntTurns = 0
}
if rand.Float64() < 0.3 {
minHP := int(^uint(0) >> 1)
minIdx := 0
minIdx := -1
for i, p := range players {
if !p.IsDead() && p.HP < minHP {
minHP = p.HP
minIdx = i
}
}
return minIdx, false
if minIdx >= 0 {
return minIdx, false
}
// Fall through to default targeting if no alive player found
}
for i, p := range players {
if !p.IsDead() {

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,19 +37,38 @@ 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")
}
}
func TestMonsterAITauntDeadWarrior(t *testing.T) {
warrior := entity.NewPlayer("Tank", entity.ClassWarrior)
warrior.TakeDamage(warrior.HP) // kill warrior
mage := entity.NewPlayer("Mage", entity.ClassMage)
m := &entity.Monster{Name: "Orc", HP: 50, ATK: 10, DEF: 5, TauntTarget: true, TauntTurns: 2}
idx, isAoE := MonsterAI(m, []*entity.Player{warrior, mage}, 1)
if isAoE {
t.Error("should not AoE")
}
if idx != 1 {
t.Errorf("expected target mage at index 1, got %d", idx)
}
if m.TauntTarget {
t.Error("TauntTarget should be cleared when warrior is dead")
}
}
func TestFleeChance(t *testing.T) {
successes := 0
for i := 0; i < 100; i++ {
if AttemptFlee() {
if AttemptFlee(0.50) {
successes++
}
}
@@ -57,3 +76,63 @@ func TestFleeChance(t *testing.T) {
t.Errorf("Flee success rate suspicious: %d/100", successes)
}
}
func TestMonsterAIBossAoE(t *testing.T) {
boss := &entity.Monster{Name: "Boss", HP: 100, IsBoss: true}
players := []*entity.Player{entity.NewPlayer("P1", entity.ClassWarrior)}
// Turn 0 should NOT AoE
_, isAoE := MonsterAI(boss, players, 0)
if isAoE {
t.Error("boss should not AoE on turn 0")
}
// Turn 3 should AoE
_, isAoE = MonsterAI(boss, players, 3)
if !isAoE {
t.Error("boss should AoE on turn 3")
}
// Turn 6 should AoE
_, isAoE = MonsterAI(boss, players, 6)
if !isAoE {
t.Error("boss should AoE on turn 6")
}
}
func TestMonsterAILowestHP(t *testing.T) {
p1 := entity.NewPlayer("Tank", entity.ClassWarrior) // 120 HP
p2 := entity.NewPlayer("Mage", entity.ClassMage) // 70 HP
p2.HP = 10 // very low
// Run many times — at least some should target p2 (30% chance)
targetedLow := 0
for i := 0; i < 100; i++ {
m := &entity.Monster{Name: "Orc", HP: 50}
idx, _ := MonsterAI(m, []*entity.Player{p1, p2}, 1)
if idx == 1 {
targetedLow++
}
}
// Should target low HP player roughly 30% of time
if targetedLow < 10 || targetedLow > 60 {
t.Errorf("lowest HP targeting out of expected range: %d/100", targetedLow)
}
}
func TestCalcDamageWithMultiplier(t *testing.T) {
// AoE multiplier 0.8: ATK=20, DEF=5, mult=0.8 → base = 20*0.8 - 5 = 11
// Range: 11 * 0.85 to 11 * 1.15 = ~9.35 to ~12.65
for i := 0; i < 50; i++ {
dmg := CalcDamage(20, 5, 0.8)
if dmg < 9 || dmg > 13 {
t.Errorf("AoE damage %d out of expected range 9-13", dmg)
}
}
}
func TestCalcDamageHighDEF(t *testing.T) {
// When DEF > ATK*mult, should deal minimum 1 damage
dmg := CalcDamage(5, 100, 1.0)
if dmg != 1 {
t.Errorf("expected minimum damage 1, got %d", dmg)
}
}

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

View File

@@ -3,6 +3,7 @@ services:
build: .
ports:
- "2222:2222"
- "8080:8080"
volumes:
- catacombs-data:/app/data
restart: unless-stopped

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,558 @@
# Terminal Visuals Enhancement — 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:** Enhance Catacombs terminal visuals — monster ASCII art, combat layout with box-drawing panels, semantic color effects, and improved title screen.
**Architecture:** New files for styles and ASCII art data. Rewrite of `game_view.go` render functions. All changes are UI-only — no game logic modifications.
**Tech Stack:** Go, charmbracelet/lipgloss (box-drawing, colors, layout)
**Spec:** `docs/superpowers/specs/2026-03-24-terminal-visuals-design.md`
---
## File Map
```
ui/styles.go — NEW: shared lipgloss style constants
ui/ascii_art.go — NEW: monster ASCII art data + Art() function
ui/game_view.go — REWRITE: combat layout, colored log, HP bar 3-color
ui/title.go — MODIFY: gradient logo, centered layout
```
---
### Task 1: Shared Styles (ui/styles.go)
**Files:**
- Create: `ui/styles.go`
- [ ] **Step 1: Create styles.go with color constants and reusable styles**
```go
package ui
import "github.com/charmbracelet/lipgloss"
// Colors
var (
colorRed = lipgloss.Color("196")
colorGreen = lipgloss.Color("46")
colorYellow = lipgloss.Color("226")
colorCyan = lipgloss.Color("51")
colorMagenta = lipgloss.Color("201")
colorWhite = lipgloss.Color("255")
colorGray = lipgloss.Color("240")
colorOrange = lipgloss.Color("208")
colorPink = lipgloss.Color("205")
)
// Text styles
var (
styleDamage = lipgloss.NewStyle().Foreground(colorRed).Bold(true)
styleHeal = lipgloss.NewStyle().Foreground(colorGreen).Bold(true)
styleCoop = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
styleFlee = lipgloss.NewStyle().Foreground(colorCyan)
styleStatus = lipgloss.NewStyle().Foreground(colorMagenta)
styleGold = lipgloss.NewStyle().Foreground(colorYellow)
styleSystem = lipgloss.NewStyle().Foreground(colorGray).Italic(true)
styleEnemy = lipgloss.NewStyle().Foreground(colorRed)
stylePlayer = lipgloss.NewStyle().Foreground(colorWhite).Bold(true)
styleHeader = lipgloss.NewStyle().Foreground(colorPink).Bold(true)
styleAction = lipgloss.NewStyle().Bold(true)
styleTimer = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
)
// Panel styles
var (
panelBorder = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorGray).
Padding(0, 1)
panelHeader = lipgloss.NewStyle().
Foreground(colorPink).
Bold(true).
Align(lipgloss.Center)
)
```
- [ ] **Step 2: Build**
```bash
go build ./...
```
- [ ] **Step 3: Commit**
```bash
git add ui/styles.go
git commit -m "feat: add shared lipgloss styles for terminal visuals"
```
---
### Task 2: Monster ASCII Art (ui/ascii_art.go)
**Files:**
- Create: `ui/ascii_art.go`
- [ ] **Step 1: Create ascii_art.go with art data**
```go
package ui
import "github.com/tolelom/catacombs/entity"
// MonsterArt returns ASCII art lines for a monster type.
func MonsterArt(mt entity.MonsterType) []string {
switch mt {
case entity.MonsterSlime:
return []string{
` /\OO/\ `,
` \ / `,
` |__| `,
}
case entity.MonsterSkeleton:
return []string{
` ,--. `,
` |oo| `,
` /||\ `,
}
case entity.MonsterOrc:
return []string{
` .---. `,
`/o o\`,
`| --- |`,
}
case entity.MonsterDarkKnight:
return []string{
` /|||\ `,
` |===| `,
` | | `,
}
case entity.MonsterBoss5:
return []string{
` /\ /\ `,
`| @ @ |`,
`| || |`,
`| \__/ |`,
` \ / `,
}
case entity.MonsterBoss10:
return []string{
` __|__ `,
` /|o o|\ `,
` | === | `,
` |\___/| `,
` |___| `,
}
case entity.MonsterBoss15:
return []string{
` ,=====. `,
`/ \ / \`,
`| (O) |`,
` \ |=| / `,
` '===' `,
}
case entity.MonsterBoss20:
return []string{
` ___/\___ `,
`| x x |`,
`| === |`,
`|\_____/|`,
`|_| |_|`,
}
default:
return []string{` ??? `}
}
}
```
- [ ] **Step 2: Build**
```bash
go build ./...
```
- [ ] **Step 3: Commit**
```bash
git add ui/ascii_art.go
git commit -m "feat: add monster ASCII art data"
```
---
### Task 3: HP Bar 3-Color + Colored Combat Log (ui/game_view.go)
**Files:**
- Modify: `ui/game_view.go`
- [ ] **Step 1: Rewrite renderHPBar with 3-color thresholds**
Replace the `renderHPBar` function:
```go
func renderHPBar(current, max, width int) string {
if max == 0 {
return ""
}
filled := current * width / max
if filled < 0 {
filled = 0
}
if filled > width {
filled = width
}
empty := width - filled
pct := float64(current) / float64(max)
var barStyle lipgloss.Style
switch {
case pct > 0.5:
barStyle = lipgloss.NewStyle().Foreground(colorGreen)
case pct > 0.25:
barStyle = lipgloss.NewStyle().Foreground(colorYellow)
default:
barStyle = lipgloss.NewStyle().Foreground(colorRed)
}
emptyStyle := lipgloss.NewStyle().Foreground(colorGray)
return barStyle.Render(strings.Repeat("█", filled)) +
emptyStyle.Render(strings.Repeat("░", empty))
}
```
- [ ] **Step 2: Rewrite renderCombatLog with pattern-based coloring**
Replace `renderCombatLog`:
```go
func renderCombatLog(log []string) string {
if len(log) == 0 {
return ""
}
border := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorGray).
Padding(0, 1)
var sb strings.Builder
for _, msg := range log {
colored := colorizeLog(msg)
sb.WriteString(" > " + colored + "\n")
}
return border.Render(sb.String())
}
func colorizeLog(msg string) string {
// Apply semantic colors based on keywords
switch {
case strings.Contains(msg, "fled"):
return styleFlee.Render(msg)
case strings.Contains(msg, "co-op"):
return styleCoop.Render(msg)
case strings.Contains(msg, "healed") || strings.Contains(msg, "Heal") || strings.Contains(msg, "Blessing"):
return styleHeal.Render(msg)
case strings.Contains(msg, "dmg") || strings.Contains(msg, "hit") || strings.Contains(msg, "attacks") || strings.Contains(msg, "Trap"):
return styleDamage.Render(msg)
case strings.Contains(msg, "Taunt") || strings.Contains(msg, "scouted"):
return styleStatus.Render(msg)
case strings.Contains(msg, "gold") || strings.Contains(msg, "Gold") || strings.Contains(msg, "found"):
return styleGold.Render(msg)
case strings.Contains(msg, "defeated") || strings.Contains(msg, "cleared") || strings.Contains(msg, "Descending"):
return styleSystem.Render(msg)
default:
return msg
}
}
```
- [ ] **Step 3: Build and test**
```bash
go build ./...
go test ./ui/ -timeout 15s
```
- [ ] **Step 4: Commit**
```bash
git add ui/game_view.go
git commit -m "feat: 3-color HP bar and semantic combat log coloring"
```
---
### Task 4: Combat Layout Redesign (ui/game_view.go)
**Files:**
- Modify: `ui/game_view.go`
This is the largest task. Rewrite `renderHUD` to use a 2-panel layout in combat mode.
- [ ] **Step 1: Add renderPartyPanel helper**
Add to `ui/game_view.go`:
```go
func renderPartyPanel(players []*entity.Player) string {
var sb strings.Builder
sb.WriteString(styleHeader.Render(" PARTY") + "\n\n")
for _, p := range players {
nameStr := stylePlayer.Render(fmt.Sprintf(" ♦ %s", p.Name))
classStr := styleSystem.Render(fmt.Sprintf(" (%s)", p.Class))
status := ""
if p.IsDead() {
status = styleDamage.Render(" [DEAD]")
}
sb.WriteString(nameStr + classStr + status + "\n")
hpBar := renderHPBar(p.HP, p.MaxHP, 16)
sb.WriteString(fmt.Sprintf(" %s %d/%d\n", hpBar, p.HP, p.MaxHP))
sb.WriteString(fmt.Sprintf(" ATK:%-3d DEF:%-3d ", p.EffectiveATK(), p.EffectiveDEF()))
sb.WriteString(styleGold.Render(fmt.Sprintf("G:%d", p.Gold)))
sb.WriteString("\n\n")
}
return sb.String()
}
```
- [ ] **Step 2: Add renderEnemyPanel helper**
```go
func renderEnemyPanel(monsters []*entity.Monster, targetCursor int) string {
var sb strings.Builder
sb.WriteString(styleHeader.Render(" ENEMIES") + "\n\n")
for i, m := range monsters {
if m.IsDead() {
continue
}
// ASCII art
art := MonsterArt(m.Type)
for _, line := range art {
sb.WriteString(styleEnemy.Render(" " + line) + "\n")
}
// Name + HP
marker := " "
if i == targetCursor {
marker = "> "
}
hpBar := renderHPBar(m.HP, m.MaxHP, 12)
taunt := ""
if m.TauntTarget {
taunt = styleStatus.Render(" [TAUNTED]")
}
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))
}
return sb.String()
}
```
- [ ] **Step 3: Rewrite renderHUD for combat mode**
Replace the combat section of `renderHUD` (the `if state.Phase == game.PhaseCombat` block):
```go
if state.Phase == game.PhaseCombat {
// Two-panel layout: PARTY | ENEMIES
partyContent := renderPartyPanel(state.Players)
enemyContent := renderEnemyPanel(state.Monsters, targetCursor)
partyPanel := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorGray).
Width(35).
Padding(0, 1).
Render(partyContent)
enemyPanel := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorGray).
Width(38).
Padding(0, 1).
Render(enemyContent)
panels := lipgloss.JoinHorizontal(lipgloss.Top, partyPanel, enemyPanel)
sb.WriteString(panels)
sb.WriteString("\n")
// Action bar
sb.WriteString(styleAction.Render("[1]Attack [2]Skill [3]Item [4]Flee [5]Wait [Tab]Target [/]Chat"))
sb.WriteString("\n")
// Timer
if !state.TurnDeadline.IsZero() {
remaining := time.Until(state.TurnDeadline)
if remaining < 0 {
remaining = 0
}
sb.WriteString(styleTimer.Render(fmt.Sprintf(" Timer: %.1fs", remaining.Seconds())))
sb.WriteString("\n")
}
// Skill description for current player (first alive)
for _, p := range state.Players {
if !p.IsDead() {
var skillDesc string
switch p.Class {
case entity.ClassWarrior:
skillDesc = "Skill: Taunt — enemies attack you for 2 turns"
case entity.ClassMage:
skillDesc = "Skill: Fireball — AoE 0.8x dmg to all enemies"
case entity.ClassHealer:
skillDesc = "Skill: Heal — restore 30 HP to an ally"
case entity.ClassRogue:
skillDesc = "Skill: Scout — reveal neighboring rooms"
}
sb.WriteString(styleSystem.Render(skillDesc))
sb.WriteString("\n")
break // show only current player's skill
}
}
}
```
- [ ] **Step 4: Update renderHUD to not wrap combat in border (panels have their own)**
The exploration mode still uses the old `border` style. Change the function to only apply `border.Render()` for exploration, not combat:
Restructure `renderHUD` so that:
- Combat: return `sb.String()` directly (panels already have borders)
- Exploring: wrap in `border.Render(sb.String())`
Replace the return at the end of `renderHUD`:
```go
if state.Phase == game.PhaseCombat {
return sb.String()
}
return border.Render(sb.String())
```
- [ ] **Step 5: Remove the unused roomTypeSymbol function**
Delete `roomTypeSymbol` (lines ~185-202) — it's dead code.
- [ ] **Step 6: Build and run all tests**
```bash
go build ./...
go test ./... -timeout 30s
```
- [ ] **Step 7: Commit**
```bash
git add ui/game_view.go
git commit -m "feat: two-panel combat layout with monster ASCII art"
```
---
### Task 5: Title Screen Enhancement (ui/title.go)
**Files:**
- Modify: `ui/title.go`
- [ ] **Step 1: Rewrite renderTitle with gradient colors and centered layout**
Replace `ui/title.go` entirely:
```go
package ui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
var titleLines = []string{
` ██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗`,
`██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝`,
`██║ ███████║ ██║ ███████║██║ ██║ ██║██╔████╔██║██████╔╝███████╗`,
`██║ ██╔══██║ ██║ ██╔══██║██║ ██║ ██║██║╚██╔╝██║██╔══██╗╚════██║`,
`╚██████╗██║ ██║ ██║ ██║ ██║╚██████╗╚██████╔╝██║ ╚═╝ ██║██████╔╝███████║`,
`╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝`,
}
// gradient from red → orange → yellow
var titleColors = []lipgloss.Color{
lipgloss.Color("196"), // red
lipgloss.Color("202"), // orange-red
lipgloss.Color("208"), // orange
lipgloss.Color("214"), // yellow-orange
lipgloss.Color("220"), // yellow
lipgloss.Color("226"), // bright yellow
}
func renderTitle(width, height int) string {
// Render logo with gradient
var logoLines []string
for i, line := range titleLines {
color := titleColors[i%len(titleColors)]
style := lipgloss.NewStyle().Foreground(color).Bold(true)
logoLines = append(logoLines, style.Render(line))
}
logo := strings.Join(logoLines, "\n")
subtitle := lipgloss.NewStyle().
Foreground(colorGray).
Render("⚔ A Cooperative Dungeon Crawler ⚔")
server := lipgloss.NewStyle().
Foreground(colorCyan).
Render("ssh catacombs.tolelom.xyz")
menu := lipgloss.NewStyle().
Foreground(colorWhite).
Bold(true).
Render("[Enter] Start [Q] Quit")
content := lipgloss.JoinVertical(lipgloss.Center,
logo,
"",
subtitle,
server,
"",
"",
menu,
)
// Center on screen
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content)
}
```
- [ ] **Step 2: Build and test**
```bash
go build ./...
go test ./ui/ -timeout 15s
```
- [ ] **Step 3: Commit**
```bash
git add ui/title.go
git commit -m "feat: gradient title screen with centered layout"
```
---
### Task 6: Final integration test
- [ ] **Step 1: Build and run all tests**
```bash
go build ./...
go test ./... -timeout 30s
```
- [ ] **Step 2: Verify no dead code or unused imports**
```bash
go vet ./...
```

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,138 @@
# Terminal Visuals Enhancement — Design Spec
## Overview
Catacombs 터미널 비주얼 개선. 4가지 영역: 몬스터 아스키아트, 전투 레이아웃 리디자인, 컬러 효과, 타이틀 화면.
## 1. Monster ASCII Art
몬스터 타입별 3~4줄 아스키아트. 전투 HUD의 ENEMIES 패널에 표시.
```
일반 몬스터 (3줄):
/\OO/\ ,--. .---. /|||\
\ / |oo| /o o\ |===|
|__| /||\ | --- | | |
Slime Skeleton Orc Dark Knight
보스 (5줄):
[B5] [B10] [B15] [B20]
/\ /\ __|__ ,=====. ___/\___
| @ @ | /|o o|\ / \ / \ | x x |
| || | | === | | (O) | | === |
| \__/ | |\___/| \ |=| / |\_____/|
\ / |___| `===' |_| |_|
Guardian Warden Overlord Archlich
```
`entity/monster.go``Art() []string` 메서드 추가. 타입별 하드코딩된 문자열 슬라이스 반환.
## 2. Combat Layout Redesign
lipgloss 박스드로잉으로 전투 화면을 패널 구조로 리디자인.
```
┌─ Catacombs B3 ──────────────────────────────────────────────┐
│ [미니맵 영역] │
├────────────────────────────┬────────────────────────────────┤
│ PARTY │ ENEMIES │
│ │ │
│ ♦ Tank (Warrior) │ /\OO/\ │
│ ██████████░░░░ 80/120 │ \ / > [0] Slime 8/20 │
│ ATK:15 DEF:10 │ |__| │
│ │ │
│ ♦ Mage (Mage) │ ,--. │
│ ██████░░░░░░░░ 45/70 │ |oo| [1] Skeleton 30/35 │
│ ATK:23 DEF:5 │ /||\ │
│ │ │
├────────────────────────────┴────────────────────────────────┤
│ > Tank hit Slime for 12 dmg │
│ > Mage hit all enemies for 18 total dmg (co-op!) │
├─────────────────────────────────────────────────────────────┤
│ [1]Attack [2]Skill [3]Item [4]Flee [5]Wait [Tab]Target │
│ Skill: Taunt — enemies attack you for 2 turns Timer: 4.2s│
└─────────────────────────────────────────────────────────────┘
```
구조:
- 상단: 미니맵 (dungeon.RenderFloor 호출, 축소)
- 중단 좌: PARTY 패널 — 각 플레이어 이름, 클래스, HP바, ATK/DEF
- 중단 우: ENEMIES 패널 — 아스키아트 + 이름 + HP바 + 타겟 커서
- 하단 로그: CombatLog 메시지 (컬러 적용)
- 최하단: 액션 바 + 타이머
탐험 모드에서는 중단이 맵 + 방 목록으로 전환.
lipgloss `JoinHorizontal`/`JoinVertical` + `Border` 사용.
## 3. Color Effects
로그 메시지와 HUD 요소에 의미적 컬러 적용:
| 요소 | 색상 | lipgloss Color |
|------|------|---------------|
| 데미지 수치 | 빨강 볼드 | `"196"` Bold |
| 힐 수치 | 초록 볼드 | `"46"` Bold |
| co-op 보너스 | 노랑 볼드 | `"226"` Bold |
| 도주 | 시안 | `"51"` |
| 상태효과(도발 등) | 마젠타 | `"201"` |
| 몬스터 이름 | 빨강 | `"196"` |
| 플레이어 이름 | 흰색 볼드 | `"255"` Bold |
| 골드 | 노랑 | `"226"` |
| 시스템 메시지 | 회색 이탤릭 | `"240"` Italic |
HP 바 3단계 색상:
- 50%↑: 초록 (`"46"`)
- 25~50%: 노랑 (`"226"`)
- 25%↓: 빨강 (`"196"`)
로그 메시지에 인라인 컬러링 적용. `addLog` 대신 구조화된 로그 메시지 사용:
```go
type LogEntry struct {
Text string
Color string // lipgloss color code
}
```
또는 더 간단하게: 로그 문자열에 lipgloss 스타일을 렌더링 시점에 적용. 로그 메시지에 키워드 패턴 매칭으로 자동 컬러링.
**선택: 렌더링 시점 패턴 매칭 방식.** `addLog`는 plain text 유지. `renderCombatLog`에서 숫자+`dmg` → 빨강, 숫자+`HP` → 초록, `co-op` → 노랑, `fled` → 시안 등 매칭.
## 4. Title Screen
대형 아스키아트 로고 + 분위기 서브텍스트:
```
██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗
██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝
██║ ███████║ ██║ ███████║██║ ██║ ██║██╔████╔██║██████╔╝███████╗
██║ ██╔══██║ ██║ ██╔══██║██║ ██║ ██║██║╚██╔╝██║██╔══██╗╚════██║
╚██████╗██║ ██║ ██║ ██║ ██║╚██████╗╚██████╔╝██║ ╚═╝ ██║██████╔╝███████║
╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝
⚔ A Cooperative Dungeon Crawler ⚔
ssh catacombs.tolelom.xyz
```
로고는 그라데이션 컬러 (lipgloss로 행별 색상 변화: 빨강→주황→노랑).
메뉴:
```
[Enter] Start
[Q] Quit
```
센터 정렬 (lipgloss `Place` 사용).
## File Map
```
entity/monster.go — Art() 메서드 추가
ui/ascii_art.go — NEW: 몬스터/보스 아스키아트 데이터
ui/game_view.go — 전투 레이아웃 리디자인, 컬러 로그
ui/title.go — 타이틀 화면 개선
ui/styles.go — NEW: 공유 lipgloss 스타일 상수
```

View File

@@ -0,0 +1,82 @@
# Web Terminal Frontend — Design Spec
## Overview
xterm.js 기반 웹 브라우저 터미널. WebSocket → SSH 프록시 방식으로 기존 SSH 게임 서버에 접속.
## Architecture
```
Browser (xterm.js) ←WebSocket→ Go HTTP Server (:8080) ←SSH→ Wish SSH Server (:2222)
```
단일 Go 바이너리에서 SSH 서버와 HTTP 서버를 동시에 실행.
## Server: web/server.go
- `net/http``:8080` 리스닝
- `/` — 정적 파일 서빙 (`web/static/` 디렉토리, embed 사용)
- `/ws` — WebSocket 엔드포인트
### WebSocket → SSH 프록시 흐름
1. 클라이언트가 `/ws`에 WebSocket 연결
2. 서버가 `golang.org/x/crypto/ssh``localhost:2222`에 비밀번호 인증 SSH 접속
3. SSH 세션에서 PTY 요청 (초기 크기: 80x24)
4. 두 goroutine으로 양방향 중계:
- SSH stdout → WebSocket 텍스트 프레임
- WebSocket 텍스트 프레임 → SSH stdin
5. 리사이즈: WebSocket 바이너리 프레임으로 `{"type":"resize","cols":N,"rows":N}` 수신 → SSH WindowChange 요청
6. 연결 종료: 어느 쪽이든 끊기면 양쪽 모두 정리
### SSH 접속 설정
- Host: `localhost:2222`
- Auth: 비밀번호 인증 (빈 비밀번호 — 게임 서버가 모든 비밀번호 수용)
- HostKey: InsecureIgnoreHostKey (로컬 접속)
- User: `web-player`
## Client: web/static/index.html
단일 HTML 파일, 빌드 도구 없음.
### CDN 의존성
- `xterm` — 터미널 에뮬레이터
- `xterm-addon-fit` — 터미널을 컨테이너에 맞춤
### 기능
- 전체화면 다크 테마 터미널
- 자동 WebSocket 연결 (`ws://` or `wss://`)
- xterm.js `onData` → WebSocket send (키 입력)
- WebSocket `onmessage` → xterm.js `write` (출력)
- `FitAddon`으로 브라우저 리사이즈 감지 → 리사이즈 메시지 전송
- 연결 끊김 시 재연결 안내 표시
### 스타일
- 배경: `#1a1a2e` (진한 네이비)
- 폰트: 시스템 모노스페이스
- 터미널이 화면 전체를 차지 (margin: 0, overflow: hidden)
## Dependencies
- `github.com/gorilla/websocket` — WebSocket 업그레이드/핸들링
- `golang.org/x/crypto/ssh` — 이미 프로젝트에 포함
## File Map
```
web/
├── server.go — HTTP 서버 + WebSocket→SSH 프록시
└── static/
└── index.html — xterm.js 클라이언트 (단일 파일)
main.go — web.Start() 호출 추가
docker-compose.yml — 8080 포트 노출 추가
```
## Deployment
- Docker에서 2222 (SSH) + 8080 (HTTP) 두 포트 노출
- Caddy/nginx 리버스 프록시로 `play.catacombs.tolelom.xyz``:8080` 가능

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

@@ -2,6 +2,25 @@ package dungeon
import "fmt"
type FloorTheme struct {
WallColor string
FloorColor string
Name string
}
func GetFloorTheme(floorNum int) FloorTheme {
switch {
case floorNum <= 5:
return FloorTheme{"90", "245", "Stone Halls"}
case floorNum <= 10:
return FloorTheme{"22", "28", "Mossy Caverns"}
case floorNum <= 15:
return FloorTheme{"88", "202", "Lava Depths"}
default:
return FloorTheme{"53", "129", "Shadow Realm"}
}
}
// ANSI color codes
const (
ansiReset = "\033[0m"
@@ -40,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++ {
@@ -82,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]
}
}
}
@@ -95,6 +146,7 @@ func wallVisible(floor *Floor, owner [][]int, x, y int) Visibility {
// RenderFloor renders the tile map as a colored ASCII string.
func RenderFloor(floor *Floor, currentRoom int, showFog bool) string {
theme := GetFloorTheme(floor.Number)
if floor == nil || floor.Tiles == nil {
return ""
}
@@ -135,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++ {
@@ -153,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
}
@@ -200,7 +255,14 @@ func RenderFloor(floor *Floor, currentRoom int, showFog bool) string {
}
if vis == Visible {
buf = append(buf, []byte(fmt.Sprintf("%s%s%c%s", ansiBright, ansiFgWhite, ch, ansiReset))...)
switch tile {
case TileWall:
buf = append(buf, []byte(fmt.Sprintf("\033[38;5;%sm%c\033[0m", theme.WallColor, ch))...)
case TileFloor:
buf = append(buf, []byte(fmt.Sprintf("\033[38;5;%sm%c\033[0m", theme.FloorColor, ch))...)
default:
buf = append(buf, []byte(fmt.Sprintf("%s%s%c%s", ansiBright, ansiFgWhite, ch, ansiReset))...)
}
} else {
// Visited but not current — dim
buf = append(buf, []byte(fmt.Sprintf("%s%c%s", ansiFgGray, ch, ansiReset))...)

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

@@ -22,6 +22,9 @@ const (
RelicATKBoost
RelicDEFBoost
RelicGoldBoost
RelicPoisonImmunity // immune to poison
RelicBurnResist // halve burn damage
RelicLifeSteal // heal 10% of damage dealt
)
type Relic struct {

View File

@@ -13,6 +13,10 @@ const (
MonsterBoss10
MonsterBoss15
MonsterBoss20
MonsterMiniBoss5
MonsterMiniBoss10
MonsterMiniBoss15
MonsterMiniBoss20
)
type monsterBase struct {
@@ -31,34 +35,57 @@ 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
const (
PatternNone BossPattern = iota
PatternAoE // every 3 turns AoE
PatternPoison // applies poison
PatternBurn // applies burn to random player
PatternHeal // heals self
PatternFreeze // applies freeze to all players
)
type Monster struct {
Name string
Type MonsterType
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: base.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,8 +18,75 @@ 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)
}
}
func TestMonsterDEFScaling(t *testing.T) {
// Slime base DEF=1, minFloor=1. At floor 5, scale = 1.15^4 ≈ 1.749
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, 1.15)
if boss.DEF != 8 {
t.Errorf("Boss5 DEF should be base 8, got %d", boss.DEF)
}
}
func TestTickTaunt(t *testing.T) {
m := &Monster{Name: "Orc", HP: 50, TauntTarget: true, TauntTurns: 2}
m.TickTaunt()
if m.TauntTurns != 1 || !m.TauntTarget {
t.Error("should still be taunted with 1 turn left")
}
m.TickTaunt()
if m.TauntTurns != 0 || m.TauntTarget {
t.Error("taunt should be cleared at 0")
}
}
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, 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

@@ -1,5 +1,7 @@
package entity
import "fmt"
type Class int
const (
@@ -24,6 +26,22 @@ var classBaseStats = map[Class]classStats{
ClassRogue: {85, 15, 4},
}
type StatusEffect int
const (
StatusPoison StatusEffect = iota
StatusBurn
StatusFreeze
StatusBleed
StatusCurse
)
type ActiveEffect struct {
Type StatusEffect
Duration int // remaining turns
Value int // damage per turn or effect strength
}
type Player struct {
Name string
Fingerprint string
@@ -33,7 +51,11 @@ type Player struct {
Gold int
Inventory []Item
Relics []Relic
Effects []ActiveEffect
Dead bool
Fled bool
SkillUses int // remaining skill uses this combat
Skills *PlayerSkills
}
func NewPlayer(name string, class Class) *Player {
@@ -57,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
@@ -67,6 +95,10 @@ func (p *Player) IsDead() bool {
return p.Dead
}
func (p *Player) IsOut() bool {
return p.Dead || p.Fled
}
func (p *Player) Revive(hpPercent float64) {
p.Dead = false
p.HP = int(float64(p.MaxHP) * hpPercent)
@@ -87,6 +119,7 @@ func (p *Player) EffectiveATK() int {
atk += r.Value
}
}
atk += p.Skills.GetATKBonus(p.Class)
return atk
}
@@ -102,5 +135,78 @@ func (p *Player) EffectiveDEF() int {
def += r.Value
}
}
def += p.Skills.GetDEFBonus(p.Class)
return def
}
func (p *Player) AddEffect(e ActiveEffect) {
// Check relic immunities
for _, r := range p.Relics {
if e.Type == StatusPoison && r.Effect == RelicPoisonImmunity {
return // immune
}
if e.Type == StatusBurn && r.Effect == RelicBurnResist {
e.Value = e.Value / 2 // halve burn damage
}
}
// Don't stack same type, refresh duration
for i, existing := range p.Effects {
if existing.Type == e.Type {
p.Effects[i] = e
return
}
}
p.Effects = append(p.Effects, e)
}
func (p *Player) HasEffect(t StatusEffect) bool {
for _, e := range p.Effects {
if e.Type == t {
return true
}
}
return false
}
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
}
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
}
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)
}
}
p.Effects = remaining
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)
@@ -63,3 +66,176 @@ func TestPlayerTakeDamage(t *testing.T) {
t.Error("Player should be dead")
}
}
func TestIsOut(t *testing.T) {
p := NewPlayer("test", ClassWarrior)
if p.IsOut() {
t.Error("alive player should not be out")
}
p.Dead = true
if !p.IsOut() {
t.Error("dead player should be out")
}
p.Dead = false
p.Fled = true
if !p.IsOut() {
t.Error("fled player should be out")
}
}
func TestRevive(t *testing.T) {
p := NewPlayer("test", ClassWarrior) // 120 MaxHP
p.TakeDamage(200)
if !p.IsDead() {
t.Error("should be dead")
}
p.Revive(0.30)
if p.IsDead() {
t.Error("should be alive after revive")
}
if p.HP != 36 { // 120 * 0.30
t.Errorf("HP should be 36, got %d", p.HP)
}
}
func TestHealCap(t *testing.T) {
p := NewPlayer("test", ClassWarrior) // 120 HP
p.HP = 100
p.Heal(50) // should cap at 120
if p.HP != 120 {
t.Errorf("HP should cap at 120, got %d", p.HP)
}
}
func TestEffectiveATKWithItems(t *testing.T) {
p := NewPlayer("test", ClassWarrior) // base ATK 12
p.Inventory = append(p.Inventory, Item{Name: "Sword", Type: ItemWeapon, Bonus: 5})
p.Inventory = append(p.Inventory, Item{Name: "Sword2", Type: ItemWeapon, Bonus: 3})
if p.EffectiveATK() != 20 { // 12 + 5 + 3
t.Errorf("ATK should be 20, got %d", p.EffectiveATK())
}
}
func TestEffectiveDEFWithItems(t *testing.T) {
p := NewPlayer("test", ClassWarrior) // base DEF 8
p.Inventory = append(p.Inventory, Item{Name: "Shield", Type: ItemArmor, Bonus: 4})
if p.EffectiveDEF() != 12 { // 8 + 4
t.Errorf("DEF should be 12, got %d", p.EffectiveDEF())
}
}
func TestStatusEffectPoison(t *testing.T) {
p := NewPlayer("test", ClassWarrior) // 120 HP
p.AddEffect(ActiveEffect{Type: StatusPoison, Duration: 2, Value: 10})
if !p.HasEffect(StatusPoison) {
t.Error("should have poison")
}
msgs := p.TickEffects()
if len(msgs) != 1 {
t.Errorf("expected 1 message, got %d", len(msgs))
}
if p.HP != 110 {
t.Errorf("HP should be 110 after poison tick, got %d", p.HP)
}
// Poison can't kill
p.HP = 5
p.TickEffects() // duration expires after this tick
if p.HP != 1 {
t.Errorf("poison should leave at 1 HP, got %d", p.HP)
}
if p.IsDead() {
t.Error("poison should not kill")
}
if p.HasEffect(StatusPoison) {
t.Error("poison should have expired")
}
}
func TestStatusEffectBurn(t *testing.T) {
p := NewPlayer("test", ClassMage) // 70 HP
p.AddEffect(ActiveEffect{Type: StatusBurn, Duration: 1, Value: 100})
p.TickEffects()
if !p.IsDead() {
t.Error("burn should be able to kill")
}
}
func TestRelicPoisonImmunity(t *testing.T) {
p := NewPlayer("test", ClassWarrior)
p.Relics = append(p.Relics, Relic{Name: "Antidote", Effect: RelicPoisonImmunity})
p.AddEffect(ActiveEffect{Type: StatusPoison, Duration: 3, Value: 10})
if p.HasEffect(StatusPoison) {
t.Error("should be immune to poison")
}
}
func TestRelicBurnResist(t *testing.T) {
p := NewPlayer("test", ClassWarrior)
p.Relics = append(p.Relics, Relic{Name: "Flame Guard", Effect: RelicBurnResist})
p.AddEffect(ActiveEffect{Type: StatusBurn, Duration: 2, Value: 10})
// Burn value should be halved to 5
if len(p.Effects) == 0 {
t.Fatal("should have burn effect (resisted, not immune)")
}
if p.Effects[0].Value != 5 {
t.Errorf("burn value should be halved to 5, got %d", p.Effects[0].Value)
}
}
func TestEffectOverwrite(t *testing.T) {
p := NewPlayer("test", ClassWarrior)
p.AddEffect(ActiveEffect{Type: StatusPoison, Duration: 1, Value: 5})
p.AddEffect(ActiveEffect{Type: StatusPoison, Duration: 3, Value: 10}) // should overwrite
if len(p.Effects) != 1 {
t.Errorf("should have 1 effect, got %d", len(p.Effects))
}
if p.Effects[0].Duration != 3 || p.Effects[0].Value != 10 {
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

@@ -1,7 +1,9 @@
package game
import (
"fmt"
"math/rand"
"time"
"github.com/tolelom/catacombs/dungeon"
"github.com/tolelom/catacombs/entity"
@@ -11,6 +13,18 @@ func (s *GameSession) EnterRoom(roomIdx int) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for _, p := range s.state.Players {
if p.Fingerprint != "" {
s.lastActivity[p.Fingerprint] = now
}
}
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]
@@ -31,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:
@@ -39,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
}
@@ -72,16 +99,31 @@ 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 = 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 = s.cfg.Game.SkillUses
}
}
func (s *GameSession) spawnBoss() {
@@ -98,51 +140,251 @@ 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.PatternPoison // Swamp theme
case entity.MonsterBoss10:
boss.Pattern = entity.PatternBurn // Volcano theme
case entity.MonsterBoss15:
boss.Pattern = entity.PatternFreeze // Glacier theme
case entity.MonsterBoss20:
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 = 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 = s.cfg.Game.SkillUses
}
}
func (s *GameSession) grantTreasure() {
// Random item for each player
floor := s.state.FloorNum
for _, p := range s.state.Players {
if len(p.Inventory) >= s.cfg.Game.InventoryLimit {
s.addLog(fmt.Sprintf("%s의 인벤토리가 가득 찼습니다!", p.Name))
continue
}
if rand.Float64() < 0.5 {
p.Inventory = append(p.Inventory, entity.Item{
Name: "Iron Sword", Type: entity.ItemWeapon, Bonus: 3 + rand.Intn(6),
})
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 {
p.Inventory = append(p.Inventory, entity.Item{
Name: "Iron Shield", Type: entity.ItemArmor, Bonus: 2 + rand.Intn(4),
})
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) generateShopItems() {
floor := s.state.FloorNum
// Weapon bonus scales: base 3-8 + floor/3
weaponBonus := 3 + rand.Intn(6) + floor/3
// Armor bonus scales: base 2-5 + floor/4
armorBonus := 2 + rand.Intn(4) + floor/4
// Prices scale with power
weaponPrice := 40 + weaponBonus*5
armorPrice := 30 + armorBonus*5
// Potion heals more on higher floors
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: 30, Price: 20},
{Name: "Iron Sword", Type: entity.ItemWeapon, Bonus: 3 + rand.Intn(6), Price: 40 + rand.Intn(41)},
{Name: "Iron Shield", Type: entity.ItemArmor, Bonus: 2 + rand.Intn(4), Price: 30 + rand.Intn(31)},
{Name: "HP Potion", Type: entity.ItemConsumable, Bonus: potionHeal, Price: potionPrice},
{Name: weaponName(floor), Type: entity.ItemWeapon, Bonus: weaponBonus, Price: weaponPrice},
{Name: armorName(floor), Type: entity.ItemArmor, Bonus: armorBonus, Price: armorPrice},
}
}
func weaponName(floor int) string {
switch {
case floor >= 15:
return "Mythril Blade"
case floor >= 10:
return "Steel Sword"
case floor >= 5:
return "Bronze Sword"
default:
return "Iron Sword"
}
}
func armorName(floor int) string {
switch {
case floor >= 15:
return "Mythril Shield"
case floor >= 10:
return "Steel Shield"
case floor >= 5:
return "Bronze Shield"
default:
return "Iron Shield"
}
}
func (s *GameSession) triggerEvent() {
// Random event: 50% trap, 50% blessing
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 {
// Trap: 10~20 damage
dmg := 10 + rand.Intn(11)
p.TakeDamage(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 {
// Blessing: heal 15~25
heal := 15 + rand.Intn(11)
p.Heal(heal)
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
@@ -13,21 +16,115 @@ const (
RoomPlaying
)
type LobbyPlayer struct {
Name string
Class string // empty until class selected
Fingerprint string
Ready bool
}
type LobbyRoom struct {
Code string
Name string
Players []string
Players []LobbyPlayer
Status RoomStatus
Session *GameSession
}
type Lobby struct {
mu sync.RWMutex
rooms map[string]*LobbyRoom
type OnlinePlayer struct {
Name string
Fingerprint string
InRoom string // room code, empty if in lobby
}
func NewLobby() *Lobby {
return &Lobby{rooms: make(map[string]*LobbyRoom)}
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(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()
l.activeSessions[fingerprint] = roomCode
}
func (l *Lobby) UnregisterSession(fingerprint string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.activeSessions, fingerprint)
}
func (l *Lobby) GetActiveSession(fingerprint string) (string, *GameSession) {
l.mu.RLock()
defer l.mu.RUnlock()
code, ok := l.activeSessions[fingerprint]
if !ok {
return "", nil
}
room, ok := l.rooms[code]
if !ok || room.Session == nil {
return "", nil
}
// Check if this player is still in the session
for _, p := range room.Session.GetState().Players {
if p.Fingerprint == fingerprint {
return code, room.Session
}
}
return "", nil
}
func (l *Lobby) PlayerOnline(fingerprint, name string) {
l.mu.Lock()
defer l.mu.Unlock()
l.online[fingerprint] = &OnlinePlayer{Name: name, Fingerprint: fingerprint}
}
func (l *Lobby) PlayerOffline(fingerprint string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.online, fingerprint)
}
func (l *Lobby) ListOnline() []*OnlinePlayer {
l.mu.RLock()
defer l.mu.RUnlock()
result := make([]*OnlinePlayer, 0, len(l.online))
for _, p := range l.online {
result = append(result, p)
}
return result
}
func (l *Lobby) InvitePlayer(roomCode, fingerprint string) error {
l.mu.Lock()
defer l.mu.Unlock()
p, ok := l.online[fingerprint]
if !ok {
return fmt.Errorf("플레이어가 온라인이 아닙니다")
}
if p.InRoom != "" {
return fmt.Errorf("플레이어가 이미 방에 있습니다")
}
// Store the invite as a pending field
p.InRoom = "invited:" + roomCode
return nil
}
func (l *Lobby) CreateRoom(name string) string {
@@ -42,26 +139,86 @@ func (l *Lobby) CreateRoom(name string) string {
Name: name,
Status: RoomWaiting,
}
slog.Info("room created", "code", code, "name", name)
return code
}
func (l *Lobby) JoinRoom(code, playerName string) error {
func (l *Lobby) JoinRoom(code, playerName, fingerprint string) error {
l.mu.Lock()
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, playerName)
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()
if room, ok := l.rooms[code]; ok {
for i := range room.Players {
if room.Players[i].Fingerprint == fingerprint {
room.Players[i].Class = class
}
}
}
}
func (l *Lobby) SetPlayerReady(code, fingerprint string, ready bool) {
l.mu.Lock()
defer l.mu.Unlock()
if room, ok := l.rooms[code]; ok {
for i := range room.Players {
if room.Players[i].Fingerprint == fingerprint {
room.Players[i].Ready = ready
}
}
}
}
func (l *Lobby) AllReady(code string) bool {
l.mu.RLock()
defer l.mu.RUnlock()
room, ok := l.rooms[code]
if !ok || len(room.Players) == 0 {
return false
}
for _, p := range room.Players {
if !p.Ready {
return false
}
}
return true
}
func (l *Lobby) GetRoom(code string) *LobbyRoom {
l.mu.RLock()
defer l.mu.RUnlock()
@@ -78,6 +235,15 @@ func (l *Lobby) ListRooms() []*LobbyRoom {
return result
}
func (l *Lobby) StartRoom(code string) {
l.mu.Lock()
defer l.mu.Unlock()
if room, ok := l.rooms[code]; ok {
room.Status = RoomPlaying
slog.Info("game started", "room", code, "players", len(room.Players))
}
}
func (l *Lobby) RemoveRoom(code string) {
l.mu.Lock()
defer l.mu.Unlock()

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,9 +25,9 @@ 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")
err := lobby.JoinRoom(code, "player1", "fp-player1")
if err != nil {
t.Errorf("Join failed: %v", err)
}
@@ -27,14 +37,73 @@ func TestJoinRoom(t *testing.T) {
}
}
func TestRoomStatusTransition(t *testing.T) {
l := NewLobby(testConfig(t))
code := l.CreateRoom("Test")
l.JoinRoom(code, "Alice", "fp-alice")
r := l.GetRoom(code)
if r.Status != RoomWaiting {
t.Errorf("new room should be Waiting, got %d", r.Status)
}
l.StartRoom(code)
r = l.GetRoom(code)
if r.Status != RoomPlaying {
t.Errorf("started room should be Playing, got %d", r.Status)
}
err := l.JoinRoom(code, "Bob", "fp-bob")
if err == nil {
t.Error("should not be able to join a Playing room")
}
}
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")
lobby.JoinRoom(code, "player", "fp-player")
}
err := lobby.JoinRoom(code, "player5")
err := lobby.JoinRoom(code, "player5", "fp-player5")
if err == nil {
t.Error("Should reject 5th player")
}
}
func TestSetPlayerClass(t *testing.T) {
l := NewLobby(testConfig(t))
code := l.CreateRoom("Test")
l.JoinRoom(code, "Alice", "fp-alice")
l.SetPlayerClass(code, "fp-alice", "Warrior")
room := l.GetRoom(code)
if room.Players[0].Class != "Warrior" {
t.Errorf("expected class Warrior, got %s", room.Players[0].Class)
}
}
func TestAllReady(t *testing.T) {
l := NewLobby(testConfig(t))
code := l.CreateRoom("Test")
l.JoinRoom(code, "Alice", "fp-alice")
l.JoinRoom(code, "Bob", "fp-bob")
if l.AllReady(code) {
t.Error("no one ready yet, should return false")
}
l.SetPlayerReady(code, "fp-alice", true)
if l.AllReady(code) {
t.Error("only Alice ready, should return false")
}
l.SetPlayerReady(code, "fp-bob", true)
if !l.AllReady(code) {
t.Error("both ready, should return true")
}
}
func TestAllReadyEmptyRoom(t *testing.T) {
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

@@ -1,9 +1,13 @@
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"
)
@@ -44,15 +48,25 @@ type GameState struct {
GameOver bool
Victory bool
ShopItems []entity.Item
CombatLog []string // recent combat messages
TurnDeadline time.Time
CombatLog []string // recent combat messages
TurnDeadline time.Time
SubmittedActions map[string]string // fingerprint -> action description
PendingLogs []string // logs waiting to be revealed one by one
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) {
s.state.CombatLog = append(s.state.CombatLog, msg)
// Keep last 5 messages
if len(s.state.CombatLog) > 5 {
s.state.CombatLog = s.state.CombatLog[len(s.state.CombatLog)-5:]
if s.state.TurnResolving {
s.state.PendingLogs = append(s.state.PendingLogs, msg)
} else {
s.state.CombatLog = append(s.state.CombatLog, msg)
if len(s.state.CombatLog) > 8 {
s.state.CombatLog = s.state.CombatLog[len(s.state.CombatLog)-8:]
}
}
}
@@ -61,27 +75,59 @@ 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{}
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 {
PlayerName string
Action PlayerAction
PlayerID string
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,
},
actions: make(map[string]PlayerAction),
actionCh: make(chan playerActionMsg, 4),
combatSignal: make(chan struct{}, 1),
done: make(chan struct{}),
lastActivity: make(map[string]time.Time),
}
}
// 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:
// already stopped
default:
close(s.done)
}
}
@@ -94,6 +140,10 @@ func (s *GameSession) StartGame() {
}
s.started = true
s.state.SoloMode = len(s.state.Players) == 1
now := time.Now()
for _, p := range s.state.Players {
s.lastActivity[p.Fingerprint] = now
}
s.mu.Unlock()
s.StartFloor()
go s.combatLoop()
@@ -102,23 +152,57 @@ func (s *GameSession) StartGame() {
// combatLoop continuously runs turns while in combat phase
func (s *GameSession) combatLoop() {
for {
select {
case <-s.done:
return
default:
}
s.mu.Lock()
phase := s.state.Phase
gameOver := s.state.GameOver
s.mu.Unlock()
if gameOver {
slog.Info("game over", "floor", s.state.FloorNum, "victory", s.state.Victory)
return
}
// Remove players inactive for >60 seconds
s.mu.Lock()
now := time.Now()
changed := false
remaining := make([]*entity.Player, 0, len(s.state.Players))
for _, p := range s.state.Players {
if p.Fingerprint != "" && !p.IsOut() {
if last, ok := s.lastActivity[p.Fingerprint]; ok {
if now.Sub(last) > 60*time.Second {
slog.Warn("player inactive removed", "fingerprint", p.Fingerprint, "name", p.Name)
s.addLog(fmt.Sprintf("%s 제거됨 (접속 끊김)", p.Name))
changed = true
continue
}
}
}
remaining = append(remaining, p)
}
if changed {
s.state.Players = remaining
if len(s.state.Players) == 0 {
s.state.GameOver = true
s.mu.Unlock()
return
}
}
s.mu.Unlock()
if phase == PhaseCombat {
s.RunTurn() // blocks until all actions collected or timeout
s.RunTurn()
} else {
// Not in combat, wait for an action signal to avoid busy-spinning
// We'll just sleep briefly and re-check
select {
case <-s.combatSignal:
// Room entered, combat may have started
case <-s.done:
return
}
}
}
@@ -134,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
@@ -155,29 +255,211 @@ func (s *GameSession) StartFloor() {
func (s *GameSession) GetState() GameState {
s.mu.Lock()
defer s.mu.Unlock()
return s.state
// Deep copy players
players := make([]*entity.Player, len(s.state.Players))
for i, p := range s.state.Players {
cp := *p
cp.Inventory = make([]entity.Item, len(p.Inventory))
copy(cp.Inventory, p.Inventory)
cp.Relics = make([]entity.Relic, len(p.Relics))
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
}
// Deep copy monsters
monsters := make([]*entity.Monster, len(s.state.Monsters))
for i, m := range s.state.Monsters {
cm := *m
monsters[i] = &cm
}
// Deep copy floor
var floorCopy *dungeon.Floor
if s.state.Floor != nil {
fc := *s.state.Floor
fc.Rooms = make([]*dungeon.Room, len(s.state.Floor.Rooms))
for i, r := range s.state.Floor.Rooms {
rc := *r
rc.Neighbors = make([]int, len(r.Neighbors))
copy(rc.Neighbors, r.Neighbors)
fc.Rooms[i] = &rc
}
floorCopy = &fc
}
// Copy combat log
logCopy := make([]string, len(s.state.CombatLog))
copy(logCopy, s.state.CombatLog)
// Copy submitted actions
submittedCopy := make(map[string]string, len(s.state.SubmittedActions))
for k, v := range s.state.SubmittedActions {
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)
return GameState{
Floor: floorCopy,
Players: players,
Monsters: monsters,
Phase: s.state.Phase,
FloorNum: s.state.FloorNum,
TurnNum: s.state.TurnNum,
CombatTurn: s.state.CombatTurn,
SoloMode: s.state.SoloMode,
GameOver: s.state.GameOver,
Victory: s.state.Victory,
ShopItems: append([]entity.Item{}, s.state.ShopItems...),
CombatLog: logCopy,
TurnDeadline: s.state.TurnDeadline,
SubmittedActions: submittedCopy,
PendingLogs: pendingCopy,
TurnResolving: s.state.TurnResolving,
BossKilled: s.state.BossKilled,
FleeSucceeded: s.state.FleeSucceeded,
LastEventName: s.state.LastEventName,
MoveVotes: moveVotesCopy,
}
}
func (s *GameSession) SubmitAction(playerName string, action PlayerAction) {
s.actionCh <- playerActionMsg{PlayerName: playerName, Action: action}
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 = "공격"
case ActionSkill:
desc = "스킬 사용"
case ActionItem:
desc = "아이템 사용"
case ActionFlee:
desc = "도주"
case ActionWait:
desc = "방어"
}
if s.state.SubmittedActions == nil {
s.state.SubmittedActions = make(map[string]string)
}
s.state.SubmittedActions[playerID] = desc
s.mu.Unlock()
s.actionCh <- playerActionMsg{PlayerID: playerID, Action: action}
}
// RevealNextLog moves one log from PendingLogs to CombatLog. Returns true if there was one to reveal.
func (s *GameSession) RevealNextLog() bool {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.state.PendingLogs) == 0 {
return false
}
msg := s.state.PendingLogs[0]
s.state.PendingLogs = s.state.PendingLogs[1:]
s.state.CombatLog = append(s.state.CombatLog, msg)
if len(s.state.CombatLog) > 8 {
s.state.CombatLog = s.state.CombatLog[len(s.state.CombatLog)-8:]
}
return true
}
func (s *GameSession) TouchActivity(fingerprint string) {
s.mu.Lock()
defer s.mu.Unlock()
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(playerName 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.Name == playerName && p.Gold >= item.Price {
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()
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
@@ -187,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,12 +4,52 @@ 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(testCfg(t))
p := entity.NewPlayer("Racer", entity.ClassWarrior)
p.Fingerprint = "test-fp"
s.AddPlayer(p)
s.StartGame()
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 100; i++ {
st := s.GetState()
for _, p := range st.Players {
_ = p.HP
_ = p.Gold
}
for _, m := range st.Monsters {
_ = m.HP
}
}
}()
for i := 0; i < 10; i++ {
select {
case s.actionCh <- playerActionMsg{PlayerID: "test-fp", Action: PlayerAction{Type: ActionWait}}:
default:
}
time.Sleep(10 * time.Millisecond)
}
<-done
}
func TestSessionTurnTimeout(t *testing.T) {
s := NewGameSession()
s := NewGameSession(testCfg(t))
p := entity.NewPlayer("test", entity.ClassWarrior)
p.Fingerprint = "test-fp"
s.AddPlayer(p)
s.StartFloor()
@@ -23,7 +63,95 @@ 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(testCfg(t))
// No logs to reveal
if s.RevealNextLog() {
t.Error("should return false when no pending logs")
}
// Manually add pending logs
s.mu.Lock()
s.state.PendingLogs = []string{"msg1", "msg2", "msg3"}
s.mu.Unlock()
if !s.RevealNextLog() {
t.Error("should return true when log revealed")
}
st := s.GetState()
if len(st.CombatLog) != 1 || st.CombatLog[0] != "msg1" {
t.Errorf("expected [msg1], got %v", st.CombatLog)
}
if len(st.PendingLogs) != 2 {
t.Errorf("expected 2 pending, got %d", len(st.PendingLogs))
}
// Reveal remaining
s.RevealNextLog()
s.RevealNextLog()
if s.RevealNextLog() {
t.Error("should return false after all revealed")
}
}
func TestDeepCopyIndependence(t *testing.T) {
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})
s.AddPlayer(p)
state := s.GetState()
// Mutate the copy
state.Players[0].HP = 999
state.Players[0].Inventory = append(state.Players[0].Inventory, entity.Item{Name: "Shield"})
// Original should be unchanged
origState := s.GetState()
if origState.Players[0].HP == 999 {
t.Error("deep copy failed: HP mutation leaked to original")
}
if len(origState.Players[0].Inventory) != 1 {
t.Error("deep copy failed: inventory mutation leaked to original")
}
}
func TestBuyItemInventoryFull(t *testing.T) {
s := NewGameSession(testCfg(t))
p := entity.NewPlayer("Buyer", entity.ClassWarrior)
p.Fingerprint = "fp-buyer"
p.Gold = 1000
// Fill inventory to 10
for i := 0; i < 10; i++ {
p.Inventory = append(p.Inventory, entity.Item{Name: "Junk"})
}
s.AddPlayer(p)
s.mu.Lock()
s.state.Phase = PhaseShop
s.state.ShopItems = []entity.Item{
{Name: "Potion", Type: entity.ItemConsumable, Bonus: 30, Price: 10},
}
s.mu.Unlock()
if result := s.BuyItem("fp-buyer", 0); result != BuyInventoryFull {
t.Errorf("expected BuyInventoryFull, got %d", result)
}
}
func TestSendChat(t *testing.T) {
s := NewGameSession(testCfg(t))
s.SendChat("Alice", "hello")
st := s.GetState()
if len(st.CombatLog) != 1 || st.CombatLog[0] != "[Alice] hello" {
t.Errorf("expected chat log, got %v", st.CombatLog)
}
}

View File

@@ -10,60 +10,116 @@ import (
"github.com/tolelom/catacombs/entity"
)
const TurnTimeout = 5 * time.Second
func (s *GameSession) RunTurn() {
s.mu.Lock()
s.state.TurnNum++
s.state.CombatTurn++
s.clearLog()
s.actions = make(map[string]PlayerAction)
s.state.SubmittedActions = make(map[string]string)
aliveCount := 0
for _, p := range s.state.Players {
if !p.IsDead() {
if !p.IsOut() {
aliveCount++
}
}
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
collecting:
for collected < aliveCount {
select {
case msg := <-s.actionCh:
s.mu.Lock()
s.actions[msg.PlayerName] = msg.Action
s.actions[msg.PlayerID] = msg.Action
s.mu.Unlock()
collected++
case <-timer.C:
goto resolve
break collecting
case <-s.done:
timer.Stop()
return
}
}
timer.Stop()
resolve:
s.mu.Lock()
defer s.mu.Unlock()
s.state.TurnDeadline = time.Time{}
// Default action for players who didn't submit: Wait
for _, p := range s.state.Players {
if !p.IsDead() {
if _, ok := s.actions[p.Name]; !ok {
s.actions[p.Name] = PlayerAction{Type: ActionWait}
if !p.IsOut() {
if _, ok := s.actions[p.Fingerprint]; !ok {
s.actions[p.Fingerprint] = PlayerAction{Type: ActionWait}
}
}
}
s.state.TurnResolving = true
s.state.PendingLogs = nil
s.resolvePlayerActions()
s.resolveMonsterActions()
s.state.TurnResolving = false
// PendingLogs now contains all turn results — UI will reveal them one by one via RevealNextLog
}
func (s *GameSession) resolvePlayerActions() {
// 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 쓰러졌습니다!", p.Name))
}
}
}
var intents []combat.AttackIntent
var intentOwners []string // track who owns each intent
@@ -76,10 +132,15 @@ func (s *GameSession) resolvePlayerActions() {
}
for _, p := range s.state.Players {
if p.IsDead() {
if p.IsOut() {
continue
}
action, ok := s.actions[p.Name]
// 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
}
@@ -94,6 +155,11 @@ func (s *GameSession) resolvePlayerActions() {
})
intentOwners = append(intentOwners, p.Name)
case ActionSkill:
if p.SkillUses <= 0 {
s.addLog(fmt.Sprintf("%s 스킬 사용 횟수가 없습니다!", p.Name))
break
}
p.SkillUses--
switch p.Class {
case entity.ClassWarrior:
for _, m := range s.state.Monsters {
@@ -102,78 +168,190 @@ 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)
case entity.ClassHealer:
targetIdx := action.TargetIdx
if targetIdx < 0 || targetIdx >= len(s.state.Players) {
targetIdx = 0 // heal self by default
targetIdx = 0
}
target := s.state.Players[targetIdx]
if target.IsDead() {
// Find first alive player to heal instead
for j, candidate := range s.state.Players {
if !candidate.IsOut() {
target = candidate
targetIdx = j
break
}
}
}
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
return
}
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))
}
}
// Check if all alive players have fled
allFled := true
for _, p := range s.state.Players {
if !p.IsDead() && !p.Fled {
allFled = false
break
}
}
if allFled && !s.state.SoloMode {
s.state.Phase = PhaseExploring
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
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))
}
}
}
@@ -181,12 +359,9 @@ func (s *GameSession) resolvePlayerActions() {
// Award gold only for monsters that JUST died this turn
for i, m := range s.state.Monsters {
if m.IsDead() && aliveBeforeTurn[i] {
goldReward := 5 + s.state.FloorNum
if goldReward > 15 {
goldReward = 15
}
goldReward := 5 + s.state.FloorNum*2
for _, p := range s.state.Players {
if !p.IsDead() {
if !p.IsOut() {
bonus := 0
for _, r := range p.Relics {
if r.Effect == entity.RelicGoldBoost {
@@ -194,14 +369,15 @@ 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()
}
}
@@ -219,7 +395,10 @@ 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
}
if s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Type == dungeon.RoomBoss {
s.advanceFloor()
} else {
@@ -229,22 +408,36 @@ 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 HP %d로 부활!", p.Name, p.HP))
}
p.Fled = false
}
}
@@ -254,12 +447,15 @@ func (s *GameSession) grantBossRelic() {
{Name: "Power Amulet", Effect: entity.RelicATKBoost, Value: 3, Price: 120},
{Name: "Iron Ward", Effect: entity.RelicDEFBoost, Value: 2, Price: 100},
{Name: "Gold Charm", Effect: entity.RelicGoldBoost, Value: 5, Price: 150},
{Name: "Antidote Charm", Effect: entity.RelicPoisonImmunity, Value: 0, Price: 100},
{Name: "Flame Guard", Effect: entity.RelicBurnResist, Value: 0, Price: 120},
{Name: "Life Siphon", Effect: entity.RelicLifeSteal, Value: 10, Price: 150},
}
for _, p := range s.state.Players {
if !p.IsDead() {
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))
}
}
}
@@ -275,19 +471,69 @@ func (s *GameSession) resolveMonsterActions() {
targetIdx, isAoE := combat.MonsterAI(m, s.state.Players, s.state.CombatTurn)
if isAoE {
for _, p := range s.state.Players {
if !p.IsDead() {
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 쓰러졌습니다!", p.Name))
}
}
}
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이(가) %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이(가) %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:
healAmt := m.MaxHP / 10
m.HP += healAmt
if m.HP > m.MaxHP {
m.HP = m.MaxHP
}
s.addLog(fmt.Sprintf("%s HP %d 재생!", m.Name, healAmt))
}
}
} else {
if targetIdx >= 0 && targetIdx < len(s.state.Players) {
p := s.state.Players[targetIdx]
if !p.IsDead() {
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 쓰러졌습니다!", p.Name))
}
}
}
}
@@ -296,7 +542,7 @@ func (s *GameSession) resolveMonsterActions() {
allPlayersDead := true
for _, p := range s.state.Players {
if !p.IsDead() {
if !p.IsOut() {
allPlayersDead = false
break
}
@@ -304,6 +550,6 @@ func (s *GameSession) resolveMonsterActions() {
if allPlayersDead {
s.state.Phase = PhaseResult
s.state.GameOver = true
s.addLog("Party wiped!")
s.addLog("파티가 전멸했습니다!")
}
}

2
go.mod
View File

@@ -27,6 +27,7 @@ require (
github.com/creack/pty v1.1.21 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -40,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
)

3
go.sum
View File

@@ -40,6 +40,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -77,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,27 +1,133 @@
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()
lobby := game.NewLobby(cfg)
startTime := time.Now()
log.Println("Catacombs server starting on :2222")
if err := server.Start("0.0.0.0", 2222, lobby, db); err != nil {
log.Fatal(err)
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)
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()
}

72
store/achievements.go Normal file
View File

@@ -0,0 +1,72 @@
package store
import (
bolt "go.etcd.io/bbolt"
)
var bucketAchievements = []byte("achievements")
type Achievement struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Unlocked bool `json:"unlocked"`
}
var AchievementDefs = []Achievement{
{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 {
return d.db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(bucketAchievements)
return err
})
}
func (d *DB) UnlockAchievement(player, achievementID string) (bool, error) {
key := []byte(player + ":" + achievementID)
alreadyUnlocked := false
err := d.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketAchievements)
if b.Get(key) != nil {
alreadyUnlocked = true
return nil
}
return b.Put(key, []byte("1"))
})
// Returns true if newly unlocked (not already had it)
return !alreadyUnlocked, err
}
func (d *DB) GetAchievements(player string) ([]Achievement, error) {
unlocked := make(map[string]bool)
err := d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketAchievements)
if b == nil {
return nil
}
for _, a := range AchievementDefs {
key := []byte(player + ":" + a.ID)
if b.Get(key) != nil {
unlocked[a.ID] = true
}
}
return nil
})
result := make([]Achievement, len(AchievementDefs))
for i, a := range AchievementDefs {
result[i] = a
result[i].Unlocked = unlocked[a.ID]
}
return result, err
}

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,9 +18,11 @@ type DB struct {
}
type RunRecord struct {
Player string `json:"player"`
Floor int `json:"floor"`
Score int `json:"score"`
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) {
@@ -35,6 +37,24 @@ func Open(path string) (*DB, error) {
if _, err := tx.CreateBucketIfNotExists(bucketRankings); err != nil {
return err
}
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
@@ -63,11 +83,11 @@ func (d *DB) GetProfile(fingerprint string) (string, error) {
return name, err
}
func (d *DB) SaveRun(player string, floor, score int) 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}
record := RunRecord{Player: player, Floor: floor, Score: score, Class: class, Members: members}
data, err := json.Marshal(record)
if err != nil {
return err
@@ -76,6 +96,63 @@ func (d *DB) SaveRun(player string, floor, score int) error {
})
}
func (d *DB) TopRunsByGold(limit int) ([]RunRecord, error) {
var runs []RunRecord
err := d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketRankings)
return b.ForEach(func(k, v []byte) error {
var r RunRecord
if json.Unmarshal(v, &r) == nil {
runs = append(runs, r)
}
return nil
})
})
if err != nil {
return nil, err
}
sort.Slice(runs, func(i, j int) bool {
return runs[i].Score > runs[j].Score
})
if len(runs) > limit {
runs = runs[:limit]
}
return runs, nil
}
type PlayerStats struct {
TotalRuns int
BestFloor int
TotalGold int
TotalKills int
Victories int
}
func (d *DB) GetStats(player string) (PlayerStats, error) {
var stats PlayerStats
err := d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketRankings)
if b == nil {
return nil
}
return b.ForEach(func(k, v []byte) error {
var r RunRecord
if json.Unmarshal(v, &r) == nil && r.Player == player {
stats.TotalRuns++
if r.Floor > stats.BestFloor {
stats.BestFloor = r.Floor
}
stats.TotalGold += r.Score
if r.Floor >= 20 {
stats.Victories++
}
}
return nil
})
})
return stats, err
}
func (d *DB) TopRuns(limit int) ([]RunRecord, error) {
var runs []RunRecord
err := d.db.View(func(tx *bolt.Tx) error {

View File

@@ -38,9 +38,9 @@ func TestRanking(t *testing.T) {
os.Remove("test_rank.db")
}()
db.SaveRun("Alice", 20, 1500)
db.SaveRun("Bob", 15, 1000)
db.SaveRun("Charlie", 20, 2000)
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 {
@@ -53,3 +53,41 @@ func TestRanking(t *testing.T) {
t.Errorf("Top player: got %q, want Charlie", rankings[0].Player)
}
}
func TestGetStats(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir + "/test.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
// Save some runs
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 {
t.Fatal(err)
}
if stats.TotalRuns != 3 {
t.Errorf("expected 3 total runs, got %d", stats.TotalRuns)
}
if stats.BestFloor != 20 {
t.Errorf("expected best floor 20, got %d", stats.BestFloor)
}
if stats.Victories != 1 {
t.Errorf("expected 1 victory, got %d", stats.Victories)
}
if stats.TotalGold != 850 { // 100+250+500
t.Errorf("expected total gold 850, got %d", stats.TotalGold)
}
// Bob's stats should be separate
bobStats, _ := db.GetStats("Bob")
if bobStats.TotalRuns != 1 {
t.Errorf("Bob should have 1 run, got %d", bobStats.TotalRuns)
}
}

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

58
ui/achievements_view.go Normal file
View File

@@ -0,0 +1,58 @@
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("── 업적 ──")
var content string
unlocked := 0
for _, a := range achievements {
icon := styleSystem.Render(" ○ ")
nameStyle := styleSystem
if a.Unlocked {
icon = styleGold.Render(" ★ ")
nameStyle = stylePlayer
unlocked++
}
content += icon + nameStyle.Render(a.Name) + "\n"
content += styleSystem.Render(" "+a.Description) + "\n"
}
progress := fmt.Sprintf("\n %s", styleGold.Render(fmt.Sprintf("%d/%d 해금", unlocked, len(achievements))))
footer := styleSystem.Render("\n[A] 뒤로")
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, "", content, progress, footer))
}

67
ui/ascii_art.go Normal file
View File

@@ -0,0 +1,67 @@
package ui
import "github.com/tolelom/catacombs/entity"
// MonsterArt returns ASCII art lines for a monster type.
func MonsterArt(mt entity.MonsterType) []string {
switch mt {
case entity.MonsterSlime:
return []string{
` /\OO/\ `,
` \ / `,
` |__| `,
}
case entity.MonsterSkeleton:
return []string{
` ,--. `,
` |oo| `,
` /||\ `,
}
case entity.MonsterOrc:
return []string{
` .---. `,
`/o o\`,
`| --- |`,
}
case entity.MonsterDarkKnight:
return []string{
` /|||\ `,
` |===| `,
` | | `,
}
case entity.MonsterBoss5:
return []string{
` /\ /\ `,
`| @ @ |`,
`| || |`,
`| \__/ |`,
` \ / `,
}
case entity.MonsterBoss10:
return []string{
` __|__ `,
` /|o o|\ `,
` | === | `,
` |\___/| `,
` |___| `,
}
case entity.MonsterBoss15:
return []string{
` ,=====. `,
`/ \ / \`,
`| (O) |`,
` \ |=| / `,
` '===' `,
}
case entity.MonsterBoss20:
return []string{
` ___/\___ `,
`| x x |`,
`| === |`,
`|\_____/|`,
`|_| |_|`,
}
default:
return []string{` ??? `}
}
}

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,17 +5,395 @@ 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) 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 {
chatStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("117"))
chatView := chatStyle.Render(fmt.Sprintf("> %s_", chatInput))
return lipgloss.JoinVertical(lipgloss.Left, mapView, hudView, logView, chatView)
}
return lipgloss.JoinVertical(lipgloss.Left,
mapView,
hudView,
@@ -27,12 +405,23 @@ func renderMap(floor *dungeon.Floor) string {
if floor == nil {
return ""
}
theme := dungeon.GetFloorTheme(floor.Number)
headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
header := headerStyle.Render(fmt.Sprintf("── Catacombs B%d ──", floor.Number))
// Count explored rooms
explored := 0
for _, r := range floor.Rooms {
if r.Visited || r.Cleared {
explored++
}
}
total := len(floor.Rooms)
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()).
@@ -43,79 +432,128 @@ 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 {
sb.WriteString("\n")
// Enemies
enemyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
for i, m := range state.Monsters {
if !m.IsDead() {
mhpBar := renderHPBar(m.HP, m.MaxHP, 15)
taunt := ""
if m.TauntTarget {
taunt = " [TAUNTED]"
}
marker := " "
if i == targetCursor {
marker = "> "
}
sb.WriteString(enemyStyle.Render(fmt.Sprintf("%s[%d] %s %s %d/%d%s", marker, i, m.Name, mhpBar, m.HP, m.MaxHP, taunt)))
sb.WriteString("\n")
// Two-panel layout: PARTY | ENEMIES
// 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().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorGray).
Width(35).
Padding(0, 1).
Render(partyContent)
enemyPanel := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorGray).
Width(38).
Padding(0, 1).
Render(enemyContent)
panels := lipgloss.JoinHorizontal(lipgloss.Top, partyPanel, enemyPanel)
sb.WriteString(panels)
sb.WriteString("\n")
// Actions with skill description
actionStyle := lipgloss.NewStyle().Bold(true)
sb.WriteString(actionStyle.Render("[1]Attack [2]Skill [3]Item [4]Flee [5]Wait [Tab]Target"))
// Action bar
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
if !state.TurnDeadline.IsZero() {
remaining := time.Until(state.TurnDeadline)
if remaining < 0 {
remaining = 0
}
timerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
sb.WriteString(timerStyle.Render(fmt.Sprintf(" Timer: %.1fs", remaining.Seconds())))
sb.WriteString(styleTimer.Render(fmt.Sprintf(" 타이머: %.1f초", remaining.Seconds())))
sb.WriteString("\n")
}
// Skill description per class
skillStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Italic(true)
// Skill description for first alive player only
for _, p := range state.Players {
if !p.IsDead() {
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 — 주변 방 공개"
}
sb.WriteString(skillStyle.Render(skillDesc))
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 {
@@ -123,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
@@ -131,15 +569,52 @@ 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 {
return sb.String()
}
return border.Render(sb.String())
}
@@ -147,15 +622,38 @@ func renderCombatLog(log []string) string {
if len(log) == 0 {
return ""
}
logStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("228")).
PaddingLeft(1)
border := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorGray).
Padding(0, 1)
var sb strings.Builder
for _, msg := range log {
sb.WriteString(" > " + msg + "\n")
colored := colorizeLog(msg)
sb.WriteString(" > " + colored + "\n")
}
return border.Render(sb.String())
}
func colorizeLog(msg string) string {
switch {
case strings.Contains(msg, "도주"):
return styleFlee.Render(msg)
case strings.Contains(msg, "협동"):
return styleCoop.Render(msg)
case strings.Contains(msg, "회복") || strings.Contains(msg, "Heal") || strings.Contains(msg, "치유") || strings.Contains(msg, "부활"):
return styleHeal.Render(msg)
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, "정찰"):
return styleStatus.Render(msg)
case strings.Contains(msg, "골드") || strings.Contains(msg, "Gold") || strings.Contains(msg, "발견"):
return styleGold.Render(msg)
case strings.Contains(msg, "처치") || strings.Contains(msg, "클리어") || strings.Contains(msg, "내려갑니다") || strings.Contains(msg, "정복"):
return styleSystem.Render(msg)
default:
return msg
}
return logStyle.Render(sb.String())
}
func renderHPBar(current, max, width int) string {
@@ -166,31 +664,104 @@ func renderHPBar(current, max, width int) string {
if filled < 0 {
filled = 0
}
if filled > width {
filled = width
}
empty := width - filled
greenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46"))
redStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
bar := greenStyle.Render(strings.Repeat("█", filled)) +
redStyle.Render(strings.Repeat("░", empty))
return bar
}
func roomTypeSymbol(rt dungeon.RoomType) string {
switch rt {
case dungeon.RoomCombat:
return "D"
case dungeon.RoomTreasure:
return "$"
case dungeon.RoomShop:
return "S"
case dungeon.RoomEvent:
return "?"
case dungeon.RoomEmpty:
return "."
case dungeon.RoomBoss:
return "B"
pct := float64(current) / float64(max)
var barStyle lipgloss.Style
switch {
case pct > 0.5:
barStyle = lipgloss.NewStyle().Foreground(colorGreen)
case pct > 0.25:
barStyle = lipgloss.NewStyle().Foreground(colorYellow)
default:
return " "
barStyle = lipgloss.NewStyle().Foreground(colorRed)
}
emptyStyle := lipgloss.NewStyle().Foreground(colorGray)
return barStyle.Render(strings.Repeat("█", filled)) +
emptyStyle.Render(strings.Repeat("░", empty))
}
func renderPartyPanel(players []*entity.Player, submittedActions map[string]string, showAllyCursor bool, allyCursor int) string {
var sb strings.Builder
sb.WriteString(styleHeader.Render(" 아군") + "\n\n")
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(" [사망]")
}
sb.WriteString(nameStr + classStr + status + "\n")
hpBar := renderHPBar(p.HP, p.MaxHP, 16)
sb.WriteString(fmt.Sprintf(" %s %d/%d\n", hpBar, p.HP, p.MaxHP))
if len(p.Effects) > 0 {
var effects []string
for _, e := range p.Effects {
switch e.Type {
case entity.StatusPoison:
effects = append(effects, styleHeal.Render(fmt.Sprintf("☠Poison(%dt)", e.Duration)))
case entity.StatusBurn:
effects = append(effects, styleDamage.Render(fmt.Sprintf("🔥Burn(%dt)", e.Duration)))
case entity.StatusFreeze:
effects = append(effects, styleFlee.Render(fmt.Sprintf("❄Freeze(%dt)", e.Duration)))
}
}
sb.WriteString(" " + strings.Join(effects, " ") + "\n")
}
sb.WriteString(fmt.Sprintf(" ATK:%-3d DEF:%-3d ", p.EffectiveATK(), p.EffectiveDEF()))
sb.WriteString(styleGold.Render(fmt.Sprintf("G:%d", p.Gold)))
sb.WriteString("\n")
if action, ok := submittedActions[p.Fingerprint]; ok {
sb.WriteString(styleHeal.Render(fmt.Sprintf(" ✓ %s", action)))
sb.WriteString("\n")
} else if !p.IsOut() {
sb.WriteString(styleSystem.Render(" ... 대기중"))
sb.WriteString("\n")
}
sb.WriteString("\n")
}
return sb.String()
}
func renderEnemyPanel(monsters []*entity.Monster, targetCursor int) string {
var sb strings.Builder
sb.WriteString(styleHeader.Render(" 적") + "\n\n")
for i, m := range monsters {
if m.IsDead() {
continue
}
// ASCII art
art := MonsterArt(m.Type)
for _, line := range art {
sb.WriteString(styleEnemy.Render(" "+line) + "\n")
}
// Name + HP
marker := " "
if i == targetCursor {
marker = "> "
}
hpBar := renderHPBar(m.HP, m.MaxHP, 12)
taunt := ""
if m.TauntTarget {
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))
}
return sb.String()
}

76
ui/help_view.go Normal file
View File

@@ -0,0 +1,76 @@
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("── 조작법 ──")
sections := []struct{ header, body string }{
{"로비", ` [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
headerStyle := lipgloss.NewStyle().Foreground(colorCyan).Bold(true)
bodyStyle := lipgloss.NewStyle().Foreground(colorWhite)
for _, s := range sections {
content += headerStyle.Render(s.header) + "\n"
content += bodyStyle.Render(s.body) + "\n\n"
}
footer := styleSystem.Render("[H] 뒤로")
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, "", content, footer))
}

121
ui/leaderboard_view.go Normal file
View File

@@ -0,0 +1,121 @@
package ui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/store"
)
// LeaderboardScreen shows the top runs.
type LeaderboardScreen struct {
tab int // 0=all-time, 1=gold, 2=daily
}
func NewLeaderboardScreen() *LeaderboardScreen {
return &LeaderboardScreen{}
}
func (s *LeaderboardScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isKey(key, "tab") || key.Type == tea.KeyTab {
s.tab = (s.tab + 1) % 3
return s, nil
}
if isKey(key, "l") || isEnter(key) || isQuit(key) {
return NewTitleScreen(), nil
}
}
return s, nil
}
func (s *LeaderboardScreen) View(ctx *Context) string {
var byFloor, byGold []store.RunRecord
var daily []store.DailyRecord
if ctx.Store != nil {
byFloor, _ = ctx.Store.TopRuns(10)
byGold, _ = ctx.Store.TopRunsByGold(10)
daily, _ = ctx.Store.GetDailyLeaderboard(time.Now().Format("2006-01-02"), 20)
}
return renderLeaderboard(byFloor, byGold, daily, s.tab, ctx.Width, ctx.Height)
}
func renderLeaderboard(byFloor, byGold []store.RunRecord, daily []store.DailyRecord, tab, width, height int) string {
title := styleHeader.Render("── 리더보드 ──")
// 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))
}
}
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)
}
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)))
}
}
footer := styleSystem.Render("\n[Tab] 탭 전환 [L] 뒤로")
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, tabLine, "", content, footer))
}

View File

@@ -3,27 +3,194 @@ 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
}
type roomInfo struct {
Code string
Name string
Players int
Players []playerInfo
Status string
}
type playerInfo struct {
Name string
Class string
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")).
@@ -33,8 +200,15 @@ func renderLobby(state lobbyState, width, height int) string {
Border(lipgloss.RoundedBorder()).
Padding(0, 1)
header := headerStyle.Render("── Lobby ──")
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 {
@@ -43,14 +217,28 @@ func renderLobby(state lobbyState, width, height int) string {
marker = "> "
}
roomList += fmt.Sprintf("%s%s [%s] (%d/4) %s\n",
marker, r.Name, r.Code, r.Players, r.Status)
marker, r.Name, r.Code, len(r.Players), r.Status)
// Show players in selected room
if i == state.cursor {
for _, p := range r.Players {
cls := p.Class
if cls == "" {
cls = "..."
}
readyMark := " "
if p.Ready {
readyMark = "✓ "
}
roomList += fmt.Sprintf(" %s%s (%s)\n", readyMark, p.Name, cls)
}
}
}
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,51 +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
)
// 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
currentScreen Screen
ctx *Context
}
func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *store.DB) Model {
@@ -56,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,
}
}
@@ -73,62 +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)
}
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)
case screenShop:
return renderShop(m.gameState, m.width, m.height)
case screenResult:
var rankings []store.RunRecord
if m.store != nil {
rankings, _ = m.store.TopRuns(10)
}
return renderResult(m.gameState.Victory, m.gameState.FloorNum, rankings)
}
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 {
@@ -147,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
}
@@ -155,285 +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.store != nil {
name, err := m.store.GetProfile(m.fingerprint)
if err != nil {
m.playerName = "Adventurer"
if m.store != nil && m.fingerprint != "" {
m.store.SaveProfile(m.fingerprint, m.playerName)
}
} else {
m.playerName = name
}
} else {
m.playerName = "Adventurer"
}
m.screen = screenLobby
m = m.withRefreshedLobby()
} 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) 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); 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.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); err == nil {
m.roomCode = r.Code
m.screen = screenClassSelect
}
}
} else if isKey(key, "q") {
m.screen = screenTitle
}
}
return m, nil
// Convenience accessors for test compatibility
func (m Model) playerName() string {
return m.ctx.PlayerName
}
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
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)
m.session.StartGame()
m.gameState = m.session.GetState()
m.screen = screenGame
}
}
}
}
return m, nil
func (m Model) roomCode() string {
return m.ctx.RoomCode
}
// 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{}
})
func (m Model) session() *game.GameSession {
return m.ctx.Session
}
type tickMsg struct{}
func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
// Refresh state on every update
if m.session != nil {
m.gameState = m.session.GetState()
}
if m.gameState.GameOver {
if m.store != nil {
score := 0
for _, p := range m.gameState.Players {
score += p.Gold
}
m.store.SaveRun(m.playerName, m.gameState.FloorNum, score)
}
m.screen = screenResult
return m, nil
}
if m.gameState.Phase == game.PhaseShop {
m.screen = screenShop
return m, nil
}
switch msg.(type) {
case tickMsg:
// State already refreshed above, just keep polling during combat
if m.gameState.Phase == game.PhaseCombat {
return m, m.pollState()
}
return m, nil
}
if key, ok := msg.(tea.KeyMsg); ok {
switch m.gameState.Phase {
case game.PhaseExploring:
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.Name == m.playerName && 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.playerName, game.PlayerAction{Type: game.ActionAttack, TargetIdx: m.targetCursor})
case "2":
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionSkill, TargetIdx: m.targetCursor})
case "3":
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionItem})
case "4":
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionFlee})
case "5":
m.session.SubmitAction(m.playerName, 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')
m.session.BuyItem(m.playerName, idx)
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) {
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"
}
m.lobbyState.rooms[i] = roomInfo{
Code: r.Code,
Name: r.Name,
Players: len(r.Players),
Status: status,
}
}
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,76 +19,114 @@ 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())
}
// Press Enter
// First-time player: Enter goes to nickname screen
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m2 := result.(Model)
if m2.screen != screenLobby {
t.Errorf("after Enter: screen=%d, want screenLobby(1)", m2.screen)
if m2.screenType() != screenNickname {
t.Errorf("after Enter (first time): screen=%d, want screenNickname(%d)", m2.screenType(), screenNickname)
}
if m2.playerName == "" {
// Type a name
for _, ch := range []rune("Hero") {
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}})
m2 = result.(Model)
}
// Confirm nickname
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyEnter})
m3 := result.(Model)
if m3.screenType() != screenLobby {
t.Errorf("after nickname Enter: screen=%d, want screenLobby(1)", m3.screenType())
}
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") }()
m := NewModel(80, 24, "testfp", lobby, db)
m := NewModel(80, 24, "testfp2", lobby, db)
// Go to lobby
// Go to nickname screen (first-time player)
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m2 := result.(Model)
// Type name and confirm
for _, ch := range []rune("Hero") {
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}})
m2 = result.(Model)
}
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyEnter})
m2 = result.(Model)
// Press 'c' to create room
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") }()
m := NewModel(80, 24, "testfp", lobby, db)
m := NewModel(80, 24, "testfp3", lobby, db)
// Title -> Lobby -> Class Select -> Game
// Title -> Nickname -> Lobby
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m2 := result.(Model)
for _, ch := range []rune("Hero") {
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}})
m2 = result.(Model)
}
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyEnter})
m2 = result.(Model)
// Lobby -> Class Select
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")
}
}

288
ui/nickname_view.go Normal file
View File

@@ -0,0 +1,288 @@
package ui
import (
"crypto/sha256"
"fmt"
"log/slog"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// 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 == "" {
display = strings.Repeat("_", 12)
} else {
display = input + "_"
}
inputBox := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorCyan).
Padding(0, 2).
Render(stylePlayer.Render(display))
hint := styleSystem.Render(fmt.Sprintf("(%d/12 글자)", len(input)))
footer := styleAction.Render("[Enter] 확인 [Esc] 취소")
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

@@ -2,39 +2,103 @@ package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
tea "github.com/charmbracelet/bubbletea"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
)
func renderResult(won bool, floorReached int, rankings []store.RunRecord) string {
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205"))
var title string
if won {
title = titleStyle.Render("VICTORY! You escaped the Catacombs!")
} else {
title = titleStyle.Render("GAME OVER")
}
floorInfo := fmt.Sprintf("Floor Reached: B%d", floorReached)
rankHeader := lipgloss.NewStyle().Bold(true).Render("── Rankings ──")
rankList := ""
for i, r := range rankings {
rankList += fmt.Sprintf(" %d. %s — B%d (Score: %d)\n", i+1, r.Player, r.Floor, r.Score)
}
menu := "[Enter] Return to Lobby [Q] Quit"
return lipgloss.JoinVertical(lipgloss.Center,
title,
"",
floorInfo,
"",
rankHeader,
rankList,
"",
menu,
)
// 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(" ✦ 승리 ✦ ") + "\n\n")
sb.WriteString(styleSystem.Render(" 카타콤을 정복했습니다!") + "\n\n")
} else {
sb.WriteString(styleDamage.Render(" ✦ 패배 ✦ ") + "\n\n")
sb.WriteString(styleSystem.Render(fmt.Sprintf(" B%d층에서 쓰러졌습니다", state.FloorNum)) + "\n\n")
}
// Player summary
sb.WriteString(styleHeader.Render("── 파티 요약 ──") + "\n\n")
totalGold := 0
for _, p := range state.Players {
status := styleHeal.Render("생존")
if p.IsDead() {
status = styleDamage.Render("사망")
}
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 총 골드: %s\n", styleGold.Render(fmt.Sprintf("%d", totalGold))))
// Rankings
if len(rankings) > 0 {
sb.WriteString("\n" + styleHeader.Render("── 최고 기록 ──") + "\n\n")
for i, r := range rankings {
medal := " "
switch i {
case 0:
medal = styleGold.Render("🥇")
case 1:
medal = styleSystem.Render("🥈")
case 2:
medal = styleGold.Render("🥉")
}
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] 로비로 돌아가기") + "\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,28 +3,101 @@ package ui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/entity"
"github.com/tolelom/catacombs/game"
)
func renderShop(state game.GameState, width, height int) string {
// 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:
return fmt.Sprintf("[ATK+%d]", item.Bonus)
case entity.ItemArmor:
return fmt.Sprintf("[DEF+%d]", item.Bonus)
case entity.ItemConsumable:
return fmt.Sprintf("[HP+%d]", item.Bonus)
default:
return fmt.Sprintf("[+%d]", item.Bonus)
}
}
func renderShop(state game.GameState, width, height int, shopMsg string) string {
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true)
goldStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("220"))
msgStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Bold(true)
header := headerStyle.Render("── Shop ──")
items := ""
for i, item := range state.ShopItems {
items += fmt.Sprintf(" [%d] %s (+%d) — %d gold\n", i+1, item.Name, item.Bonus, item.Price)
header := headerStyle.Render("── 상점 ──")
// Show current player's gold
goldLine := ""
for _, p := range state.Players {
inventoryCount := len(p.Inventory)
goldLine += goldStyle.Render(fmt.Sprintf(" %s — 골드: %d 아이템: %d/10", p.Name, p.Gold, inventoryCount))
goldLine += "\n"
}
menu := "[1-3] Buy [Q] Leave Shop"
items := ""
for i, item := range state.ShopItems {
label := itemTypeLabel(item)
items += fmt.Sprintf(" [%d] %s %s — %d 골드\n", i+1, item.Name, label, item.Price)
}
return lipgloss.JoinVertical(lipgloss.Left,
header,
"",
items,
"",
menu,
)
menu := "[1-3] 구매 [Q] 상점 나가기"
parts := []string{header, "", goldLine, items, "", menu}
if shopMsg != "" {
parts = append(parts, "", msgStyle.Render(shopMsg))
}
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}

55
ui/stats_view.go Normal file
View File

@@ -0,0 +1,55 @@
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("── 플레이어 통계 ──")
var content string
content += stylePlayer.Render(fmt.Sprintf(" %s", playerName)) + "\n\n"
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(" 승률: %s\n", styleSystem.Render(fmt.Sprintf("%.1f%%", winRate)))
footer := styleSystem.Render("[S] 뒤로")
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, "", content, "", footer))
}

32
ui/styles.go Normal file
View File

@@ -0,0 +1,32 @@
package ui
import "github.com/charmbracelet/lipgloss"
// Colors
var (
colorRed = lipgloss.Color("196")
colorGreen = lipgloss.Color("46")
colorYellow = lipgloss.Color("226")
colorCyan = lipgloss.Color("51")
colorMagenta = lipgloss.Color("201")
colorWhite = lipgloss.Color("255")
colorGray = lipgloss.Color("240")
colorOrange = lipgloss.Color("208")
colorPink = lipgloss.Color("205")
)
// Text styles
var (
styleDamage = lipgloss.NewStyle().Foreground(colorRed).Bold(true)
styleHeal = lipgloss.NewStyle().Foreground(colorGreen).Bold(true)
styleCoop = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
styleFlee = lipgloss.NewStyle().Foreground(colorCyan)
styleStatus = lipgloss.NewStyle().Foreground(colorMagenta)
styleGold = lipgloss.NewStyle().Foreground(colorYellow)
styleSystem = lipgloss.NewStyle().Foreground(colorGray).Italic(true)
styleEnemy = lipgloss.NewStyle().Foreground(colorRed)
stylePlayer = lipgloss.NewStyle().Foreground(colorWhite).Bold(true)
styleHeader = lipgloss.NewStyle().Foreground(colorPink).Bold(true)
styleAction = lipgloss.NewStyle().Bold(true)
styleTimer = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
)

View File

@@ -1,37 +1,126 @@
package ui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var titleArt = `
██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗
██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝
██║ ███████║ ██║ ███████║██║ ██║ ██║██╔████╔██║██████╔╝███████╗
██║ ██╔══██║ ██║ ██╔══██║██║ ██║ ██║██║╚██╔╝██║██╔══██╗╚════██║
╚██████╗██║ ██║ ██║ ██║ ██║╚██████╗╚██████╔╝██║ ╚═╝ ██║██████╔╝███████║
╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝
`
// 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{
` ██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗`,
`██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝`,
`██║ ███████║ ██║ ███████║██║ ██║ ██║██╔████╔██║██████╔╝███████╗`,
`██║ ██╔══██║ ██║ ██╔══██║██║ ██║ ██║██║╚██╔╝██║██╔══██╗╚════██║`,
`╚██████╗██║ ██║ ██║ ██║ ██║╚██████╗╚██████╔╝██║ ╚═╝ ██║██████╔╝███████║`,
` ╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝`,
}
var titleColors = []lipgloss.Color{
lipgloss.Color("196"),
lipgloss.Color("202"),
lipgloss.Color("208"),
lipgloss.Color("214"),
lipgloss.Color("220"),
lipgloss.Color("226"),
}
func renderTitle(width, height int) string {
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("205")).
var logoLines []string
for i, line := range titleLines {
color := titleColors[i%len(titleColors)]
style := lipgloss.NewStyle().Foreground(color).Bold(true)
logoLines = append(logoLines, style.Render(line))
}
logo := strings.Join(logoLines, "\n")
subtitle := lipgloss.NewStyle().
Foreground(colorGray).
Render("⚔ 협동 던전 크롤러 ⚔")
server := lipgloss.NewStyle().
Foreground(colorCyan).
Render("ssh catacombs.tolelom.xyz")
menu := lipgloss.NewStyle().
Foreground(colorWhite).
Bold(true).
Align(lipgloss.Center)
Render("[Enter] 시작 [H] 도움말 [S] 통계 [A] 업적 [L] 리더보드 [C] 도감 [Q] 종료")
subtitleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Align(lipgloss.Center)
menuStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Align(lipgloss.Center)
return lipgloss.JoinVertical(lipgloss.Center,
titleStyle.Render(titleArt),
content := lipgloss.JoinVertical(lipgloss.Center,
logo,
"",
subtitleStyle.Render("A Co-op Roguelike Adventure"),
subtitle,
server,
"",
menuStyle.Render("[Enter] Start [Q] Quit"),
"",
menu,
)
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content)
}

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

175
web/server.go Normal file
View File

@@ -0,0 +1,175 @@
package web
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"
)
//go:embed static
var staticFiles embed.FS
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
type resizeMsg struct {
Type string `json:"type"`
Cols int `json:"cols"`
Rows int `json:"rows"`
}
// 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
mux.Handle("/", http.FileServer(http.FS(staticFiles)))
// WebSocket endpoint
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
handleWS(w, r, sshPort)
})
// 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 {
slog.Error("WebSocket upgrade error", "error", err)
return
}
defer ws.Close()
// Connect to local SSH server
sshConfig := &ssh.ClientConfig{
User: "web-player",
Auth: []ssh.AuthMethod{
ssh.Password(""),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
sshAddr := fmt.Sprintf("localhost:%d", sshPort)
client, err := ssh.Dial("tcp", sshAddr, sshConfig)
if err != nil {
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
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
slog.Error("SSH session error", "error", err)
return
}
defer session.Close()
// Request PTY
if err := session.RequestPty("xterm-256color", 24, 80, ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
}); err != nil {
slog.Error("PTY request error", "error", err)
return
}
stdin, err := session.StdinPipe()
if err != nil {
slog.Error("stdin pipe error", "error", err)
return
}
stdout, err := session.StdoutPipe()
if err != nil {
slog.Error("stdout pipe error", "error", err)
return
}
if err := session.Shell(); err != nil {
slog.Error("shell error", "error", err)
return
}
var once sync.Once
done := make(chan struct{})
cleanup := func() {
once.Do(func() {
close(done)
})
}
// SSH stdout → WebSocket
go func() {
defer cleanup()
buf := make([]byte, 4096)
for {
n, err := stdout.Read(buf)
if n > 0 {
if writeErr := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); writeErr != nil {
return
}
}
if err != nil {
return
}
}
}()
// WebSocket → SSH stdin (text frames) or resize (binary frames)
go func() {
defer cleanup()
for {
msgType, data, err := ws.ReadMessage()
if err != nil {
return
}
switch msgType {
case websocket.TextMessage:
if _, err := stdin.Write(data); err != nil {
return
}
case websocket.BinaryMessage:
var msg resizeMsg
if json.Unmarshal(data, &msg) == nil && msg.Type == "resize" {
session.WindowChange(msg.Rows, msg.Cols)
}
}
}
}()
// Wait for either side to close
select {
case <-done:
}
// Ensure SSH session ends
_ = session.Close()
_ = client.Close()
_ = io.WriteCloser(stdin).Close()
}

121
web/static/index.html Normal file
View File

@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Catacombs</title>
<link rel="stylesheet" href="https://unpkg.com/@xterm/xterm@5.5.0/css/xterm.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; background: #1a1a2e; }
#terminal { height: 100%; width: 100%; }
#overlay {
display: none;
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(26, 26, 46, 0.9);
justify-content: center; align-items: center;
z-index: 10;
}
#overlay.visible { display: flex; }
#overlay-text {
color: #e0e0e0; font-family: monospace; font-size: 18px;
text-align: center; line-height: 2;
}
#overlay-text span { color: #51d0ff; }
</style>
</head>
<body>
<div id="terminal"></div>
<div id="overlay">
<div id="overlay-text">
Connection lost.<br>
<span>Press any key to reconnect.</span>
</div>
</div>
<script src="https://unpkg.com/@xterm/xterm@5.5.0/lib/xterm.js"></script>
<script src="https://unpkg.com/@xterm/addon-fit@0.10.0/lib/addon-fit.js"></script>
<script>
const termEl = document.getElementById('terminal');
const overlay = document.getElementById('overlay');
const term = new Terminal({
cursorBlink: true,
fontSize: 16,
fontFamily: "'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace",
theme: {
background: '#1a1a2e',
foreground: '#e0e0e0',
cursor: '#51d0ff',
selectionBackground: '#44475a',
},
allowProposedApi: true,
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(termEl);
fitAddon.fit();
let ws = null;
let connected = false;
function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws`);
ws.onopen = () => {
connected = true;
overlay.classList.remove('visible');
term.clear();
term.focus();
sendResize();
};
ws.binaryType = 'arraybuffer';
ws.onmessage = (e) => {
term.write(new Uint8Array(e.data));
};
ws.onclose = () => {
connected = false;
overlay.classList.add('visible');
};
ws.onerror = () => {
connected = false;
overlay.classList.add('visible');
};
}
function sendResize() {
if (ws && ws.readyState === WebSocket.OPEN) {
const msg = JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows });
ws.send(new Blob([msg]));
}
}
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
window.addEventListener('resize', () => {
fitAddon.fit();
sendResize();
});
// Reconnect on any key when disconnected
document.addEventListener('keydown', (e) => {
if (!connected) {
e.preventDefault();
connect();
}
});
// Initial connection
connect();
</script>
</body>
</html>