Compare commits

..

38 Commits

Author SHA1 Message Date
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
43 changed files with 4519 additions and 221 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"
]
}

Binary file not shown.

View File

@@ -82,8 +82,8 @@ func AttemptFlee() bool {
}
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 && turnNumber > 0 && turnNumber%3 == 0 {
return -1, true // AoE every 3 turns for all bosses
}
if m.TauntTarget {
for i, p := range players {
@@ -91,17 +91,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

@@ -46,6 +46,25 @@ func TestAoENoCoopBonus(t *testing.T) {
}
}
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++ {
@@ -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)
}
}

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 ./...
```

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

@@ -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"
@@ -95,6 +114,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 ""
}
@@ -200,7 +220,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

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

@@ -33,6 +33,16 @@ var monsterDefs = map[MonsterType]monsterBase{
MonsterBoss20: {"Archlich", 600, 40, 20, 20, true},
}
type BossPattern int
const (
PatternNone BossPattern = iota
PatternAoE // every 3 turns AoE
PatternPoison // applies poison
PatternBurn // applies burn to random player
PatternHeal // heals self
)
type Monster struct {
Name string
Type MonsterType
@@ -41,6 +51,7 @@ type Monster struct {
IsBoss bool
TauntTarget bool
TauntTurns int
Pattern BossPattern
}
func NewMonster(mt MonsterType, floor int) *Monster {
@@ -51,13 +62,14 @@ func NewMonster(mt MonsterType, floor int) *Monster {
}
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,
DEF: def,
IsBoss: base.IsBoss,
}
}

View File

@@ -23,3 +23,36 @@ func TestBossStats(t *testing.T) {
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)
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)
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 TestMonsterAtMinFloor(t *testing.T) {
// Slime at floor 1 (minFloor=1) should have base stats
m := NewMonster(MonsterSlime, 1)
if m.HP != 20 || m.ATK != 5 || m.DEF != 1 {
t.Errorf("Slime at min floor should be base stats, got HP=%d ATK=%d DEF=%d", m.HP, m.ATK, m.DEF)
}
}

View File

@@ -1,5 +1,7 @@
package entity
import "fmt"
type Class int
const (
@@ -24,6 +26,20 @@ var classBaseStats = map[Class]classStats{
ClassRogue: {85, 15, 4},
}
type StatusEffect int
const (
StatusPoison StatusEffect = iota
StatusBurn
StatusFreeze
)
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 +49,10 @@ type Player struct {
Gold int
Inventory []Item
Relics []Relic
Effects []ActiveEffect
Dead bool
Fled bool
SkillUses int // remaining skill uses this combat
}
func NewPlayer(name string, class Class) *Player {
@@ -67,6 +86,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)
@@ -104,3 +127,59 @@ func (p *Player) EffectiveDEF() int {
}
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() (damages []string) {
var remaining []ActiveEffect
for _, e := range p.Effects {
switch e.Type {
case StatusPoison:
p.HP -= e.Value
if p.HP <= 0 {
p.HP = 1 // Poison can't kill, leaves at 1 HP
}
damages = append(damages, fmt.Sprintf("%s takes %d poison damage", p.Name, e.Value))
case StatusBurn:
p.HP -= e.Value
if p.HP <= 0 {
p.HP = 0
p.Dead = true
}
damages = append(damages, fmt.Sprintf("%s takes %d burn damage", p.Name, e.Value))
}
e.Duration--
if e.Duration > 0 {
remaining = append(remaining, e)
}
}
p.Effects = remaining
return
}

View File

@@ -63,3 +63,130 @@ 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")
}
}

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,13 @@ 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.state.Floor.CurrentRoom = roomIdx
dungeon.UpdateVisibility(s.state.Floor)
room := s.state.Floor.Rooms[roomIdx]
@@ -79,9 +88,15 @@ func (s *GameSession) spawnMonsters() {
m.HP = 1
}
m.MaxHP = m.HP
m.DEF = m.DEF / 2
}
s.state.Monsters[i] = m
}
// Reset skill uses for all players at combat start
for _, p := range s.state.Players {
p.SkillUses = 3
}
}
func (s *GameSession) spawnBoss() {
@@ -99,50 +114,116 @@ func (s *GameSession) spawnBoss() {
mt = entity.MonsterBoss5
}
boss := entity.NewMonster(mt, s.state.FloorNum)
switch mt {
case entity.MonsterBoss5:
boss.Pattern = entity.PatternAoE
case entity.MonsterBoss10:
boss.Pattern = entity.PatternPoison
case entity.MonsterBoss15:
boss.Pattern = entity.PatternBurn
case entity.MonsterBoss20:
boss.Pattern = entity.PatternHeal
}
if s.state.SoloMode {
boss.HP = boss.HP / 2
boss.MaxHP = boss.HP
boss.DEF = boss.DEF / 2
}
s.state.Monsters = []*entity.Monster{boss}
// Reset skill uses for all players at combat start
for _, p := range s.state.Players {
p.SkillUses = 3
}
}
func (s *GameSession) grantTreasure() {
// Random item for each player
floor := s.state.FloorNum
for _, p := range s.state.Players {
if len(p.Inventory) >= 10 {
s.addLog(fmt.Sprintf("%s's inventory is full!", p.Name))
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 found %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 found %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
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
for _, p := range s.state.Players {
if p.IsDead() {
continue
}
if rand.Float64() < 0.5 {
// Trap: 10~20 damage
dmg := 10 + rand.Intn(11)
baseDmg := 10 + s.state.FloorNum
dmg := baseDmg + rand.Intn(baseDmg/2+1)
p.TakeDamage(dmg)
s.addLog(fmt.Sprintf("Trap! %s takes %d damage", p.Name, dmg))
} else {
// Blessing: heal 15~25
heal := 15 + rand.Intn(11)
baseHeal := 15 + s.state.FloorNum
heal := baseHeal + rand.Intn(baseHeal/2+1)
before := p.HP
p.Heal(heal)
s.addLog(fmt.Sprintf("Blessing! %s heals %d HP", p.Name, p.HP-before))
}
}
}

View File

@@ -13,21 +13,109 @@ 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 OnlinePlayer struct {
Name string
Fingerprint string
InRoom string // room code, empty if in lobby
}
type Lobby struct {
mu sync.RWMutex
rooms map[string]*LobbyRoom
mu sync.RWMutex
rooms map[string]*LobbyRoom
online map[string]*OnlinePlayer // fingerprint -> player
activeSessions map[string]string // fingerprint -> room code (for reconnect)
}
func NewLobby() *Lobby {
return &Lobby{rooms: make(map[string]*LobbyRoom)}
return &Lobby{
rooms: make(map[string]*LobbyRoom),
online: make(map[string]*OnlinePlayer),
activeSessions: make(map[string]string),
}
}
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("player not online")
}
if p.InRoom != "" {
return fmt.Errorf("player already in a room")
}
// Store the invite as a pending field
p.InRoom = "invited:" + roomCode
return nil
}
func (l *Lobby) CreateRoom(name string) string {
@@ -45,7 +133,7 @@ func (l *Lobby) CreateRoom(name string) string {
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]
@@ -58,10 +146,49 @@ func (l *Lobby) JoinRoom(code, playerName string) error {
if room.Status != RoomWaiting {
return fmt.Errorf("room %s already in progress", code)
}
room.Players = append(room.Players, playerName)
room.Players = append(room.Players, LobbyPlayer{Name: playerName, Fingerprint: fingerprint})
return nil
}
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 +205,14 @@ 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
}
}
func (l *Lobby) RemoveRoom(code string) {
l.mu.Lock()
defer l.mu.Unlock()

View File

@@ -17,7 +17,7 @@ func TestCreateRoom(t *testing.T) {
func TestJoinRoom(t *testing.T) {
lobby := NewLobby()
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 +27,73 @@ func TestJoinRoom(t *testing.T) {
}
}
func TestRoomStatusTransition(t *testing.T) {
l := NewLobby()
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()
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()
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()
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()
code := l.CreateRoom("Test")
if l.AllReady(code) {
t.Error("empty room should not be all ready")
}
}

View File

@@ -1,6 +1,7 @@
package game
import (
"fmt"
"sync"
"time"
@@ -44,15 +45,23 @@ 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
}
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:]
}
}
}
@@ -67,11 +76,13 @@ type GameSession struct {
actions map[string]PlayerAction // playerName -> action
actionCh chan playerActionMsg
combatSignal chan struct{}
done chan struct{}
lastActivity map[string]time.Time // fingerprint -> last activity time
}
type playerActionMsg struct {
PlayerName string
Action PlayerAction
PlayerID string
Action PlayerAction
}
func NewGameSession() *GameSession {
@@ -82,6 +93,17 @@ func NewGameSession() *GameSession {
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),
}
}
func (s *GameSession) Stop() {
select {
case <-s.done:
// already stopped
default:
close(s.done)
}
}
@@ -94,6 +116,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,6 +128,12 @@ 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
@@ -111,14 +143,40 @@ func (s *GameSession) combatLoop() {
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 {
s.addLog(fmt.Sprintf("%s removed (disconnected)", 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
}
}
}
@@ -155,15 +213,125 @@ 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)
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 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,
}
}
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()
desc := ""
switch action.Type {
case ActionAttack:
desc = "Attacking"
case ActionSkill:
desc = "Using Skill"
case ActionItem:
desc = "Using Item"
case ActionFlee:
desc = "Fleeing"
case ActionWait:
desc = "Defending"
}
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()
}
// BuyItem handles shop purchases
func (s *GameSession) BuyItem(playerName string, itemIdx int) bool {
func (s *GameSession) BuyItem(playerID string, itemIdx int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.state.Phase != PhaseShop || itemIdx < 0 || itemIdx >= len(s.state.ShopItems) {
@@ -171,7 +339,10 @@ func (s *GameSession) BuyItem(playerName string, itemIdx int) bool {
}
item := s.state.ShopItems[itemIdx]
for _, p := range s.state.Players {
if p.Name == playerName && p.Gold >= item.Price {
if p.Fingerprint == playerID && p.Gold >= item.Price {
if len(p.Inventory) >= 10 {
return false
}
p.Gold -= item.Price
p.Inventory = append(p.Inventory, item)
return true
@@ -180,6 +351,13 @@ func (s *GameSession) BuyItem(playerName string, itemIdx int) bool {
return false
}
// SendChat appends a chat message to the combat log
func (s *GameSession) SendChat(playerName, message string) {
s.mu.Lock()
defer s.mu.Unlock()
s.addLog(fmt.Sprintf("[%s] %s", playerName, message))
}
// LeaveShop exits the shop phase
func (s *GameSession) LeaveShop() {
s.mu.Lock()

View File

@@ -7,9 +7,42 @@ import (
"github.com/tolelom/catacombs/entity"
)
func TestGetStateNoRace(t *testing.T) {
s := NewGameSession()
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()
p := entity.NewPlayer("test", entity.ClassWarrior)
p.Fingerprint = "test-fp"
s.AddPlayer(p)
s.StartFloor()
@@ -27,3 +60,91 @@ func TestSessionTurnTimeout(t *testing.T) {
t.Error("Turn did not timeout within 7 seconds")
}
}
func TestRevealNextLog(t *testing.T) {
s := NewGameSession()
// 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()
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()
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 s.BuyItem("fp-buyer", 0) {
t.Error("should not buy when inventory is full")
}
}
func TestSendChat(t *testing.T) {
s := NewGameSession()
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

@@ -18,9 +18,10 @@ func (s *GameSession) RunTurn() {
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++
}
}
@@ -32,38 +33,59 @@ func (s *GameSession) RunTurn() {
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() {
// Tick status effects
for _, p := range s.state.Players {
if !p.IsOut() {
msgs := p.TickEffects()
for _, msg := range msgs {
s.addLog(msg)
}
if p.IsDead() {
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
}
}
}
var intents []combat.AttackIntent
var intentOwners []string // track who owns each intent
@@ -76,10 +98,10 @@ func (s *GameSession) resolvePlayerActions() {
}
for _, p := range s.state.Players {
if p.IsDead() {
if p.IsOut() {
continue
}
action, ok := s.actions[p.Name]
action, ok := s.actions[p.Fingerprint]
if !ok {
continue
}
@@ -94,6 +116,11 @@ func (s *GameSession) resolvePlayerActions() {
})
intentOwners = append(intentOwners, p.Name)
case ActionSkill:
if p.SkillUses <= 0 {
s.addLog(fmt.Sprintf("%s has no skill uses left!", p.Name))
break
}
p.SkillUses--
switch p.Class {
case entity.ClassWarrior:
for _, m := range s.state.Monsters {
@@ -114,9 +141,19 @@ func (s *GameSession) resolvePlayerActions() {
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
}
}
}
before := target.HP
target.Heal(30)
s.addLog(fmt.Sprintf("%s healed %s for %d HP", p.Name, target.Name, target.HP-before))
@@ -145,10 +182,12 @@ func (s *GameSession) resolvePlayerActions() {
case ActionFlee:
if combat.AttemptFlee() {
s.addLog(fmt.Sprintf("%s fled from battle!", 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))
}
@@ -157,6 +196,24 @@ func (s *GameSession) resolvePlayerActions() {
}
}
// 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("All players fled!")
for _, p := range s.state.Players {
p.Fled = false
}
return
}
if len(intents) > 0 && len(s.state.Monsters) > 0 {
results := combat.ResolveAttacks(intents, s.state.Monsters)
for i, r := range results {
@@ -181,12 +238,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 {
@@ -202,6 +256,7 @@ func (s *GameSession) resolvePlayerActions() {
}
s.addLog(fmt.Sprintf("%s defeated! +%d gold", m.Name, goldReward))
if m.IsBoss {
s.state.BossKilled = true
s.grantBossRelic()
}
}
@@ -220,6 +275,9 @@ func (s *GameSession) resolvePlayerActions() {
if len(s.state.Monsters) == 0 {
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
s.addLog("Room cleared!")
for _, p := range s.state.Players {
p.Fled = false
}
if s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Type == dungeon.RoomBoss {
s.advanceFloor()
} else {
@@ -244,7 +302,9 @@ func (s *GameSession) advanceFloor() {
for _, p := range s.state.Players {
if p.IsDead() {
p.Revive(0.30)
s.addLog(fmt.Sprintf("✦ %s revived at %d HP!", p.Name, p.HP))
}
p.Fled = false
}
}
@@ -254,9 +314,12 @@ 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))
@@ -275,19 +338,51 @@ 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))
if p.IsDead() {
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
}
}
}
if m.IsBoss {
// Boss special pattern
switch m.Pattern {
case entity.PatternPoison:
for _, p := range s.state.Players {
if !p.IsOut() {
p.AddEffect(entity.ActiveEffect{Type: entity.StatusPoison, Duration: 3, Value: 5})
s.addLog(fmt.Sprintf("%s poisons %s!", m.Name, p.Name))
}
}
case entity.PatternBurn:
for _, p := range s.state.Players {
if !p.IsOut() {
p.AddEffect(entity.ActiveEffect{Type: entity.StatusBurn, Duration: 2, Value: 8})
s.addLog(fmt.Sprintf("%s burns %s!", m.Name, p.Name))
}
}
case entity.PatternHeal:
healAmt := m.MaxHP / 10
m.HP += healAmt
if m.HP > m.MaxHP {
m.HP = m.MaxHP
}
s.addLog(fmt.Sprintf("%s regenerates %d HP!", 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))
if p.IsDead() {
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
}
}
}
}
@@ -296,7 +391,7 @@ func (s *GameSession) resolveMonsterActions() {
allPlayersDead := true
for _, p := range s.state.Players {
if !p.IsDead() {
if !p.IsOut() {
allPlayersDead = false
break
}

1
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

2
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=

10
main.go
View File

@@ -7,6 +7,7 @@ import (
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/server"
"github.com/tolelom/catacombs/store"
"github.com/tolelom/catacombs/web"
)
func main() {
@@ -20,7 +21,14 @@ func main() {
lobby := game.NewLobby()
log.Println("Catacombs server starting on :2222")
// Start web terminal server in background
go func() {
if err := web.Start(":8080", 2222); err != nil {
log.Printf("Web server error: %v", err)
}
}()
log.Println("Catacombs server starting — SSH :2222, Web :8080")
if err := server.Start("0.0.0.0", 2222, lobby, db); err != nil {
log.Fatal(err)
}

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: "Dungeon Delver", Description: "Clear floor 5 for the first time"},
{ID: "boss_slayer", Name: "Boss Slayer", Description: "Defeat any boss"},
{ID: "floor10", Name: "Deep Explorer", Description: "Reach floor 10"},
{ID: "floor20", Name: "Conqueror", Description: "Conquer the Catacombs (floor 20)"},
{ID: "solo_clear", Name: "Lone Wolf", Description: "Clear floor 5 solo"},
{ID: "gold_hoarder", Name: "Gold Hoarder", Description: "Accumulate 200+ gold in one run"},
{ID: "no_death", Name: "Untouchable", Description: "Complete a floor without anyone dying"},
{ID: "full_party", Name: "Fellowship", Description: "Start a game with 4 players"},
{ID: "relic_collector", Name: "Relic Collector", Description: "Collect 3+ relics in one run"},
{ID: "flee_master", Name: "Tactical Retreat", Description: "Successfully flee from combat"},
}
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
}

View File

@@ -21,6 +21,7 @@ type RunRecord struct {
Player string `json:"player"`
Floor int `json:"floor"`
Score int `json:"score"`
Class string `json:"class,omitempty"`
}
func Open(path string) (*DB, error) {
@@ -35,6 +36,9 @@ func Open(path string) (*DB, error) {
if _, err := tx.CreateBucketIfNotExists(bucketRankings); err != nil {
return err
}
if _, err := tx.CreateBucketIfNotExists(bucketAchievements); err != nil {
return err
}
return nil
})
return &DB{db: db}, err
@@ -63,11 +67,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) 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}
data, err := json.Marshal(record)
if err != nil {
return err
@@ -76,6 +80,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")
db.SaveRun("Bob", 15, 1000, "Mage")
db.SaveRun("Charlie", 20, 2000, "Rogue")
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")
db.SaveRun("Alice", 10, 250, "Warrior")
db.SaveRun("Alice", 20, 500, "Warrior") // victory (floor >= 20)
db.SaveRun("Bob", 3, 50, "")
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)
}
}

33
ui/achievements_view.go Normal file
View File

@@ -0,0 +1,33 @@
package ui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/store"
)
func renderAchievements(playerName string, achievements []store.Achievement, width, height int) string {
title := styleHeader.Render("── Achievements ──")
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", unlocked, len(achievements))))
footer := styleSystem.Render("\n[A] Back")
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

@@ -11,11 +11,17 @@ import (
"github.com/tolelom/catacombs/game"
)
func renderGame(state game.GameState, width, height int, targetCursor int, moveCursor int) string {
func renderGame(state game.GameState, width, height int, targetCursor int, moveCursor int, chatting bool, chatInput string) string {
mapView := renderMap(state.Floor)
hudView := renderHUD(state, targetCursor, moveCursor)
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,8 +33,19 @@ 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("── Catacombs B%d: %s ── %d/%d Rooms ──", floor.Number, theme.Name, explored, total))
return header + "\n" + dungeon.RenderFloor(floor, floor.CurrentRoom, true)
}
@@ -58,57 +75,60 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int) string {
}
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
partyContent := renderPartyPanel(state.Players, state.SubmittedActions)
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
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
}
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(" Timer: %.1fs", 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 = "Skill: Taunt enemies attack you for 2 turns"
case entity.ClassMage:
skillDesc = "Skill: Fireball - AoE 0.8x dmg to all enemies"
skillDesc = "Skill: Fireball AoE 0.8x dmg to all enemies"
case entity.ClassHealer:
skillDesc = "Skill: Heal - restore 30 HP to an ally"
skillDesc = "Skill: Heal restore 30 HP to an ally"
case entity.ClassRogue:
skillDesc = "Skill: Scout - reveal neighboring rooms"
skillDesc = "Skill: Scout reveal neighboring rooms"
}
sb.WriteString(skillStyle.Render(skillDesc))
skillDesc += fmt.Sprintf(" (%d uses left)", p.SkillUses)
sb.WriteString(styleSystem.Render(skillDesc))
sb.WriteString("\n")
break
}
}
} else if state.Phase == game.PhaseExploring {
@@ -140,6 +160,9 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int) string {
sb.WriteString("[Up/Down] Select [Enter] Move [Q] Quit")
}
if state.Phase == game.PhaseCombat {
return sb.String()
}
return border.Render(sb.String())
}
@@ -147,15 +170,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, "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
}
return logStyle.Render(sb.String())
}
func renderHPBar(current, max, width int) string {
@@ -166,31 +212,100 @@ 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) 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))
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(" ... Waiting"))
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(" 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(fmt.Sprintf(" [TAUNTED %dt]", 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()
}

44
ui/help_view.go Normal file
View File

@@ -0,0 +1,44 @@
package ui
import (
"github.com/charmbracelet/lipgloss"
)
func renderHelp(width, height int) string {
title := styleHeader.Render("── Controls ──")
sections := []struct{ header, body string }{
{"Exploration", ` [Up/Down] Select room
[Enter] Move to room
[/] Chat
[Q] Quit`},
{"Combat", ` [1] Attack [2] Skill
[3] Use Item [4] Flee
[5] Defend [Tab] Switch Target
[/] Chat`},
{"Shop", ` [1-3] Buy item
[Q] Leave shop`},
{"Classes", ` Warrior 120HP 12ATK 8DEF Taunt (draw fire 2t)
Mage 70HP 20ATK 3DEF Fireball (AoE 0.8x)
Healer 90HP 8ATK 5DEF Heal (restore 30HP)
Rogue 85HP 15ATK 4DEF Scout (reveal rooms)`},
{"Tips", ` • Skills have 3 uses per combat
• Co-op bonus: 10% extra when 2+ attack same target
• Items are limited to 10 per player
• Dead players revive next floor at 30% HP`},
}
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] Back")
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, "", content, footer))
}

51
ui/leaderboard_view.go Normal file
View File

@@ -0,0 +1,51 @@
package ui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/store"
)
func renderLeaderboard(byFloor, byGold []store.RunRecord, width, height int) string {
title := styleHeader.Render("── Leaderboard ──")
// By Floor
var floorSection string
floorSection += styleCoop.Render(" Top by Floor") + "\n"
for i, r := range byFloor {
if i >= 5 {
break
}
medal := fmt.Sprintf(" %d.", i+1)
cls := ""
if r.Class != "" {
cls = fmt.Sprintf(" [%s]", r.Class)
}
floorSection += fmt.Sprintf(" %s %s%s B%d %s\n",
medal, stylePlayer.Render(r.Player), styleSystem.Render(cls),
r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score)))
}
// By Gold
var goldSection string
goldSection += styleCoop.Render("\n Top by Gold") + "\n"
for i, r := range byGold {
if i >= 5 {
break
}
medal := fmt.Sprintf(" %d.", i+1)
cls := ""
if r.Class != "" {
cls = fmt.Sprintf(" [%s]", r.Class)
}
goldSection += fmt.Sprintf(" %s %s%s B%d %s\n",
medal, stylePlayer.Render(r.Player), styleSystem.Render(cls),
r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score)))
}
footer := styleSystem.Render("\n[L] Back")
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, "", floorSection, goldSection, footer))
}

View File

@@ -15,15 +15,22 @@ type lobbyState struct {
roomName string
joining bool
codeInput string
online int
}
type roomInfo struct {
Code string
Name string
Players int
Players []playerInfo
Status string
}
type playerInfo struct {
Name string
Class string
Ready bool
}
func renderLobby(state lobbyState, width, height int) string {
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("205")).
@@ -33,7 +40,7 @@ func renderLobby(state lobbyState, width, height int) string {
Border(lipgloss.RoundedBorder()).
Padding(0, 1)
header := headerStyle.Render("── Lobby ──")
header := headerStyle.Render(fmt.Sprintf("── Lobby ── %d online ──", state.online))
menu := "[C] Create Room [J] Join by Code [Up/Down] Select [Enter] Join [Q] Back"
roomList := ""
@@ -43,7 +50,21 @@ 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!"

View File

@@ -20,6 +20,11 @@ const (
screenGame
screenShop
screenResult
screenHelp
screenStats
screenAchievements
screenLeaderboard
screenNickname
)
// StateUpdateMsg is sent by GameSession to update the view
@@ -47,6 +52,11 @@ type Model struct {
inputBuffer string
targetCursor int
moveCursor int // selected neighbor index during exploration
chatting bool
chatInput string
rankingSaved bool
shopMsg string
nicknameInput string
}
func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *store.DB) Model {
@@ -100,6 +110,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateShop(msg)
case screenResult:
return m.updateResult(msg)
case screenHelp:
return m.updateHelp(msg)
case screenStats:
return m.updateStats(msg)
case screenAchievements:
return m.updateAchievements(msg)
case screenLeaderboard:
return m.updateLeaderboard(msg)
case screenNickname:
return m.updateNickname(msg)
}
return m, nil
}
@@ -116,15 +136,38 @@ func (m Model) View() string {
case screenClassSelect:
return renderClassSelect(m.classState, m.width, m.height)
case screenGame:
return renderGame(m.gameState, m.width, m.height, m.targetCursor, m.moveCursor)
return renderGame(m.gameState, m.width, m.height, m.targetCursor, m.moveCursor, m.chatting, m.chatInput)
case screenShop:
return renderShop(m.gameState, m.width, m.height)
return renderShop(m.gameState, m.width, m.height, m.shopMsg)
case screenResult:
var rankings []store.RunRecord
if m.store != nil {
rankings, _ = m.store.TopRuns(10)
}
return renderResult(m.gameState.Victory, m.gameState.FloorNum, rankings)
return renderResult(m.gameState, rankings)
case screenHelp:
return renderHelp(m.width, m.height)
case screenStats:
var stats store.PlayerStats
if m.store != nil {
stats, _ = m.store.GetStats(m.playerName)
}
return renderStats(m.playerName, stats, m.width, m.height)
case screenAchievements:
var achievements []store.Achievement
if m.store != nil {
achievements, _ = m.store.GetAchievements(m.playerName)
}
return renderAchievements(m.playerName, achievements, m.width, m.height)
case screenLeaderboard:
var byFloor, byGold []store.RunRecord
if m.store != nil {
byFloor, _ = m.store.TopRuns(10)
byGold, _ = m.store.TopRunsByGold(10)
}
return renderLeaderboard(byFloor, byGold, m.width, m.height)
case screenNickname:
return renderNickname(m.nicknameInput, m.width, m.height)
}
return ""
}
@@ -158,21 +201,47 @@ func isDown(key tea.KeyMsg) bool {
func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isEnter(key) {
if m.fingerprint == "" {
m.fingerprint = fmt.Sprintf("anon-%d", time.Now().UnixNano())
}
if m.store != nil {
name, err := m.store.GetProfile(m.fingerprint)
if err != nil {
m.playerName = "Adventurer"
if m.store != nil && m.fingerprint != "" {
m.store.SaveProfile(m.fingerprint, m.playerName)
}
} else {
m.playerName = name
// First time player — show nickname input
m.screen = screenNickname
m.nicknameInput = ""
return m, nil
}
m.playerName = name
} else {
m.playerName = "Adventurer"
}
if m.lobby != nil {
m.lobby.PlayerOnline(m.fingerprint, m.playerName)
}
// Check for active session to reconnect
if m.lobby != nil {
code, session := m.lobby.GetActiveSession(m.fingerprint)
if session != nil {
m.roomCode = code
m.session = session
m.gameState = m.session.GetState()
m.screen = screenGame
m.session.TouchActivity(m.fingerprint)
m.session.SendChat("System", m.playerName+" reconnected!")
return m, m.pollState()
}
}
m.screen = screenLobby
m = m.withRefreshedLobby()
} else if isKey(key, "h") {
m.screen = screenHelp
} else if isKey(key, "s") {
m.screen = screenStats
} else if isKey(key, "a") {
m.screen = screenAchievements
} else if isKey(key, "l") {
m.screen = screenLeaderboard
} else if isQuit(key) {
return m, tea.Quit
}
@@ -180,13 +249,91 @@ func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m Model) updateNickname(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isEnter(key) && len(m.nicknameInput) > 0 {
m.playerName = m.nicknameInput
if m.store != nil && m.fingerprint != "" {
m.store.SaveProfile(m.fingerprint, m.playerName)
}
m.nicknameInput = ""
if m.lobby != nil {
m.lobby.PlayerOnline(m.fingerprint, m.playerName)
}
// Check for active session to reconnect
if m.lobby != nil {
code, session := m.lobby.GetActiveSession(m.fingerprint)
if session != nil {
m.roomCode = code
m.session = session
m.gameState = m.session.GetState()
m.screen = screenGame
m.session.TouchActivity(m.fingerprint)
m.session.SendChat("System", m.playerName+" reconnected!")
return m, m.pollState()
}
}
m.screen = screenLobby
m = m.withRefreshedLobby()
} else if isKey(key, "esc") || key.Type == tea.KeyEsc {
m.nicknameInput = ""
m.screen = screenTitle
} else if key.Type == tea.KeyBackspace && len(m.nicknameInput) > 0 {
m.nicknameInput = m.nicknameInput[:len(m.nicknameInput)-1]
} else if len(key.Runes) == 1 && len(m.nicknameInput) < 12 {
ch := string(key.Runes)
// Only allow alphanumeric and some special chars
if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 {
m.nicknameInput += ch
}
}
}
return m, nil
}
func (m Model) updateStats(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isKey(key, "s") || isEnter(key) || isQuit(key) {
m.screen = screenTitle
}
}
return m, nil
}
func (m Model) updateAchievements(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isKey(key, "a") || isEnter(key) || isQuit(key) {
m.screen = screenTitle
}
}
return m, nil
}
func (m Model) updateLeaderboard(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isKey(key, "l") || isEnter(key) || isQuit(key) {
m.screen = screenTitle
}
}
return m, nil
}
func (m Model) updateHelp(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isKey(key, "h") || isEnter(key) || isQuit(key) {
m.screen = screenTitle
}
}
return m, nil
}
func (m Model) updateLobby(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
// Join-by-code input mode
if m.lobbyState.joining {
if isEnter(key) && len(m.lobbyState.codeInput) == 4 {
if m.lobby != nil {
if err := m.lobby.JoinRoom(m.lobbyState.codeInput, m.playerName); err == nil {
if err := m.lobby.JoinRoom(m.lobbyState.codeInput, m.playerName, m.fingerprint); err == nil {
m.roomCode = m.lobbyState.codeInput
m.screen = screenClassSelect
}
@@ -208,7 +355,7 @@ func (m Model) updateLobby(msg tea.Msg) (tea.Model, tea.Cmd) {
if isKey(key, "c") {
if m.lobby != nil {
code := m.lobby.CreateRoom(m.playerName + "'s Room")
m.lobby.JoinRoom(code, m.playerName)
m.lobby.JoinRoom(code, m.playerName, m.fingerprint)
m.roomCode = code
m.screen = screenClassSelect
}
@@ -226,12 +373,15 @@ func (m Model) updateLobby(msg tea.Msg) (tea.Model, tea.Cmd) {
} 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 {
if err := m.lobby.JoinRoom(r.Code, m.playerName, m.fingerprint); err == nil {
m.roomCode = r.Code
m.screen = screenClassSelect
}
}
} else if isKey(key, "q") {
if m.lobby != nil {
m.lobby.PlayerOffline(m.fingerprint)
}
m.screen = screenTitle
}
}
@@ -251,6 +401,7 @@ func (m Model) updateClassSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
} else if isEnter(key) {
if m.lobby != nil {
selectedClass := classOptions[m.classState.cursor].class
m.lobby.SetPlayerClass(m.roomCode, m.fingerprint, selectedClass.String())
room := m.lobby.GetRoom(m.roomCode)
if room != nil {
if room.Session == nil {
@@ -260,7 +411,11 @@ func (m Model) updateClassSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
player := entity.NewPlayer(m.playerName, selectedClass)
player.Fingerprint = m.fingerprint
m.session.AddPlayer(player)
if m.lobby != nil {
m.lobby.RegisterSession(m.fingerprint, m.roomCode)
}
m.session.StartGame()
m.lobby.StartRoom(m.roomCode)
m.gameState = m.session.GetState()
m.screen = screenGame
}
@@ -280,18 +435,68 @@ func (m Model) pollState() tea.Cmd {
type tickMsg struct{}
func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.session != nil && m.fingerprint != "" {
m.session.TouchActivity(m.fingerprint)
}
// Refresh state on every update
if m.session != nil {
m.gameState = m.session.GetState()
// Clamp target cursor to valid range after monsters die
if len(m.gameState.Monsters) > 0 {
if m.targetCursor >= len(m.gameState.Monsters) {
m.targetCursor = len(m.gameState.Monsters) - 1
}
} else {
m.targetCursor = 0
}
}
if m.gameState.GameOver {
if m.store != nil {
if m.store != nil && !m.rankingSaved {
score := 0
for _, p := range m.gameState.Players {
score += p.Gold
}
m.store.SaveRun(m.playerName, m.gameState.FloorNum, score)
// Find the current player's class
playerClass := ""
for _, p := range m.gameState.Players {
if p.Fingerprint == m.fingerprint {
playerClass = p.Class.String()
break
}
}
m.store.SaveRun(m.playerName, m.gameState.FloorNum, score, playerClass)
// Check achievements
if m.gameState.FloorNum >= 5 {
m.store.UnlockAchievement(m.playerName, "first_clear")
}
if m.gameState.FloorNum >= 10 {
m.store.UnlockAchievement(m.playerName, "floor10")
}
if m.gameState.Victory {
m.store.UnlockAchievement(m.playerName, "floor20")
}
if m.gameState.SoloMode && m.gameState.FloorNum >= 5 {
m.store.UnlockAchievement(m.playerName, "solo_clear")
}
if m.gameState.BossKilled {
m.store.UnlockAchievement(m.playerName, "boss_slayer")
}
if m.gameState.FleeSucceeded {
m.store.UnlockAchievement(m.playerName, "flee_master")
}
for _, p := range m.gameState.Players {
if p.Gold >= 200 {
m.store.UnlockAchievement(p.Name, "gold_hoarder")
}
if len(p.Relics) >= 3 {
m.store.UnlockAchievement(p.Name, "relic_collector")
}
}
if len(m.gameState.Players) >= 4 {
m.store.UnlockAchievement(m.playerName, "full_party")
}
m.rankingSaved = true
}
m.screen = screenResult
return m, nil
@@ -303,16 +508,63 @@ func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case tickMsg:
// State already refreshed above, just keep polling during combat
if m.session != nil {
m.session.RevealNextLog()
}
// Keep polling during combat or while there are pending logs to reveal
if m.gameState.Phase == game.PhaseCombat {
return m, m.pollState()
}
if len(m.gameState.PendingLogs) > 0 {
return m, m.pollState()
}
return m, nil
}
if key, ok := msg.(tea.KeyMsg); ok {
// Chat mode
if m.chatting {
if isEnter(key) && len(m.chatInput) > 0 {
if m.session != nil {
m.session.SendChat(m.playerName, m.chatInput)
m.gameState = m.session.GetState()
}
m.chatting = false
m.chatInput = ""
} else if isKey(key, "esc") || key.Type == tea.KeyEsc {
m.chatting = false
m.chatInput = ""
} else if key.Type == tea.KeyBackspace && len(m.chatInput) > 0 {
m.chatInput = m.chatInput[:len(m.chatInput)-1]
} else if len(key.Runes) == 1 && len(m.chatInput) < 40 {
m.chatInput += string(key.Runes)
}
if m.gameState.Phase == game.PhaseCombat {
return m, m.pollState()
}
return m, nil
}
if isKey(key, "/") {
m.chatting = true
m.chatInput = ""
if m.gameState.Phase == game.PhaseCombat {
return m, m.pollState()
}
return m, nil
}
switch m.gameState.Phase {
case game.PhaseExploring:
// Dead players can only observe, not move
for _, p := range m.gameState.Players {
if p.Fingerprint == m.fingerprint && p.IsDead() {
if isQuit(key) {
return m, tea.Quit
}
return m, nil
}
}
neighbors := m.getNeighbors()
if isUp(key) {
if m.moveCursor > 0 {
@@ -338,7 +590,7 @@ func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
case game.PhaseCombat:
isPlayerDead := false
for _, p := range m.gameState.Players {
if p.Name == m.playerName && p.IsDead() {
if p.Fingerprint == m.fingerprint && p.IsDead() {
isPlayerDead = true
break
}
@@ -355,15 +607,15 @@ func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.session != nil {
switch key.String() {
case "1":
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionAttack, TargetIdx: m.targetCursor})
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionAttack, TargetIdx: m.targetCursor})
case "2":
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionSkill, TargetIdx: m.targetCursor})
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionSkill, TargetIdx: m.targetCursor})
case "3":
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionItem})
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionItem})
case "4":
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionFlee})
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionFlee})
case "5":
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionWait})
m.session.SubmitAction(m.fingerprint, game.PlayerAction{Type: game.ActionWait})
}
// After submitting, poll for turn resolution
return m, m.pollState()
@@ -390,7 +642,11 @@ func (m Model) updateShop(msg tea.Msg) (tea.Model, tea.Cmd) {
case "1", "2", "3":
if m.session != nil {
idx := int(key.String()[0] - '1')
m.session.BuyItem(m.playerName, idx)
if m.session.BuyItem(m.fingerprint, idx) {
m.shopMsg = "Purchased!"
} else {
m.shopMsg = "Not enough gold!"
}
m.gameState = m.session.GetState()
}
case "q":
@@ -407,6 +663,18 @@ func (m Model) updateShop(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) updateResult(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if isEnter(key) {
if m.lobby != nil && m.fingerprint != "" {
m.lobby.UnregisterSession(m.fingerprint)
}
if m.session != nil {
m.session.Stop()
m.session = nil
}
if m.lobby != nil && m.roomCode != "" {
m.lobby.RemoveRoom(m.roomCode)
}
m.roomCode = ""
m.rankingSaved = false
m.screen = screenLobby
m = m.withRefreshedLobby()
} else if isQuit(key) {
@@ -427,13 +695,18 @@ func (m Model) withRefreshedLobby() Model {
if r.Status == game.RoomPlaying {
status = "Playing"
}
players := make([]playerInfo, len(r.Players))
for j, p := range r.Players {
players[j] = playerInfo{Name: p.Name, Class: p.Class, Ready: p.Ready}
}
m.lobbyState.rooms[i] = roomInfo{
Code: r.Code,
Name: r.Name,
Players: len(r.Players),
Players: players,
Status: status,
}
}
m.lobbyState.online = len(m.lobby.ListOnline())
m.lobbyState.cursor = 0
return m
}

View File

@@ -28,14 +28,28 @@ func TestTitleToLobby(t *testing.T) {
t.Fatalf("initial screen: got %d, want screenTitle(0)", m.screen)
}
// 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.screen != screenNickname {
t.Errorf("after Enter (first time): screen=%d, want screenNickname(%d)", m2.screen, 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.screen != screenLobby {
t.Errorf("after nickname Enter: screen=%d, want screenLobby(1)", m3.screen)
}
if m3.playerName == "" {
t.Error("playerName should be set")
}
}
@@ -45,12 +59,20 @@ func TestLobbyCreateRoom(t *testing.T) {
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)
@@ -68,11 +90,19 @@ func TestClassSelectToGame(t *testing.T) {
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)

31
ui/nickname_view.go Normal file
View File

@@ -0,0 +1,31 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
func renderNickname(input string, width, height int) string {
title := styleHeader.Render("── Enter Your Name ──")
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 characters)", len(input)))
footer := styleAction.Render("[Enter] Confirm [Esc] Cancel")
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, "", inputBox, hint, "", footer))
}

View File

@@ -2,39 +2,56 @@ package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"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"))
func renderResult(state game.GameState, rankings []store.RunRecord) string {
var sb strings.Builder
var title string
if won {
title = titleStyle.Render("VICTORY! You escaped the Catacombs!")
// Title
if state.Victory {
sb.WriteString(styleHeal.Render("VICTORY ✦ ") + "\n\n")
sb.WriteString(styleSystem.Render(" You conquered the Catacombs!") + "\n\n")
} else {
title = titleStyle.Render("GAME OVER")
sb.WriteString(styleDamage.Render(" ✦ DEFEAT ✦ ") + "\n\n")
sb.WriteString(styleSystem.Render(fmt.Sprintf(" Fallen on floor B%d", state.FloorNum)) + "\n\n")
}
floorInfo := fmt.Sprintf("Floor Reached: B%d", floorReached)
// Player summary
sb.WriteString(styleHeader.Render("── Party Summary ──") + "\n\n")
totalGold := 0
for _, p := range state.Players {
status := styleHeal.Render("Alive")
if p.IsDead() {
status = styleDamage.Render("Dead")
}
sb.WriteString(fmt.Sprintf(" %s (%s) %s Gold: %d Items: %d Relics: %d\n",
stylePlayer.Render(p.Name), p.Class, status, p.Gold, len(p.Inventory), len(p.Relics)))
totalGold += p.Gold
}
sb.WriteString(fmt.Sprintf("\n Total Gold: %s\n", styleGold.Render(fmt.Sprintf("%d", totalGold))))
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)
// Rankings
if len(rankings) > 0 {
sb.WriteString("\n" + styleHeader.Render("── Top Runs ──") + "\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("🥉")
}
sb.WriteString(fmt.Sprintf(" %s %s Floor B%d Score: %d\n", medal, r.Player, r.Floor, r.Score))
}
}
menu := "[Enter] Return to Lobby [Q] Quit"
sb.WriteString("\n" + styleAction.Render(" [Enter] Return to Lobby") + "\n")
return lipgloss.JoinVertical(lipgloss.Center,
title,
"",
floorInfo,
"",
rankHeader,
rankList,
"",
menu,
)
return sb.String()
}

View File

@@ -4,27 +4,55 @@ import (
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/entity"
"github.com/tolelom/catacombs/game"
)
func renderShop(state game.GameState, width, height int) string {
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 ──")
// Show current player's gold
goldLine := ""
for _, p := range state.Players {
inventoryCount := len(p.Inventory)
goldLine += goldStyle.Render(fmt.Sprintf(" %s — Gold: %d Items: %d/10", p.Name, p.Gold, inventoryCount))
goldLine += "\n"
}
items := ""
for i, item := range state.ShopItems {
items += fmt.Sprintf(" [%d] %s (+%d) — %d gold\n", i+1, item.Name, item.Bonus, item.Price)
label := itemTypeLabel(item)
items += fmt.Sprintf(" [%d] %s %s — %d gold\n", i+1, item.Name, label, item.Price)
}
menu := "[1-3] Buy [Q] Leave Shop"
return lipgloss.JoinVertical(lipgloss.Left,
header,
"",
items,
"",
menu,
)
parts := []string{header, "", goldLine, items, "", menu}
if shopMsg != "" {
parts = append(parts, "", msgStyle.Render(shopMsg))
}
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}

30
ui/stats_view.go Normal file
View File

@@ -0,0 +1,30 @@
package ui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/store"
)
func renderStats(playerName string, stats store.PlayerStats, width, height int) string {
title := styleHeader.Render("── Player Statistics ──")
var content string
content += stylePlayer.Render(fmt.Sprintf(" %s", playerName)) + "\n\n"
content += fmt.Sprintf(" Total Runs: %s\n", styleGold.Render(fmt.Sprintf("%d", stats.TotalRuns)))
content += fmt.Sprintf(" Best Floor: %s\n", styleGold.Render(fmt.Sprintf("B%d", stats.BestFloor)))
content += fmt.Sprintf(" Total Gold: %s\n", styleGold.Render(fmt.Sprintf("%d", stats.TotalGold)))
content += fmt.Sprintf(" Victories: %s\n", styleHeal.Render(fmt.Sprintf("%d", stats.Victories)))
winRate := 0.0
if stats.TotalRuns > 0 {
winRate = float64(stats.Victories) / float64(stats.TotalRuns) * 100
}
content += fmt.Sprintf(" Win Rate: %s\n", styleSystem.Render(fmt.Sprintf("%.1f%%", winRate)))
footer := styleSystem.Render("[S] Back")
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,60 @@
package ui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
var titleArt = `
██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗
██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝
██║ ███████║ ██║ ███████║██║ ██║ ██║██╔████╔██║██████╔╝███████╗
██║ ██╔══██║ ██║ ██╔══██║██║ ██║ ██║██║╚██╔╝██║██╔══██╗╚════██║
╚██████╗██║ ██║ ██║ ██║ ██║╚██████╗╚██████╔╝██║ ╚═╝ ██║██████╔╝███████║
╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝
`
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("⚔ A Cooperative Dungeon Crawler ⚔")
server := lipgloss.NewStyle().
Foreground(colorCyan).
Render("ssh catacombs.tolelom.xyz")
menu := lipgloss.NewStyle().
Foreground(colorWhite).
Bold(true).
Align(lipgloss.Center)
Render("[Enter] Start [H] Help [S] Stats [A] Achievements [L] Leaderboard [Q] Quit")
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)
}

161
web/server.go Normal file
View File

@@ -0,0 +1,161 @@
package web
import (
"embed"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"sync"
"github.com/gorilla/websocket"
"golang.org/x/crypto/ssh"
)
//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.
func Start(addr string, sshPort int) error {
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)
})
log.Printf("Starting web terminal on %s", addr)
return http.ListenAndServe(addr, mux)
}
func handleWS(w http.ResponseWriter, r *http.Request, sshPort int) {
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade error: %v", err)
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 {
log.Printf("SSH dial error: %v", 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 {
log.Printf("SSH session error: %v", 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 {
log.Printf("PTY request error: %v", err)
return
}
stdin, err := session.StdinPipe()
if err != nil {
log.Printf("stdin pipe error: %v", err)
return
}
stdout, err := session.StdoutPipe()
if err != nil {
log.Printf("stdout pipe error: %v", err)
return
}
if err := session.Shell(); err != nil {
log.Printf("shell error: %v", 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.TextMessage, 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()
}

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

@@ -0,0 +1,120 @@
<!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.onmessage = (e) => {
term.write(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>